feat(admin): 관리자 로그인 소셜 → 아이디/비밀번호 (회원 인증과 분리)
Deploy (dev) / deploy (push) Failing after 11m12s
CI / build (push) Failing after 13m21s

- V9: admin_account(BCrypt) + admin_session 테이블 (회원 users/auth_session 과 분리)
- /api/admin/auth: login(아이디/비번)·me·logout, AdminAuthInterceptor 로 /api/admin/** 보호
- 회원 자격의 관리자 개념 제거: MemberAdmin self-lock 제거, dev-login(DevAuthController) 삭제, 구 AdminInterceptor 삭제
- 관리자는 이제 회원(users)이 아님 → 회원관리 목록에서 미노출
- 시드: admin_account(admin/admin1234)
- BCrypt용 spring-security-crypto (전체 Security 스타터 미사용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-12 00:09:27 +09:00
parent abb0c6a9f7
commit fe48d5c604
19 changed files with 452 additions and 144 deletions
+3
View File
@@ -38,6 +38,9 @@ dependencies {
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반 // Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
implementation 'com.nimbusds:nimbus-jose-jwt:9.40' implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
// 관리자 비밀번호 해시(BCrypt) — 전체 Security 스타터 없이 crypto 만 사용
implementation 'org.springframework.security:spring-security-crypto'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
// H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB // H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB
@@ -5,15 +5,12 @@ import com.dog.dognation.admin.dto.MemberDetail;
import com.dog.dognation.admin.dto.RoleUpdateRequest; import com.dog.dognation.admin.dto.RoleUpdateRequest;
import com.dog.dognation.admin.dto.StatusUpdateRequest; import com.dog.dognation.admin.dto.StatusUpdateRequest;
import com.dog.dognation.admin.dto.TierUpdateRequest; import com.dog.dognation.admin.dto.TierUpdateRequest;
import com.dog.dognation.auth.SessionUser;
import com.dog.dognation.auth.web.AuthInterceptor;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -21,8 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** /**
* 관리자용 회원 관리 API. (/api/admin/members — AdminInterceptor 로 ADMIN 만 접근) * 관리자용 회원 관리 API. (/api/admin/members — AdminAuthInterceptor 로 관리자만 접근)
* 관리자 페이지는 웹 전용.
*/ */
@RestController @RestController
@RequestMapping("/api/admin/members") @RequestMapping("/api/admin/members")
@@ -49,34 +45,23 @@ public class MemberAdminController {
} }
@PutMapping("/{id}/status") @PutMapping("/{id}/status")
public MemberAdminResponse updateStatus( public MemberAdminResponse updateStatus(@PathVariable Long id, @Valid @RequestBody StatusUpdateRequest req) {
@PathVariable Long id, return memberAdminService.updateStatus(id, req.status());
@Valid @RequestBody StatusUpdateRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return memberAdminService.updateStatus(id, req.status(), current.id());
} }
@PutMapping("/{id}/tier") @PutMapping("/{id}/tier")
public MemberAdminResponse updateTier( public MemberAdminResponse updateTier(@PathVariable Long id, @Valid @RequestBody TierUpdateRequest req) {
@PathVariable Long id, return memberAdminService.updateTier(id, req.tier());
@Valid @RequestBody TierUpdateRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return memberAdminService.updateTier(id, req.tier(), current.id());
} }
@PutMapping("/{id}/role") @PutMapping("/{id}/role")
public MemberAdminResponse updateRole( public MemberAdminResponse updateRole(@PathVariable Long id, @Valid @RequestBody RoleUpdateRequest req) {
@PathVariable Long id, return memberAdminService.updateRole(id, req.role());
@Valid @RequestBody RoleUpdateRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return memberAdminService.updateRole(id, req.role(), current.id());
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> delete( public ResponseEntity<Void> delete(@PathVariable Long id) {
@PathVariable Long id, memberAdminService.delete(id);
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
memberAdminService.delete(id, current.id());
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
} }
@@ -46,13 +46,10 @@ public class MemberAdminService {
} }
@Transactional @Transactional
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) { public MemberAdminResponse updateStatus(Long targetId, String status) {
if (!STATUSES.contains(status)) { if (!STATUSES.contains(status)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 상태입니다."); throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 상태입니다.");
} }
if (targetId.equals(currentAdminId)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "본인의 상태는 변경할 수 없습니다.");
}
User target = mustFind(targetId); User target = mustFind(targetId);
target.setStatus(status); target.setStatus(status);
if (!"ACTIVE".equals(status)) { if (!"ACTIVE".equals(status)) {
@@ -62,7 +59,7 @@ public class MemberAdminService {
} }
@Transactional @Transactional
public MemberAdminResponse updateTier(Long targetId, String tier, Long currentAdminId) { public MemberAdminResponse updateTier(Long targetId, String tier) {
SubscriptionTier parsed; SubscriptionTier parsed;
try { try {
parsed = SubscriptionTier.valueOf(tier); parsed = SubscriptionTier.valueOf(tier);
@@ -75,13 +72,10 @@ public class MemberAdminService {
} }
@Transactional @Transactional
public MemberAdminResponse updateRole(Long targetId, String role, Long currentAdminId) { public MemberAdminResponse updateRole(Long targetId, String role) {
if (!ROLES.contains(role)) { if (!ROLES.contains(role)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 역할입니다."); throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 역할입니다.");
} }
if (targetId.equals(currentAdminId)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "본인의 역할은 변경할 수 없습니다.");
}
User target = mustFind(targetId); User target = mustFind(targetId);
target.setRole(role); target.setRole(role);
sessionRepository.deleteByUserId(targetId); // 권한 변경 → 재로그인으로 새 권한 반영 sessionRepository.deleteByUserId(targetId); // 권한 변경 → 재로그인으로 새 권한 반영
@@ -89,10 +83,7 @@ public class MemberAdminService {
} }
@Transactional @Transactional
public void delete(Long targetId, Long currentAdminId) { public void delete(Long targetId) {
if (targetId.equals(currentAdminId)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다.");
}
User target = mustFind(targetId); User target = mustFind(targetId);
// 탈퇴 로그 기록(금일 탈퇴 통계) 후 삭제 // 탈퇴 로그 기록(금일 탈퇴 통계) 후 삭제
withdrawalLogRepository.save( withdrawalLogRepository.save(
@@ -0,0 +1,48 @@
package com.dog.dognation.adminauth;
import com.dog.dognation.adminauth.dto.AdminLoginRequest;
import com.dog.dognation.adminauth.dto.AdminLoginResponse;
import com.dog.dognation.adminauth.dto.AdminResponse;
import com.dog.dognation.adminauth.web.AdminAuthInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 관리자 인증 API.
* POST /api/admin/auth/login (비보호) — 아이디/비밀번호 로그인
* GET /api/admin/auth/me (보호)
* POST /api/admin/auth/logout (보호)
*/
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminAuthService adminAuthService;
public AdminAuthController(AdminAuthService adminAuthService) {
this.adminAuthService = adminAuthService;
}
@PostMapping("/login")
public AdminLoginResponse login(@Valid @RequestBody AdminLoginRequest req) {
return adminAuthService.login(req.username(), req.password());
}
@GetMapping("/me")
public AdminResponse me(@RequestAttribute(AdminAuthInterceptor.CURRENT_ADMIN) Long adminId) {
return adminAuthService.me(adminId);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
adminAuthService.logout(AdminAuthInterceptor.resolveToken(request));
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,91 @@
package com.dog.dognation.adminauth;
import com.dog.dognation.adminauth.dto.AdminLoginResponse;
import com.dog.dognation.adminauth.dto.AdminResponse;
import com.dog.dognation.common.exception.ApiException;
import com.dog.dognation.domain.adminauth.AdminAccount;
import com.dog.dognation.domain.adminauth.AdminAccountRepository;
import com.dog.dognation.domain.adminauth.AdminSession;
import com.dog.dognation.domain.adminauth.AdminSessionRepository;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 관리자 인증 — 아이디/비밀번호 로그인 + 세션(admin_session). 회원 인증과 완전 분리.
*/
@Service
public class AdminAuthService {
private static final Duration TTL = Duration.ofHours(8); // 관리자 세션 유효시간
private final AdminAccountRepository accountRepository;
private final AdminSessionRepository sessionRepository;
private final PasswordEncoder passwordEncoder;
public AdminAuthService(AdminAccountRepository accountRepository,
AdminSessionRepository sessionRepository,
PasswordEncoder passwordEncoder) {
this.accountRepository = accountRepository;
this.sessionRepository = sessionRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AdminLoginResponse login(String username, String password) {
AdminAccount admin = accountRepository.findByUsername(username).orElse(null);
if (admin == null || !passwordEncoder.matches(password, admin.getPasswordHash())) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.");
}
admin.markLogin();
String token = UUID.randomUUID().toString().replace("-", "");
sessionRepository.save(new AdminSession(token, admin.getId(), OffsetDateTime.now().plus(TTL)));
return new AdminLoginResponse(token, TTL.getSeconds(), AdminResponse.from(admin));
}
/** 토큰 → 관리자 id. 유효하면 슬라이딩 연장, 아니면 null. */
@Transactional
public Long resolve(String token) {
if (token == null || token.isBlank()) {
return null;
}
AdminSession session = sessionRepository.findById(token).orElse(null);
if (session == null) {
return null;
}
OffsetDateTime now = OffsetDateTime.now();
if (session.isExpired(now)) {
sessionRepository.deleteById(token);
return null;
}
session.extend(now.plus(TTL));
return session.getAdminId();
}
@Transactional(readOnly = true)
public AdminResponse me(Long adminId) {
return accountRepository.findById(adminId)
.map(AdminResponse::from)
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "세션이 유효하지 않습니다."));
}
@Transactional
public void logout(String token) {
if (token != null && !token.isBlank()) {
sessionRepository.deleteById(token);
}
}
@Scheduled(cron = "0 30 4 * * *", zone = "${app.scheduler.timezone:Asia/Seoul}")
@Transactional
public void cleanupExpired() {
sessionRepository.deleteByExpiresAtBefore(OffsetDateTime.now());
}
}
@@ -0,0 +1,9 @@
package com.dog.dognation.adminauth.dto;
import jakarta.validation.constraints.NotBlank;
/** 관리자 로그인 — 아이디/비밀번호. */
public record AdminLoginRequest(
@NotBlank(message = "아이디를 입력하세요.") String username,
@NotBlank(message = "비밀번호를 입력하세요.") String password) {
}
@@ -0,0 +1,5 @@
package com.dog.dognation.adminauth.dto;
/** 관리자 로그인 응답 — 세션 토큰 + 관리자 정보. */
public record AdminLoginResponse(String token, long expiresInSeconds, AdminResponse admin) {
}
@@ -0,0 +1,11 @@
package com.dog.dognation.adminauth.dto;
import com.dog.dognation.domain.adminauth.AdminAccount;
/** 관리자 정보 응답. */
public record AdminResponse(Long id, String username, String name) {
public static AdminResponse from(AdminAccount a) {
return new AdminResponse(a.getId(), a.getUsername(), a.getName());
}
}
@@ -0,0 +1,55 @@
package com.dog.dognation.adminauth.web;
import com.dog.dognation.adminauth.AdminAuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
/**
* 관리자 전용 경로 보호. admin_session 토큰(Bearer)을 검증하고 관리자 id 를 요청 속성으로 전달.
* 유효하지 않으면 401 JSON.
*/
@Component
public class AdminAuthInterceptor implements HandlerInterceptor {
public static final String CURRENT_ADMIN = "currentAdmin";
private final AdminAuthService adminAuthService;
public AdminAuthInterceptor(AdminAuthService adminAuthService) {
this.adminAuthService = adminAuthService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
Long adminId = adminAuthService.resolve(resolveToken(request));
if (adminId == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"관리자 로그인이 필요합니다.\"}");
return false;
}
request.setAttribute(CURRENT_ADMIN, adminId);
return true;
}
public static String resolveToken(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}
@@ -1,30 +0,0 @@
package com.dog.dognation.auth.web;
import com.dog.dognation.auth.SessionUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 관리자 전용 경로 보호. AuthInterceptor 다음에 실행되어 SessionUser 의 role 을 검사한다.
*/
@Component
public class AdminInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
Object attr = request.getAttribute(AuthInterceptor.CURRENT_MEMBER);
if (attr instanceof SessionUser user && user.isAdmin()) {
return true;
}
AuthInterceptor.write(response, HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다.");
return false;
}
}
@@ -1,14 +1,17 @@
package com.dog.dognation.config; package com.dog.dognation.config;
import com.dog.dognation.domain.adminauth.AdminAccount;
import com.dog.dognation.domain.adminauth.AdminAccountRepository;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* local 프로파일 전용 관리자 시드 (앱 시드 {@code LocalSeedData} 와 분리). * local 프로파일 전용 관리자 시드 (앱 시드 {@code LocalSeedData} 와 분리).
* 관리자 콘솔을 개발자 로그인(admin@dog.com)으로 확인하기 위한 관리자 + 더미 회원. * 관리자 계정(아이디/비밀번호) + 회원관리 표에 보일 더미 회원.
*/ */
@Component @Component
@Profile("local") @Profile("local")
@@ -16,24 +19,33 @@ import org.springframework.stereotype.Component;
public class AdminSeedData implements CommandLineRunner { public class AdminSeedData implements CommandLineRunner {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final AdminAccountRepository adminAccountRepository;
private final PasswordEncoder passwordEncoder;
public AdminSeedData(JdbcTemplate jdbc) { public AdminSeedData(JdbcTemplate jdbc, AdminAccountRepository adminAccountRepository,
PasswordEncoder passwordEncoder) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.adminAccountRepository = adminAccountRepository;
this.passwordEncoder = passwordEncoder;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
insertUser("admin@dog.com", "관리자", "PREMIUM", "ADMIN"); // 관리자 계정 (아이디/비밀번호) — 로컬 확인용
// 회원관리 표에 보일 더미 회원 if (!adminAccountRepository.existsByUsername("admin")) {
insertUser("dol@dog.com", "돌이", "FREE", "USER"); adminAccountRepository.save(new AdminAccount(
insertUser("byeol@dog.com", "별이", "AD_FREE", "USER"); "admin", passwordEncoder.encode("admin1234"), "관리자"));
System.out.println("[admin seed] admin@dog.com(ADMIN) + 더미 회원 준비 완료"); }
// 회원관리 표에 보일 더미 회원(소셜)
insertUser("dol@dog.com", "돌이", "FREE");
insertUser("byeol@dog.com", "별이", "AD_FREE");
System.out.println("[admin seed] admin_account(admin/admin1234) + 더미 회원 준비 완료");
} }
private void insertUser(String email, String nickname, String tier, String role) { private void insertUser(String email, String nickname, String tier) {
jdbc.update(""" jdbc.update("""
INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at)
VALUES (?, ?, ?, 'ACTIVE', ?, 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", email, nickname, tier, role); """, email, nickname, tier);
} }
} }
@@ -1,57 +0,0 @@
package com.dog.dognation.config;
import com.dog.dognation.auth.dto.LoginResponse;
import com.dog.dognation.auth.dto.MemberResponse;
import com.dog.dognation.common.exception.ApiException;
import com.dog.dognation.domain.auth.AuthSession;
import com.dog.dognation.domain.auth.AuthSessionRepository;
import com.dog.dognation.domain.user.User;
import com.dog.dognation.domain.user.UserRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
* 개발자 로그인 — <b>local 프로파일 전용</b>. 구글/애플 없이 이메일로 세션을 발급한다.
* 관리자 콘솔을 로컬에서 확인하기 위한 용도(예: admin@dog.com). 운영 빌드에는 등록되지 않는다.
*/
@Profile("local")
@RestController
@RequestMapping("/api/auth")
public class DevAuthController {
private static final Duration TTL = Duration.ofDays(30);
private final UserRepository userRepository;
private final AuthSessionRepository sessionRepository;
public DevAuthController(UserRepository userRepository, AuthSessionRepository sessionRepository) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
}
/** 이메일로 세션 발급 (검증 없음, local 전용). */
@PostMapping("/dev-login")
@Transactional
public LoginResponse devLogin(@RequestBody Map<String, String> body) {
String email = body.get("email");
if (email == null || email.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "email 이 필요합니다.");
}
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다: " + email));
String token = UUID.randomUUID().toString().replace("-", "");
sessionRepository.save(AuthSession.issue(token, user, true, OffsetDateTime.now().plus(TTL)));
return new LoginResponse(token, TTL.getSeconds(), MemberResponse.from(user));
}
}
@@ -0,0 +1,19 @@
package com.dog.dognation.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 관리자 비밀번호 해시용 PasswordEncoder (BCrypt).
* 전체 Spring Security 스타터는 사용하지 않으므로 자동 보안 설정은 적용되지 않는다.
*/
@Configuration
public class SecurityCryptoConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -1,6 +1,6 @@
package com.dog.dognation.config; package com.dog.dognation.config;
import com.dog.dognation.auth.web.AdminInterceptor; import com.dog.dognation.adminauth.web.AdminAuthInterceptor;
import com.dog.dognation.auth.web.AuthInterceptor; import com.dog.dognation.auth.web.AuthInterceptor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -9,17 +9,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* 웹 계층 설정 — CORS 허용 + 인증/인가 인터셉터 등록. * 웹 계층 설정 — CORS 허용 + 인증/인가 인터셉터 등록.
* 소셜 로그인 엔드포인트(/api/auth/google, /apple, /google-client-id)는 비보호. * 회원(소셜) 인증과 관리자(아이디/비번) 인증은 분리된다.
*/ */
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor; private final AuthInterceptor authInterceptor;
private final AdminInterceptor adminInterceptor; private final AdminAuthInterceptor adminAuthInterceptor;
public WebConfig(AuthInterceptor authInterceptor, AdminInterceptor adminInterceptor) { public WebConfig(AuthInterceptor authInterceptor, AdminAuthInterceptor adminAuthInterceptor) {
this.authInterceptor = authInterceptor; this.authInterceptor = authInterceptor;
this.adminInterceptor = adminInterceptor; this.adminAuthInterceptor = adminAuthInterceptor;
} }
/** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ /** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */
@@ -39,12 +39,13 @@ public class WebConfig implements WebMvcConfigurer {
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) // 회원(소셜) 인증
registry.addInterceptor(authInterceptor) registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**", .addPathPatterns("/api/auth/me", "/api/auth/logout",
"/api/dogs/**", "/api/chat/**", "/api/reports"); "/api/dogs/**", "/api/chat/**", "/api/reports");
// 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사) // 관리자(아이디/비번) 인증 — /api/admin/** 보호, 로그인만 비보호
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminAuthInterceptor)
.addPathPatterns("/api/admin/**"); .addPathPatterns("/api/admin/**")
.excludePathPatterns("/api/admin/auth/login");
} }
} }
@@ -0,0 +1,67 @@
package com.dog.dognation.domain.adminauth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
/**
* 관리자 계정 — 아이디/비밀번호 로그인. 회원(users)과 분리된다.
*/
@Entity
@Table(name = "admin_account")
public class AdminAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(nullable = false, length = 50)
private String name;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "last_login_at")
private OffsetDateTime lastLoginAt;
protected AdminAccount() {
}
public AdminAccount(String username, String passwordHash, String name) {
this.username = username;
this.passwordHash = passwordHash;
this.name = name;
this.createdAt = OffsetDateTime.now();
}
public void markLogin() {
this.lastLoginAt = OffsetDateTime.now();
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,12 @@
package com.dog.dognation.domain.adminauth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AdminAccountRepository extends JpaRepository<AdminAccount, Long> {
Optional<AdminAccount> findByUsername(String username);
boolean existsByUsername(String username);
}
@@ -0,0 +1,55 @@
package com.dog.dognation.domain.adminauth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
/**
* 관리자 세션. Bearer 토큰 기준, 슬라이딩 만료. 회원 세션과 분리.
*/
@Entity
@Table(name = "admin_session")
public class AdminSession {
@Id
@Column(length = 64)
private String token;
@Column(name = "admin_id", nullable = false)
private Long adminId;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
protected AdminSession() {
}
public AdminSession(String token, Long adminId, OffsetDateTime expiresAt) {
this.token = token;
this.adminId = adminId;
this.expiresAt = expiresAt;
this.createdAt = OffsetDateTime.now();
}
public boolean isExpired(OffsetDateTime now) {
return expiresAt == null || expiresAt.isBefore(now);
}
public void extend(OffsetDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public String getToken() {
return token;
}
public Long getAdminId() {
return adminId;
}
}
@@ -0,0 +1,10 @@
package com.dog.dognation.domain.adminauth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.OffsetDateTime;
public interface AdminSessionRepository extends JpaRepository<AdminSession, String> {
int deleteByExpiresAtBefore(OffsetDateTime now);
}
@@ -0,0 +1,21 @@
-- ============================================================
-- V9: 관리자 계정(아이디/비밀번호 로그인) — 회원(users)과 분리
-- ============================================================
CREATE TABLE admin_account (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- BCrypt
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
-- 관리자 세션 (회원 auth_session 과 분리)
CREATE TABLE admin_session (
token VARCHAR(64) PRIMARY KEY,
admin_id BIGINT NOT NULL REFERENCES admin_account(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_admin_session_expires ON admin_session(expires_at);