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