feat: 소셜 로그인(구글/애플)·세션·관리자 회원관리 백엔드

- users: 소셜 전용 전환(password_hash 제거) + role/provider/google_id/apple_id/profile_image (V2 마이그레이션)
- auth_session 테이블: Redis 없이 DB 세션(슬라이딩 만료), Bearer 토큰
- 소셜 로그인: 구글 tokeninfo(aud) · 애플 JWKS(iss/aud/exp) 검증 → 이메일 계정연결/신규가입
- AuthInterceptor(세션)+AdminInterceptor(role=ADMIN) 2단 보호, 관리자 페이지는 웹 전용
- /api/admin/members: 목록·상태·티어·역할·삭제 (본인 잠금방지, 정지/삭제/역할변경 시 세션 무효화)
- 예외는 기존 ApiExceptionHandler로 통합(ApiException 매핑 추가)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-11 08:07:41 +09:00
parent 115bc13ea1
commit 19b26ebe37
28 changed files with 1076 additions and 9 deletions
+3
View File
@@ -29,6 +29,9 @@ dependencies {
implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.flywaydb:flyway-database-postgresql'
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
// H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB // H2: 로컬 실행 프로파일(local) 및 테스트용 인메모리 DB
@@ -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<MemberAdminResponse> 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<Void> delete(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
memberAdminService.delete(id, current.id());
return ResponseEntity.noContent().build();
}
}
@@ -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<String> STATUSES = Set.of("ACTIVE", "DORMANT", "BANNED");
private static final Set<String> 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<MemberAdminResponse> 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, "회원을 찾을 수 없습니다."));
}
}
@@ -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());
}
}
@@ -0,0 +1,7 @@
package com.dog.dognation.admin.dto;
import jakarta.validation.constraints.NotBlank;
/** 회원 권한 변경 — USER / ADMIN. */
public record RoleUpdateRequest(@NotBlank(message = "역할을 입력하세요.") String role) {
}
@@ -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) {
}
@@ -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) {
}
@@ -1,5 +1,6 @@
package com.dog.dognation.api; package com.dog.dognation.api;
import com.dog.dognation.common.exception.ApiException;
import com.dog.dognation.domain.matching.QuotaExceededException; import com.dog.dognation.domain.matching.QuotaExceededException;
import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -13,6 +14,13 @@ import java.util.Map;
@RestControllerAdvice @RestControllerAdvice
public class ApiExceptionHandler { public class ApiExceptionHandler {
/** 서비스 계층 표준 예외 — 지정한 HTTP 상태로 매핑. (인증/권한/소셜로그인 등) */
@ExceptionHandler(ApiException.class)
public ResponseEntity<Map<String, String>> handleApi(ApiException e) {
return ResponseEntity.status(e.getStatus())
.body(Map.of("code", e.getStatus().name(), "message", e.getMessage()));
}
/** 매칭 쿼터 초과 → 429 Too Many Requests */ /** 매칭 쿼터 초과 → 429 Too Many Requests */
@ExceptionHandler(QuotaExceededException.class) @ExceptionHandler(QuotaExceededException.class)
public ResponseEntity<Map<String, String>> handleQuota(QuotaExceededException e) { public ResponseEntity<Map<String, String>> handleQuota(QuotaExceededException e) {
@@ -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<SecurityContext> processor;
public JWTClaimsSet verify(String identityToken) throws Exception {
return processor().process(identityToken, null);
}
private ConfigurableJWTProcessor<SecurityContext> processor() throws Exception {
ConfigurableJWTProcessor<SecurityContext> p = processor;
if (p == null) {
synchronized (this) {
if (processor == null) {
JWKSource<SecurityContext> keySource = JWKSourceBuilder
.create(new URL(APPLE_JWKS))
.retrying(true)
.build();
DefaultJWTProcessor<SecurityContext> proc = new DefaultJWTProcessor<>();
proc.setJWSKeySelector(
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource));
processor = proc;
}
p = processor;
}
}
return p;
}
}
@@ -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<String, String> 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<Void> 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());
}
}
@@ -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, "사용할 수 없는 계정입니다.");
}
}
}
@@ -0,0 +1,12 @@
package com.dog.dognation.auth;
/**
* 인터셉터가 검증 후 요청 속성으로 전달하는 경량 인증 주체.
* 컨트롤러에서 현재 로그인 사용자로 사용한다.
*/
public record SessionUser(Long id, String role) {
public boolean isAdmin() {
return "ADMIN".equals(role);
}
}
@@ -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) {
}
@@ -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) {
}
@@ -0,0 +1,8 @@
package com.dog.dognation.auth.dto;
/**
* 로그인/소셜가입 응답 — 세션 토큰 + 만료(초) + 회원 정보.
* 프론트는 token 을 Authorization: Bearer 로 사용한다.
*/
public record LoginResponse(String token, long expiresInSeconds, MemberResponse member) {
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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 + "\"}");
}
}
@@ -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;
}
}
@@ -1,13 +1,28 @@
package com.dog.dognation.config; 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.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */ /**
* 웹 계층 설정 — 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 AdminInterceptor adminInterceptor;
public WebConfig(AuthInterceptor authInterceptor, AdminInterceptor adminInterceptor) {
this.authInterceptor = authInterceptor;
this.adminInterceptor = adminInterceptor;
}
/** 프론트엔드(Vite dev / Capacitor 웹뷰)에서의 크로스 오리진 호출 허용. */
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") registry.addMapping("/api/**")
@@ -20,4 +35,14 @@ public class WebConfig implements WebMvcConfigurer {
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*"); .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/**");
}
} }
@@ -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;
}
}
@@ -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<AuthSession, String> {
/** 회원의 모든 세션 삭제 — 강제 로그아웃(정지/삭제/역할변경 시). */
void deleteByUserId(Long userId);
/** 만료된 세션 정리(스케줄러). */
int deleteByExpiresAtBefore(OffsetDateTime now);
}
@@ -7,10 +7,15 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
/**
* 회원. 애플/구글 소셜 로그인 전용(로컬 비밀번호 없음).
* role 로 관리자(ADMIN) 여부를, provider/google_id/apple_id 로 소셜 계정을 식별한다.
*/
@Entity @Entity
@Table(name = "users") @Table(name = "users")
public class User { public class User {
@@ -22,9 +27,6 @@ public class User {
@Column(nullable = false, unique = true) @Column(nullable = false, unique = true)
private String email; private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(nullable = false, length = 50) @Column(nullable = false, length = 50)
private String nickname; private String nickname;
@@ -33,7 +35,25 @@ public class User {
private SubscriptionTier subscriptionTier = SubscriptionTier.FREE; private SubscriptionTier subscriptionTier = SubscriptionTier.FREE;
@Column(nullable = false, length = 20) @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) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
@@ -44,6 +64,31 @@ public class User {
protected 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() { public Long getId() {
return id; return id;
} }
@@ -52,11 +97,75 @@ public class User {
return email; return email;
} }
public void setEmail(String email) {
this.email = email;
}
public String getNickname() { public String getNickname() {
return nickname; return nickname;
} }
public void setNickname(String nickname) {
this.nickname = nickname;
}
public SubscriptionTier getSubscriptionTier() { public SubscriptionTier getSubscriptionTier() {
return subscriptionTier; 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;
}
} }
@@ -2,5 +2,17 @@ package com.dog.dognation.domain.user;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByGoogleId(String googleId);
Optional<User> findByAppleId(String appleId);
Optional<User> findByEmail(String email);
/** 관리자 회원 목록 — 최신 가입순. */
List<User> findAllByOrderByCreatedAtDesc();
} }
+4
View File
@@ -29,6 +29,10 @@ app:
timezone: Asia/Seoul timezone: Asia/Seoul
matching: matching:
daily-free-limit: 3 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: server:
port: 8080 port: 8080
@@ -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);
@@ -96,8 +96,8 @@ class MatchingServiceTest {
private long seedUser(String email, String tier) { private long seedUser(String email, String tier) {
jdbc.update(""" jdbc.update("""
INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at)
VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", email, email, tier); """, email, email, tier);
return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email);
} }
@@ -73,8 +73,8 @@ class MatchQuotaSchedulerTest {
private long seedUser(String email, String tier, int remaining) { private long seedUser(String email, String tier, int remaining) {
jdbc.update(""" jdbc.update("""
INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at)
VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, 'ACTIVE', 'USER', 'GOOGLE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", email, email, tier); """, email, email, tier);
Long id = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); Long id = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email);
quotaRepository.save(new DailyMatchQuota(id, remaining, java.time.LocalDate.now())); quotaRepository.save(new DailyMatchQuota(id, remaining, java.time.LocalDate.now()));