feat(admin): 관리자 로그인 소셜 → 아이디/비밀번호 (회원 인증과 분리)
- 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:
@@ -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);
|
||||||
Reference in New Issue
Block a user