From fe48d5c604677e35f632ab370affc0fb7f009fe1 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 12 Jul 2026 00:09:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=86=8C=EC=85=9C=20=E2=86=92=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EB=94=94/=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20(=ED=9A=8C=EC=9B=90=20=EC=9D=B8=EC=A6=9D=EA=B3=BC?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- build.gradle | 3 + .../admin/MemberAdminController.java | 33 ++----- .../dognation/admin/MemberAdminService.java | 17 +--- .../adminauth/AdminAuthController.java | 48 ++++++++++ .../dognation/adminauth/AdminAuthService.java | 91 +++++++++++++++++++ .../adminauth/dto/AdminLoginRequest.java | 9 ++ .../adminauth/dto/AdminLoginResponse.java | 5 + .../adminauth/dto/AdminResponse.java | 11 +++ .../adminauth/web/AdminAuthInterceptor.java | 55 +++++++++++ .../dognation/auth/web/AdminInterceptor.java | 30 ------ .../dog/dognation/config/AdminSeedData.java | 32 +++++-- .../dognation/config/DevAuthController.java | 57 ------------ .../config/SecurityCryptoConfig.java | 19 ++++ .../com/dog/dognation/config/WebConfig.java | 21 +++-- .../domain/adminauth/AdminAccount.java | 67 ++++++++++++++ .../adminauth/AdminAccountRepository.java | 12 +++ .../domain/adminauth/AdminSession.java | 55 +++++++++++ .../adminauth/AdminSessionRepository.java | 10 ++ .../db/migration/V9__admin_account.sql | 21 +++++ 19 files changed, 452 insertions(+), 144 deletions(-) create mode 100644 src/main/java/com/dog/dognation/adminauth/AdminAuthController.java create mode 100644 src/main/java/com/dog/dognation/adminauth/AdminAuthService.java create mode 100644 src/main/java/com/dog/dognation/adminauth/dto/AdminLoginRequest.java create mode 100644 src/main/java/com/dog/dognation/adminauth/dto/AdminLoginResponse.java create mode 100644 src/main/java/com/dog/dognation/adminauth/dto/AdminResponse.java create mode 100644 src/main/java/com/dog/dognation/adminauth/web/AdminAuthInterceptor.java delete mode 100644 src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java delete mode 100644 src/main/java/com/dog/dognation/config/DevAuthController.java create mode 100644 src/main/java/com/dog/dognation/config/SecurityCryptoConfig.java create mode 100644 src/main/java/com/dog/dognation/domain/adminauth/AdminAccount.java create mode 100644 src/main/java/com/dog/dognation/domain/adminauth/AdminAccountRepository.java create mode 100644 src/main/java/com/dog/dognation/domain/adminauth/AdminSession.java create mode 100644 src/main/java/com/dog/dognation/domain/adminauth/AdminSessionRepository.java create mode 100644 src/main/resources/db/migration/V9__admin_account.sql diff --git a/build.gradle b/build.gradle index 0b9c9ee..b072d42 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,9 @@ dependencies { // Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반 implementation 'com.nimbusds:nimbus-jose-jwt:9.40' + // 관리자 비밀번호 해시(BCrypt) — 전체 Security 스타터 없이 crypto 만 사용 + implementation 'org.springframework.security:spring-security-crypto' + runtimeOnly 'org.postgresql:postgresql' // H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB diff --git a/src/main/java/com/dog/dognation/admin/MemberAdminController.java b/src/main/java/com/dog/dognation/admin/MemberAdminController.java index b493192..bfe9a56 100644 --- a/src/main/java/com/dog/dognation/admin/MemberAdminController.java +++ b/src/main/java/com/dog/dognation/admin/MemberAdminController.java @@ -5,15 +5,12 @@ import com.dog.dognation.admin.dto.MemberDetail; import com.dog.dognation.admin.dto.RoleUpdateRequest; import com.dog.dognation.admin.dto.StatusUpdateRequest; 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 org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; 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.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,8 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; /** - * 관리자용 회원 관리 API. (/api/admin/members — AdminInterceptor 로 ADMIN 만 접근) - * 관리자 페이지는 웹 전용. + * 관리자용 회원 관리 API. (/api/admin/members — AdminAuthInterceptor 로 관리자만 접근) */ @RestController @RequestMapping("/api/admin/members") @@ -49,34 +45,23 @@ public class MemberAdminController { } @PutMapping("/{id}/status") - public MemberAdminResponse updateStatus( - @PathVariable Long id, - @Valid @RequestBody StatusUpdateRequest req, - @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return memberAdminService.updateStatus(id, req.status(), current.id()); + public MemberAdminResponse updateStatus(@PathVariable Long id, @Valid @RequestBody StatusUpdateRequest req) { + return memberAdminService.updateStatus(id, req.status()); } @PutMapping("/{id}/tier") - public MemberAdminResponse updateTier( - @PathVariable Long id, - @Valid @RequestBody TierUpdateRequest req, - @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return memberAdminService.updateTier(id, req.tier(), current.id()); + public MemberAdminResponse updateTier(@PathVariable Long id, @Valid @RequestBody TierUpdateRequest req) { + return memberAdminService.updateTier(id, req.tier()); } @PutMapping("/{id}/role") - public MemberAdminResponse updateRole( - @PathVariable Long id, - @Valid @RequestBody RoleUpdateRequest req, - @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return memberAdminService.updateRole(id, req.role(), current.id()); + public MemberAdminResponse updateRole(@PathVariable Long id, @Valid @RequestBody RoleUpdateRequest req) { + return memberAdminService.updateRole(id, req.role()); } @DeleteMapping("/{id}") - public ResponseEntity delete( - @PathVariable Long id, - @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - memberAdminService.delete(id, current.id()); + public ResponseEntity delete(@PathVariable Long id) { + memberAdminService.delete(id); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/dog/dognation/admin/MemberAdminService.java b/src/main/java/com/dog/dognation/admin/MemberAdminService.java index e3120bf..adaa729 100644 --- a/src/main/java/com/dog/dognation/admin/MemberAdminService.java +++ b/src/main/java/com/dog/dognation/admin/MemberAdminService.java @@ -46,13 +46,10 @@ public class MemberAdminService { } @Transactional - public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) { + public MemberAdminResponse updateStatus(Long targetId, String status) { if (!STATUSES.contains(status)) { throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 상태입니다."); } - if (targetId.equals(currentAdminId)) { - throw new ApiException(HttpStatus.BAD_REQUEST, "본인의 상태는 변경할 수 없습니다."); - } User target = mustFind(targetId); target.setStatus(status); if (!"ACTIVE".equals(status)) { @@ -62,7 +59,7 @@ public class MemberAdminService { } @Transactional - public MemberAdminResponse updateTier(Long targetId, String tier, Long currentAdminId) { + public MemberAdminResponse updateTier(Long targetId, String tier) { SubscriptionTier parsed; try { parsed = SubscriptionTier.valueOf(tier); @@ -75,13 +72,10 @@ public class MemberAdminService { } @Transactional - public MemberAdminResponse updateRole(Long targetId, String role, Long currentAdminId) { + public MemberAdminResponse updateRole(Long targetId, String role) { if (!ROLES.contains(role)) { throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 역할입니다."); } - if (targetId.equals(currentAdminId)) { - throw new ApiException(HttpStatus.BAD_REQUEST, "본인의 역할은 변경할 수 없습니다."); - } User target = mustFind(targetId); target.setRole(role); sessionRepository.deleteByUserId(targetId); // 권한 변경 → 재로그인으로 새 권한 반영 @@ -89,10 +83,7 @@ public class MemberAdminService { } @Transactional - public void delete(Long targetId, Long currentAdminId) { - if (targetId.equals(currentAdminId)) { - throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다."); - } + public void delete(Long targetId) { User target = mustFind(targetId); // 탈퇴 로그 기록(금일 탈퇴 통계) 후 삭제 withdrawalLogRepository.save( diff --git a/src/main/java/com/dog/dognation/adminauth/AdminAuthController.java b/src/main/java/com/dog/dognation/adminauth/AdminAuthController.java new file mode 100644 index 0000000..2a753e8 --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/AdminAuthController.java @@ -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 logout(HttpServletRequest request) { + adminAuthService.logout(AdminAuthInterceptor.resolveToken(request)); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dog/dognation/adminauth/AdminAuthService.java b/src/main/java/com/dog/dognation/adminauth/AdminAuthService.java new file mode 100644 index 0000000..bea638f --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/AdminAuthService.java @@ -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()); + } +} diff --git a/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginRequest.java b/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginRequest.java new file mode 100644 index 0000000..c0297f8 --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginRequest.java @@ -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) { +} diff --git a/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginResponse.java b/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginResponse.java new file mode 100644 index 0000000..e109a2e --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/dto/AdminLoginResponse.java @@ -0,0 +1,5 @@ +package com.dog.dognation.adminauth.dto; + +/** 관리자 로그인 응답 — 세션 토큰 + 관리자 정보. */ +public record AdminLoginResponse(String token, long expiresInSeconds, AdminResponse admin) { +} diff --git a/src/main/java/com/dog/dognation/adminauth/dto/AdminResponse.java b/src/main/java/com/dog/dognation/adminauth/dto/AdminResponse.java new file mode 100644 index 0000000..fdcdc70 --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/dto/AdminResponse.java @@ -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()); + } +} diff --git a/src/main/java/com/dog/dognation/adminauth/web/AdminAuthInterceptor.java b/src/main/java/com/dog/dognation/adminauth/web/AdminAuthInterceptor.java new file mode 100644 index 0000000..c457f22 --- /dev/null +++ b/src/main/java/com/dog/dognation/adminauth/web/AdminAuthInterceptor.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java b/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java deleted file mode 100644 index 02edf53..0000000 --- a/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java +++ /dev/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; - } -} diff --git a/src/main/java/com/dog/dognation/config/AdminSeedData.java b/src/main/java/com/dog/dognation/config/AdminSeedData.java index 6f0cad8..b7c4baf 100644 --- a/src/main/java/com/dog/dognation/config/AdminSeedData.java +++ b/src/main/java/com/dog/dognation/config/AdminSeedData.java @@ -1,14 +1,17 @@ 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.context.annotation.Profile; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; /** * local 프로파일 전용 관리자 시드 (앱 시드 {@code LocalSeedData} 와 분리). - * 관리자 콘솔을 개발자 로그인(admin@dog.com)으로 확인하기 위한 관리자 + 더미 회원. + * 관리자 계정(아이디/비밀번호) + 회원관리 표에 보일 더미 회원. */ @Component @Profile("local") @@ -16,24 +19,33 @@ import org.springframework.stereotype.Component; public class AdminSeedData implements CommandLineRunner { 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.adminAccountRepository = adminAccountRepository; + this.passwordEncoder = passwordEncoder; } @Override public void run(String... args) { - insertUser("admin@dog.com", "관리자", "PREMIUM", "ADMIN"); - // 회원관리 표에 보일 더미 회원 - insertUser("dol@dog.com", "돌이", "FREE", "USER"); - insertUser("byeol@dog.com", "별이", "AD_FREE", "USER"); - System.out.println("[admin seed] admin@dog.com(ADMIN) + 더미 회원 준비 완료"); + // 관리자 계정 (아이디/비밀번호) — 로컬 확인용 + if (!adminAccountRepository.existsByUsername("admin")) { + adminAccountRepository.save(new AdminAccount( + "admin", passwordEncoder.encode("admin1234"), "관리자")); + } + // 회원관리 표에 보일 더미 회원(소셜) + 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(""" INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) - VALUES (?, ?, ?, 'ACTIVE', ?, 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, email, nickname, tier, role); + VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, email, nickname, tier); } } diff --git a/src/main/java/com/dog/dognation/config/DevAuthController.java b/src/main/java/com/dog/dognation/config/DevAuthController.java deleted file mode 100644 index f912255..0000000 --- a/src/main/java/com/dog/dognation/config/DevAuthController.java +++ /dev/null @@ -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; - -/** - * 개발자 로그인 — local 프로파일 전용. 구글/애플 없이 이메일로 세션을 발급한다. - * 관리자 콘솔을 로컬에서 확인하기 위한 용도(예: 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 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)); - } -} diff --git a/src/main/java/com/dog/dognation/config/SecurityCryptoConfig.java b/src/main/java/com/dog/dognation/config/SecurityCryptoConfig.java new file mode 100644 index 0000000..278ac67 --- /dev/null +++ b/src/main/java/com/dog/dognation/config/SecurityCryptoConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java index c25b7a1..1a56ca9 100644 --- a/src/main/java/com/dog/dognation/config/WebConfig.java +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -1,6 +1,6 @@ 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 org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; @@ -9,17 +9,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 웹 계층 설정 — CORS 허용 + 인증/인가 인터셉터 등록. - * 소셜 로그인 엔드포인트(/api/auth/google, /apple, /google-client-id)는 비보호. + * 회원(소셜) 인증과 관리자(아이디/비번) 인증은 분리된다. */ @Configuration public class WebConfig implements WebMvcConfigurer { 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.adminInterceptor = adminInterceptor; + this.adminAuthInterceptor = adminAuthInterceptor; } /** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ @@ -39,12 +39,13 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) + // 회원(소셜) 인증 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"); - // 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사) - registry.addInterceptor(adminInterceptor) - .addPathPatterns("/api/admin/**"); + // 관리자(아이디/비번) 인증 — /api/admin/** 보호, 로그인만 비보호 + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api/admin/**") + .excludePathPatterns("/api/admin/auth/login"); } } diff --git a/src/main/java/com/dog/dognation/domain/adminauth/AdminAccount.java b/src/main/java/com/dog/dognation/domain/adminauth/AdminAccount.java new file mode 100644 index 0000000..d26b8c5 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/adminauth/AdminAccount.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/domain/adminauth/AdminAccountRepository.java b/src/main/java/com/dog/dognation/domain/adminauth/AdminAccountRepository.java new file mode 100644 index 0000000..97c37dc --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/adminauth/AdminAccountRepository.java @@ -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 { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/dog/dognation/domain/adminauth/AdminSession.java b/src/main/java/com/dog/dognation/domain/adminauth/AdminSession.java new file mode 100644 index 0000000..e2d7bdc --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/adminauth/AdminSession.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/domain/adminauth/AdminSessionRepository.java b/src/main/java/com/dog/dognation/domain/adminauth/AdminSessionRepository.java new file mode 100644 index 0000000..c3e19f3 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/adminauth/AdminSessionRepository.java @@ -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 { + + int deleteByExpiresAtBefore(OffsetDateTime now); +} diff --git a/src/main/resources/db/migration/V9__admin_account.sql b/src/main/resources/db/migration/V9__admin_account.sql new file mode 100644 index 0000000..60d95ba --- /dev/null +++ b/src/main/resources/db/migration/V9__admin_account.sql @@ -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);