diff --git a/build.gradle b/build.gradle index 2169fd7..780d8e0 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,9 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' + // Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반 + implementation 'com.nimbusds:nimbus-jose-jwt:9.40' + 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 new file mode 100644 index 0000000..9c720ea --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/MemberAdminController.java @@ -0,0 +1,72 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.MemberAdminResponse; +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; + +import java.util.List; + +/** + * 관리자용 회원 관리 API. (/api/admin/members — AdminInterceptor 로 ADMIN 만 접근) + * 관리자 페이지는 웹 전용. + */ +@RestController +@RequestMapping("/api/admin/members") +public class MemberAdminController { + + private final MemberAdminService memberAdminService; + + public MemberAdminController(MemberAdminService memberAdminService) { + this.memberAdminService = memberAdminService; + } + + @GetMapping + public List list() { + return memberAdminService.list(); + } + + @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()); + } + + @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()); + } + + @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()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + memberAdminService.delete(id, current.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 new file mode 100644 index 0000000..4814186 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/MemberAdminService.java @@ -0,0 +1,99 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.MemberAdminResponse; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.auth.AuthSessionRepository; +import com.dog.dognation.domain.user.SubscriptionTier; +import com.dog.dognation.domain.user.User; +import com.dog.dognation.domain.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +/** + * 관리자용 회원 관리 서비스 (목록/상태/티어/역할/삭제). + * 본인 계정의 상태·역할·삭제는 잠금 방지를 위해 차단한다. + * 상태 정지/삭제/역할 변경 시 대상 회원의 세션을 즉시 무효화한다. + */ +@Service +public class MemberAdminService { + + private static final Set STATUSES = Set.of("ACTIVE", "DORMANT", "BANNED"); + private static final Set ROLES = Set.of("USER", "ADMIN"); + + private final UserRepository userRepository; + private final AuthSessionRepository sessionRepository; + + public MemberAdminService(UserRepository userRepository, AuthSessionRepository sessionRepository) { + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + } + + @Transactional(readOnly = true) + public List list() { + return userRepository.findAllByOrderByCreatedAtDesc().stream() + .map(MemberAdminResponse::from) + .toList(); + } + + @Transactional + public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) { + 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)) { + sessionRepository.deleteByUserId(targetId); // 정지/휴면 → 강제 로그아웃 + } + return MemberAdminResponse.from(target); + } + + @Transactional + public MemberAdminResponse updateTier(Long targetId, String tier, Long currentAdminId) { + SubscriptionTier parsed; + try { + parsed = SubscriptionTier.valueOf(tier); + } catch (IllegalArgumentException e) { + throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 구독 티어입니다."); + } + User target = mustFind(targetId); + target.setSubscriptionTier(parsed); + return MemberAdminResponse.from(target); + } + + @Transactional + public MemberAdminResponse updateRole(Long targetId, String role, Long currentAdminId) { + 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); // 권한 변경 → 재로그인으로 새 권한 반영 + return MemberAdminResponse.from(target); + } + + @Transactional + public void delete(Long targetId, Long currentAdminId) { + if (targetId.equals(currentAdminId)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다."); + } + User target = mustFind(targetId); + sessionRepository.deleteByUserId(targetId); + userRepository.delete(target); + } + + private User mustFind(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/MemberAdminResponse.java b/src/main/java/com/dog/dognation/admin/dto/MemberAdminResponse.java new file mode 100644 index 0000000..c05911b --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/MemberAdminResponse.java @@ -0,0 +1,33 @@ +package com.dog.dognation.admin.dto; + +import com.dog.dognation.domain.user.User; + +import java.time.OffsetDateTime; + +/** + * 관리자용 회원 정보 응답. + */ +public record MemberAdminResponse( + Long id, + String email, + String nickname, + String role, // USER / ADMIN + String provider, // GOOGLE / APPLE + String subscriptionTier, // FREE / AD_FREE / PREMIUM + String status, // ACTIVE / DORMANT / BANNED + String profileImage, + OffsetDateTime createdAt) { + + public static MemberAdminResponse from(User u) { + return new MemberAdminResponse( + u.getId(), + u.getEmail(), + u.getNickname(), + u.getRole(), + u.getProvider(), + u.getSubscriptionTier().name(), + u.getStatus(), + u.getProfileImage(), + u.getCreatedAt()); + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/RoleUpdateRequest.java b/src/main/java/com/dog/dognation/admin/dto/RoleUpdateRequest.java new file mode 100644 index 0000000..022f0c6 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/RoleUpdateRequest.java @@ -0,0 +1,7 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 회원 권한 변경 — USER / ADMIN. */ +public record RoleUpdateRequest(@NotBlank(message = "역할을 입력하세요.") String role) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/StatusUpdateRequest.java b/src/main/java/com/dog/dognation/admin/dto/StatusUpdateRequest.java new file mode 100644 index 0000000..2a35773 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/StatusUpdateRequest.java @@ -0,0 +1,7 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 회원 상태 변경 — ACTIVE / DORMANT / BANNED. */ +public record StatusUpdateRequest(@NotBlank(message = "상태를 입력하세요.") String status) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/TierUpdateRequest.java b/src/main/java/com/dog/dognation/admin/dto/TierUpdateRequest.java new file mode 100644 index 0000000..599257d --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/TierUpdateRequest.java @@ -0,0 +1,7 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 회원 구독 티어 변경 — FREE / AD_FREE / PREMIUM. */ +public record TierUpdateRequest(@NotBlank(message = "구독 티어를 입력하세요.") String tier) { +} diff --git a/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java b/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java index ad015db..a7f871f 100644 --- a/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java +++ b/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java @@ -1,5 +1,6 @@ package com.dog.dognation.api; +import com.dog.dognation.common.exception.ApiException; import com.dog.dognation.domain.matching.QuotaExceededException; import jakarta.persistence.EntityNotFoundException; import org.springframework.http.HttpStatus; @@ -13,6 +14,13 @@ import java.util.Map; @RestControllerAdvice public class ApiExceptionHandler { + /** 서비스 계층 표준 예외 — 지정한 HTTP 상태로 매핑. (인증/권한/소셜로그인 등) */ + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApi(ApiException e) { + return ResponseEntity.status(e.getStatus()) + .body(Map.of("code", e.getStatus().name(), "message", e.getMessage())); + } + /** 매칭 쿼터 초과 → 429 Too Many Requests */ @ExceptionHandler(QuotaExceededException.class) public ResponseEntity> handleQuota(QuotaExceededException e) { diff --git a/src/main/java/com/dog/dognation/auth/AppleTokenVerifier.java b/src/main/java/com/dog/dognation/auth/AppleTokenVerifier.java new file mode 100644 index 0000000..2165154 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/AppleTokenVerifier.java @@ -0,0 +1,50 @@ +package com.dog.dognation.auth; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.springframework.stereotype.Component; + +import java.net.URL; + +/** + * Sign in with Apple identity token(JWT)을 애플 공개키(JWKS)로 서명 검증한다. + * JWKS 는 최초 사용 시 lazy 로딩하고 내부적으로 캐시/재시도한다. + * iss/aud/exp 검증은 호출부(AuthService)에서 수행한다. + */ +@Component +public class AppleTokenVerifier { + + private static final String APPLE_JWKS = "https://appleid.apple.com/auth/keys"; + + private volatile ConfigurableJWTProcessor processor; + + public JWTClaimsSet verify(String identityToken) throws Exception { + return processor().process(identityToken, null); + } + + private ConfigurableJWTProcessor processor() throws Exception { + ConfigurableJWTProcessor p = processor; + if (p == null) { + synchronized (this) { + if (processor == null) { + JWKSource keySource = JWKSourceBuilder + .create(new URL(APPLE_JWKS)) + .retrying(true) + .build(); + DefaultJWTProcessor proc = new DefaultJWTProcessor<>(); + proc.setJWSKeySelector( + new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource)); + processor = proc; + } + p = processor; + } + } + return p; + } +} diff --git a/src/main/java/com/dog/dognation/auth/AuthController.java b/src/main/java/com/dog/dognation/auth/AuthController.java new file mode 100644 index 0000000..c3573af --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/AuthController.java @@ -0,0 +1,64 @@ +package com.dog.dognation.auth; + +import com.dog.dognation.auth.dto.AppleLoginRequest; +import com.dog.dognation.auth.dto.GoogleLoginRequest; +import com.dog.dognation.auth.dto.LoginResponse; +import com.dog.dognation.auth.dto.MemberResponse; +import com.dog.dognation.auth.web.AuthInterceptor; +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; + +import java.util.Map; + +/** + * 인증 API. + * GET /api/auth/google-client-id 구글 클라이언트 ID (비보호) + * POST /api/auth/google 구글 로그인/가입 (비보호) + * POST /api/auth/apple 애플 로그인/가입 (비보호) + * POST /api/auth/logout 로그아웃 (보호) + * GET /api/auth/me 내 정보 (보호) + */ +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + /** 구글 OAuth 클라이언트 ID (비보호) — 프론트 구글 버튼 초기화용. 미설정 시 빈 문자열. */ + @GetMapping("/google-client-id") + public Map googleClientId() { + return Map.of("clientId", authService.googleClientId()); + } + + @PostMapping("/google") + public LoginResponse googleLogin(@Valid @RequestBody GoogleLoginRequest req) { + return authService.googleLogin(req.idToken(), req.rememberMe()); + } + + @PostMapping("/apple") + public LoginResponse appleLogin(@Valid @RequestBody AppleLoginRequest req) { + return authService.appleLogin(req.identityToken(), req.name(), req.rememberMe()); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + authService.logout(AuthInterceptor.resolveToken(request)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/me") + public MemberResponse me(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return authService.getProfile(current.id()); + } +} diff --git a/src/main/java/com/dog/dognation/auth/AuthService.java b/src/main/java/com/dog/dognation/auth/AuthService.java new file mode 100644 index 0000000..1986ed7 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/AuthService.java @@ -0,0 +1,251 @@ +package com.dog.dognation.auth; + +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 com.nimbusds.jwt.JWTClaimsSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +/** + * 인증 서비스. + * - 소셜 로그인(구글/애플): ID 토큰 검증 → 계정 조회/연결/생성 → 세션 토큰 발급 + * - 세션: auth_session 테이블에 저장, 접근 시 슬라이딩 만료 연장 + * 회원은 소셜 전용(로컬 비밀번호 없음). + */ +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션 + private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인 + + private final UserRepository userRepository; + private final AuthSessionRepository sessionRepository; + private final AppleTokenVerifier appleTokenVerifier; + private final RestClient restClient = RestClient.create(); + + /** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */ + @Value("${app.auth.google-client-id:}") + private String googleClientId; + + /** 애플 로그인 aud(=앱 번들 ID). 미설정 시 애플 로그인 비활성. */ + @Value("${app.auth.apple-client-id:}") + private String appleClientId; + + public AuthService(UserRepository userRepository, + AuthSessionRepository sessionRepository, + AppleTokenVerifier appleTokenVerifier) { + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + this.appleTokenVerifier = appleTokenVerifier; + } + + public String googleClientId() { + return googleClientId == null ? "" : googleClientId; + } + + // ==================== 구글 ==================== + + /** 구글 로그인/가입 — ID 토큰 검증 후 provider=GOOGLE 계정을 조회/연결/생성하고 세션 발급. */ + @Transactional + public LoginResponse googleLogin(String idToken, boolean rememberMe) { + if (googleClientId == null || googleClientId.isBlank()) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "구글 로그인이 설정되지 않았습니다."); + } + if (idToken == null || idToken.isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다."); + } + Map info; + try { + info = restClient.get() + .uri("https://oauth2.googleapis.com/tokeninfo?id_token={t}", idToken) + .retrieve() + .body(Map.class); + } catch (Exception e) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "구글 인증에 실패했습니다."); + } + if (info == null || !googleClientId.equals(String.valueOf(info.get("aud")))) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 구글 토큰입니다."); + } + String sub = String.valueOf(info.get("sub")); + String email = info.get("email") == null ? null : String.valueOf(info.get("email")); + boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified"))); + String name = info.get("name") != null ? String.valueOf(info.get("name")) + : (email != null ? email.split("@")[0] : "사용자"); + String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null; + + User user = userRepository.findByGoogleId(sub).orElse(null); + + // 같은 (검증된)이메일 계정에 구글 연결 — 중복 계정 방지 + if (user == null && email != null && !email.isBlank() && emailVerified) { + User existing = userRepository.findByEmail(email).orElse(null); + if (existing != null) { + requireActive(existing); + existing.setGoogleId(sub); + user = existing; + log.info("[google] linked to existing user id={} email={}", user.getId(), email); + } + } + + if (user == null) { + user = userRepository.save(User.createSocial("GOOGLE", sub, email, name, picture)); + log.info("[google] new user id={} email={}", user.getId(), email); + } + requireActive(user); + + // 아바타 변경 시 동기화 + if (picture != null && !picture.equals(user.getProfileImage())) { + user.setProfileImage(picture); + } + return issueSession(user, rememberMe); + } + + // ==================== 애플 ==================== + + /** 애플 로그인/가입 — identity token 을 애플 JWKS 로 검증하고 계정 조회/연결/생성 후 세션 발급. */ + @Transactional + public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) { + if (appleClientId == null || appleClientId.isBlank()) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "애플 로그인이 설정되지 않았습니다."); + } + if (identityToken == null || identityToken.isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다."); + } + JWTClaimsSet claims; + try { + claims = appleTokenVerifier.verify(identityToken); + } catch (Exception e) { + log.warn("[apple] token verify failed: {}", e.toString()); + throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다."); + } + Date now = new Date(); + if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now) + || !"https://appleid.apple.com".equals(claims.getIssuer()) + || claims.getAudience() == null || !claims.getAudience().contains(appleClientId)) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다."); + } + String sub = claims.getSubject(); + if (sub == null || sub.isBlank()) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다."); + } + String email; + boolean emailVerified; + try { + email = claims.getStringClaim("email"); + Object ev = claims.getClaim("email_verified"); // 문자열/불리언 모두 대응 + emailVerified = ev != null && "true".equals(String.valueOf(ev)); + } catch (Exception e) { + email = null; + emailVerified = false; + } + String name = (providedName != null && !providedName.isBlank()) ? providedName + : (email != null ? email.split("@")[0] : "사용자"); + + User user = userRepository.findByAppleId(sub).orElse(null); + + if (user == null && email != null && !email.isBlank() && emailVerified) { + User existing = userRepository.findByEmail(email).orElse(null); + if (existing != null) { + requireActive(existing); + existing.setAppleId(sub); + user = existing; + log.info("[apple] linked to existing user id={} email={}", user.getId(), email); + } + } + + if (user == null) { + user = userRepository.save(User.createSocial("APPLE", sub, email, name, null)); + log.info("[apple] new user id={} email={}", user.getId(), email); + } + requireActive(user); + return issueSession(user, rememberMe); + } + + // ==================== 세션 ==================== + + /** 세션 토큰 발급(구글/애플 공용). */ + private LoginResponse issueSession(User user, boolean rememberMe) { + Duration ttl = rememberMe ? REMEMBER_TTL : SESSION_TTL; + String token = UUID.randomUUID().toString().replace("-", ""); + OffsetDateTime expiresAt = OffsetDateTime.now().plus(ttl); + sessionRepository.save(AuthSession.issue(token, user, rememberMe, expiresAt)); + log.info("[login] user={} provider={} rememberMe={} 토큰 발급", user.getId(), user.getProvider(), rememberMe); + return new LoginResponse(token, ttl.getSeconds(), MemberResponse.from(user)); + } + + /** + * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다. + * 인터셉터가 매 보호요청마다 호출한다. + */ + @Transactional + public SessionUser getSession(String token) { + if (token == null || token.isBlank()) { + return null; + } + AuthSession session = sessionRepository.findById(token).orElse(null); + if (session == null) { + return null; + } + OffsetDateTime now = OffsetDateTime.now(); + if (session.isExpired(now)) { + sessionRepository.deleteById(token); // 만료분 정리 + return null; + } + Duration ttl = session.isRememberMe() ? REMEMBER_TTL : SESSION_TTL; + session.extend(now.plus(ttl)); // 슬라이딩 만료 (managed → flush 시 UPDATE) + return new SessionUser(session.getUserId(), session.getRole()); + } + + @Transactional + public void logout(String token) { + if (token != null && !token.isBlank()) { + sessionRepository.deleteById(token); + } + } + + /** 현재 회원 프로필(최신값). */ + @Transactional(readOnly = true) + public MemberResponse getProfile(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + return MemberResponse.from(user); + } + + /** 만료된 세션 정리 (매일 새벽 4시). */ + @Scheduled(cron = "0 0 4 * * *", zone = "${app.scheduler.timezone:Asia/Seoul}") + @Transactional + public void cleanupExpiredSessions() { + try { + int n = sessionRepository.deleteByExpiresAtBefore(OffsetDateTime.now()); + if (n > 0) { + log.info("[session] 만료 세션 {}건 정리", n); + } + } catch (Exception e) { + log.warn("[session] 만료 세션 정리 실패: {}", e.toString()); + } + } + + private void requireActive(User user) { + if (!"ACTIVE".equals(user.getStatus())) { + throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); + } + } +} diff --git a/src/main/java/com/dog/dognation/auth/SessionUser.java b/src/main/java/com/dog/dognation/auth/SessionUser.java new file mode 100644 index 0000000..f1d3cd5 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/SessionUser.java @@ -0,0 +1,12 @@ +package com.dog.dognation.auth; + +/** + * 인터셉터가 검증 후 요청 속성으로 전달하는 경량 인증 주체. + * 컨트롤러에서 현재 로그인 사용자로 사용한다. + */ +public record SessionUser(Long id, String role) { + + public boolean isAdmin() { + return "ADMIN".equals(role); + } +} diff --git a/src/main/java/com/dog/dognation/auth/dto/AppleLoginRequest.java b/src/main/java/com/dog/dognation/auth/dto/AppleLoginRequest.java new file mode 100644 index 0000000..ae8d633 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/dto/AppleLoginRequest.java @@ -0,0 +1,13 @@ +package com.dog.dognation.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 애플 로그인 요청 — 프론트(Sign in with Apple)에서 받은 identity token(JWT). + * 이름은 애플이 최초 인증 시에만 주므로, 신규 가입 시 채우도록 함께 받는다. + */ +public record AppleLoginRequest( + @NotBlank(message = "토큰이 없습니다.") String identityToken, + String name, + boolean rememberMe) { +} diff --git a/src/main/java/com/dog/dognation/auth/dto/GoogleLoginRequest.java b/src/main/java/com/dog/dognation/auth/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..ade0b3b --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/dto/GoogleLoginRequest.java @@ -0,0 +1,11 @@ +package com.dog.dognation.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 구글 로그인 요청 — 프론트(Google Identity Services)에서 받은 ID 토큰. + */ +public record GoogleLoginRequest( + @NotBlank(message = "토큰이 없습니다.") String idToken, + boolean rememberMe) { +} diff --git a/src/main/java/com/dog/dognation/auth/dto/LoginResponse.java b/src/main/java/com/dog/dognation/auth/dto/LoginResponse.java new file mode 100644 index 0000000..a3d152e --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/dto/LoginResponse.java @@ -0,0 +1,8 @@ +package com.dog.dognation.auth.dto; + +/** + * 로그인/소셜가입 응답 — 세션 토큰 + 만료(초) + 회원 정보. + * 프론트는 token 을 Authorization: Bearer 로 사용한다. + */ +public record LoginResponse(String token, long expiresInSeconds, MemberResponse member) { +} diff --git a/src/main/java/com/dog/dognation/auth/dto/MemberResponse.java b/src/main/java/com/dog/dognation/auth/dto/MemberResponse.java new file mode 100644 index 0000000..71ac018 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/dto/MemberResponse.java @@ -0,0 +1,33 @@ +package com.dog.dognation.auth.dto; + +import com.dog.dognation.domain.user.User; + +import java.time.OffsetDateTime; + +/** + * 회원 응답(본인/로그인). 민감정보 제외. + */ +public record MemberResponse( + Long id, + String email, + String nickname, + String role, + String provider, + String subscriptionTier, + String status, + String profileImage, + OffsetDateTime createdAt) { + + public static MemberResponse from(User u) { + return new MemberResponse( + u.getId(), + u.getEmail(), + u.getNickname(), + u.getRole(), + u.getProvider(), + u.getSubscriptionTier().name(), + u.getStatus(), + u.getProfileImage(), + u.getCreatedAt()); + } +} diff --git a/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java b/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java new file mode 100644 index 0000000..02edf53 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/web/AdminInterceptor.java @@ -0,0 +1,30 @@ +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/auth/web/AuthInterceptor.java b/src/main/java/com/dog/dognation/auth/web/AuthInterceptor.java new file mode 100644 index 0000000..6237767 --- /dev/null +++ b/src/main/java/com/dog/dognation/auth/web/AuthInterceptor.java @@ -0,0 +1,61 @@ +package com.dog.dognation.auth.web; + +import com.dog.dognation.auth.AuthService; +import com.dog.dognation.auth.SessionUser; +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; + +/** + * 보호 경로 접근 시 Authorization: Bearer {token} 을 검증한다. + * 유효 세션이면 SessionUser 를 요청 속성으로 전달, 아니면 401 JSON. + */ +@Component +public class AuthInterceptor implements HandlerInterceptor { + + public static final String CURRENT_MEMBER = "currentMember"; + + private final AuthService authService; + + public AuthInterceptor(AuthService authService) { + this.authService = authService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + if (HttpMethod.OPTIONS.matches(request.getMethod())) { + return true; // CORS preflight 통과 + } + SessionUser user = authService.getSession(resolveToken(request)); + if (user == null) { + write(response, HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + return false; + } + request.setAttribute(CURRENT_MEMBER, user); + 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; + } + + static void write(HttpServletResponse response, HttpStatus status, String message) throws Exception { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // 전역 예외 핸들러(ApiExceptionHandler)와 동일한 {code, message} 형태로 통일 + response.getWriter().write("{\"code\":\"" + status.name() + "\",\"message\":\"" + message + "\"}"); + } +} diff --git a/src/main/java/com/dog/dognation/common/exception/ApiException.java b/src/main/java/com/dog/dognation/common/exception/ApiException.java new file mode 100644 index 0000000..919be7c --- /dev/null +++ b/src/main/java/com/dog/dognation/common/exception/ApiException.java @@ -0,0 +1,21 @@ +package com.dog.dognation.common.exception; + +import org.springframework.http.HttpStatus; + +/** + * 서비스 계층에서 던지는 표준 예외. HTTP 상태 + 사용자용 메시지를 함께 담는다. + * {@link GlobalExceptionHandler} 가 {status, message} JSON 으로 변환한다. + */ +public class ApiException extends RuntimeException { + + private final HttpStatus status; + + public ApiException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java index c34b356..2256818 100644 --- a/src/main/java/com/dog/dognation/config/WebConfig.java +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -1,13 +1,28 @@ package com.dog.dognation.config; +import com.dog.dognation.auth.web.AdminInterceptor; +import com.dog.dognation.auth.web.AuthInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -/** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ +/** + * 웹 계층 설정 — CORS 허용 + 인증/인가 인터셉터 등록. + * 소셜 로그인 엔드포인트(/api/auth/google, /apple, /google-client-id)는 비보호. + */ @Configuration public class WebConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + private final AdminInterceptor adminInterceptor; + + public WebConfig(AuthInterceptor authInterceptor, AdminInterceptor adminInterceptor) { + this.authInterceptor = authInterceptor; + this.adminInterceptor = adminInterceptor; + } + + /** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") @@ -20,4 +35,14 @@ public class WebConfig implements WebMvcConfigurer { .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*"); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**"); + // 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사) + registry.addInterceptor(adminInterceptor) + .addPathPatterns("/api/admin/**"); + } } diff --git a/src/main/java/com/dog/dognation/domain/auth/AuthSession.java b/src/main/java/com/dog/dognation/domain/auth/AuthSession.java new file mode 100644 index 0000000..c62cc98 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/auth/AuthSession.java @@ -0,0 +1,79 @@ +package com.dog.dognation.domain.auth; + +import com.dog.dognation.domain.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 로그인 세션. Bearer 토큰을 키로 DB 에 저장하며 슬라이딩 만료(접근 시 연장)한다. + * Redis 없이 이 테이블만으로 세션을 관리한다. + */ +@Entity +@Table(name = "auth_session") +public class AuthSession { + + @Id + @Column(length = 64) + private String token; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String role; // 발급 시점 권한 스냅샷 + + @Column(name = "remember_me", nullable = false) + private boolean rememberMe; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected AuthSession() { + } + + public static AuthSession issue(String token, User user, boolean rememberMe, OffsetDateTime expiresAt) { + AuthSession s = new AuthSession(); + s.token = token; + s.userId = user.getId(); + s.role = user.getRole(); + s.rememberMe = rememberMe; + s.expiresAt = expiresAt; + s.createdAt = OffsetDateTime.now(); + return s; + } + + 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 getUserId() { + return userId; + } + + public String getRole() { + return role; + } + + public boolean isRememberMe() { + return rememberMe; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/dog/dognation/domain/auth/AuthSessionRepository.java b/src/main/java/com/dog/dognation/domain/auth/AuthSessionRepository.java new file mode 100644 index 0000000..21bd91a --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/auth/AuthSessionRepository.java @@ -0,0 +1,14 @@ +package com.dog.dognation.domain.auth; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.OffsetDateTime; + +public interface AuthSessionRepository extends JpaRepository { + + /** 회원의 모든 세션 삭제 — 강제 로그아웃(정지/삭제/역할변경 시). */ + void deleteByUserId(Long userId); + + /** 만료된 세션 정리(스케줄러). */ + int deleteByExpiresAtBefore(OffsetDateTime now); +} diff --git a/src/main/java/com/dog/dognation/domain/user/User.java b/src/main/java/com/dog/dognation/domain/user/User.java index 60ef2c7..1404ac3 100644 --- a/src/main/java/com/dog/dognation/domain/user/User.java +++ b/src/main/java/com/dog/dognation/domain/user/User.java @@ -7,10 +7,15 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import java.time.OffsetDateTime; +/** + * 회원. 애플/구글 소셜 로그인 전용(로컬 비밀번호 없음). + * role 로 관리자(ADMIN) 여부를, provider/google_id/apple_id 로 소셜 계정을 식별한다. + */ @Entity @Table(name = "users") public class User { @@ -22,9 +27,6 @@ public class User { @Column(nullable = false, unique = true) private String email; - @Column(name = "password_hash", nullable = false) - private String passwordHash; - @Column(nullable = false, length = 50) private String nickname; @@ -33,7 +35,25 @@ public class User { private SubscriptionTier subscriptionTier = SubscriptionTier.FREE; @Column(nullable = false, length = 20) - private String status = "ACTIVE"; + private String status = "ACTIVE"; // ACTIVE / DORMANT / BANNED + + @Column(nullable = false, length = 20) + private String role = "USER"; // USER / ADMIN + + @Column(nullable = false, length = 20) + private String provider = "GOOGLE"; // GOOGLE / APPLE + + @Column(name = "provider_id") + private String providerId; // 제공자측 고유 sub + + @Column(name = "google_id") + private String googleId; // 구글 sub (계정 연결) + + @Column(name = "apple_id") + private String appleId; // 애플 sub (계정 연결) + + @Column(name = "profile_image", length = 500) + private String profileImage; // 아바타 URL @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt = OffsetDateTime.now(); @@ -44,6 +64,31 @@ public class User { protected User() { } + /** 소셜 최초 로그인 = 신규 가입. provider 는 GOOGLE/APPLE. */ + public static User createSocial(String provider, String providerSub, + String email, String nickname, String profileImage) { + User u = new User(); + u.provider = provider; + u.providerId = providerSub; + if ("GOOGLE".equals(provider)) { + u.googleId = providerSub; + } else if ("APPLE".equals(provider)) { + u.appleId = providerSub; + } + u.email = email; + u.nickname = nickname; + u.profileImage = profileImage; + u.subscriptionTier = SubscriptionTier.FREE; + u.status = "ACTIVE"; + u.role = "USER"; + return u; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + public Long getId() { return id; } @@ -52,11 +97,75 @@ public class User { return email; } + public void setEmail(String email) { + this.email = email; + } + public String getNickname() { return nickname; } + public void setNickname(String nickname) { + this.nickname = nickname; + } + public SubscriptionTier getSubscriptionTier() { return subscriptionTier; } + + public void setSubscriptionTier(SubscriptionTier subscriptionTier) { + this.subscriptionTier = subscriptionTier; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getProvider() { + return provider; + } + + public String getProviderId() { + return providerId; + } + + public String getGoogleId() { + return googleId; + } + + public void setGoogleId(String googleId) { + this.googleId = googleId; + } + + public String getAppleId() { + return appleId; + } + + public void setAppleId(String appleId) { + this.appleId = appleId; + } + + public String getProfileImage() { + return profileImage; + } + + public void setProfileImage(String profileImage) { + this.profileImage = profileImage; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } } diff --git a/src/main/java/com/dog/dognation/domain/user/UserRepository.java b/src/main/java/com/dog/dognation/domain/user/UserRepository.java index 0bb0049..4629d5b 100644 --- a/src/main/java/com/dog/dognation/domain/user/UserRepository.java +++ b/src/main/java/com/dog/dognation/domain/user/UserRepository.java @@ -2,5 +2,17 @@ package com.dog.dognation.domain.user; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + + Optional findByGoogleId(String googleId); + + Optional findByAppleId(String appleId); + + Optional findByEmail(String email); + + /** 관리자 회원 목록 — 최신 가입순. */ + List findAllByOrderByCreatedAtDesc(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7519426..7be8a24 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,10 @@ app: timezone: Asia/Seoul matching: daily-free-limit: 3 + # 소셜 로그인 — 값은 .env 에서 주입. 미설정 시 해당 로그인 비활성. + auth: + google-client-id: ${GOOGLE_CLIENT_ID:} # 구글 OAuth 웹 클라이언트 ID (ID 토큰 aud 검증) + apple-client-id: ${APPLE_CLIENT_ID:} # Sign in with Apple aud(앱 번들 ID) server: port: 8080 diff --git a/src/main/resources/db/migration/V3__auth_and_admin.sql b/src/main/resources/db/migration/V3__auth_and_admin.sql new file mode 100644 index 0000000..27b29e3 --- /dev/null +++ b/src/main/resources/db/migration/V3__auth_and_admin.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- V2: 소셜 로그인(구글/애플) · 관리자 역할 · 세션 +-- 회원은 애플/구글 소셜 로그인 전용, 관리자 페이지(웹)는 role=ADMIN 으로 보호. +-- ============================================================ + +-- 회원은 소셜 로그인 전용 → 비밀번호 컬럼 제거 +ALTER TABLE users DROP COLUMN password_hash; + +-- 권한(USER/ADMIN) — 관리자 페이지(웹) 접근 제어용 +ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'USER' + CHECK (role IN ('USER', 'ADMIN')); + +-- 소셜 제공자 정보 +ALTER TABLE users ADD COLUMN provider VARCHAR(20) NOT NULL DEFAULT 'GOOGLE' + CHECK (provider IN ('GOOGLE', 'APPLE')); +ALTER TABLE users ADD COLUMN provider_id VARCHAR(255); -- 제공자측 고유 sub +ALTER TABLE users ADD COLUMN google_id VARCHAR(255) UNIQUE; -- 구글 sub (계정 연결/조회) +ALTER TABLE users ADD COLUMN apple_id VARCHAR(255) UNIQUE; -- 애플 sub (계정 연결/조회) +ALTER TABLE users ADD COLUMN profile_image VARCHAR(500); -- 아바타 URL(구글 사진 등) + +-- ------------------------------------------------------------ +-- 세션(로그인 상태) — Bearer 토큰 기준, 슬라이딩 만료. +-- Redis 없이 DB 단일 저장. 관리자/앱 공용. +-- ------------------------------------------------------------ +CREATE TABLE auth_session ( + token VARCHAR(64) PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, -- 발급 시점 권한(스냅샷) + remember_me BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_auth_session_user ON auth_session(user_id); +CREATE INDEX idx_auth_session_expires ON auth_session(expires_at); diff --git a/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java b/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java index 1d96468..c223570 100644 --- a/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java +++ b/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java @@ -96,8 +96,8 @@ class MatchingServiceTest { private long seedUser(String email, String tier) { jdbc.update(""" - INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) - VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) + VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, email, email, tier); return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); } diff --git a/src/test/java/com/dog/dognation/scheduler/MatchQuotaSchedulerTest.java b/src/test/java/com/dog/dognation/scheduler/MatchQuotaSchedulerTest.java index 14f4e23..465a0b5 100644 --- a/src/test/java/com/dog/dognation/scheduler/MatchQuotaSchedulerTest.java +++ b/src/test/java/com/dog/dognation/scheduler/MatchQuotaSchedulerTest.java @@ -73,8 +73,8 @@ class MatchQuotaSchedulerTest { private long seedUser(String email, String tier, int remaining) { jdbc.update(""" - INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) - VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) + VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, email, email, tier); Long id = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); quotaRepository.save(new DailyMatchQuota(id, remaining, java.time.LocalDate.now()));