252 lines
11 KiB
Java
252 lines
11 KiB
Java
|
|
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, "사용할 수 없는 계정입니다.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|