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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
Reference in New Issue
Block a user