feat(auth): 애플 로그인 API — Sign in with Apple identity token 검증(JWKS)
- POST /api/auth/apple — nimbus-jose-jwt 로 애플 공개키(JWKS) 서명 검증 + iss/aud(번들ID)/exp 확인 - member.apple_id 컬럼(멱등 ALTER) + findByAppleId/linkApple, 구글과 동일한 이메일 자동연결 로직 - app.apple-client-id 설정(기본 kr.sblog.slimbudget) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,13 @@ public class AuthService {
|
||||
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
|
||||
private String googleClientId;
|
||||
|
||||
/** 애플 로그인 aud(=iOS 앱 번들 ID). 기본값은 앱 패키지명. */
|
||||
@org.springframework.beans.factory.annotation.Value("${app.apple-client-id:kr.sblog.slimbudget}")
|
||||
private String appleClientId;
|
||||
|
||||
/** 애플 ID 토큰 검증기(JWKS 캐시 포함). 최초 사용 시 lazy 초기화. */
|
||||
private volatile com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> appleJwtProcessor;
|
||||
|
||||
private static final String SESSION_PREFIX = "session:";
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
||||
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
|
||||
@@ -241,6 +248,110 @@ public class AuthService {
|
||||
return googleClientId == null ? "" : googleClientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플 로그인. Sign in with Apple identity token(JWT)을 애플 공개키(JWKS)로 검증한 뒤,
|
||||
* 구글과 동일한 규칙으로 계정을 조회/연결/생성한다.
|
||||
*/
|
||||
public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) {
|
||||
if (identityToken == null || identityToken.isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
|
||||
}
|
||||
com.nimbusds.jwt.JWTClaimsSet claims;
|
||||
try {
|
||||
claims = verifyAppleToken(identityToken);
|
||||
} catch (Exception e) {
|
||||
log.warn("[apple] token verify failed: {}", e.toString());
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다.");
|
||||
}
|
||||
// 발급자·대상(aud=번들 ID)·만료 검증
|
||||
java.util.Date now = new java.util.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"); // 문자열("true")/불리언 모두 대응
|
||||
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] : "사용자");
|
||||
|
||||
// 1) 애플 sub 로 이미 연결/가입된 계정 조회
|
||||
Member member = memberMapper.findByAppleId(sub);
|
||||
|
||||
// 2) 없으면 같은 (검증된)이메일의 기존 계정에 애플 연결 — 중복 계정/데이터 분리 방지
|
||||
if (member == null && email != null && !email.isBlank() && emailVerified) {
|
||||
Member existing = memberMapper.findByEmailForAppleLink(email);
|
||||
if (existing != null) {
|
||||
if (!"ACTIVE".equals(existing.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
memberMapper.linkApple(existing.getId(), sub);
|
||||
existing.setAppleId(sub);
|
||||
member = existing;
|
||||
log.info("[apple] linked to existing member id={} provider={} email={}",
|
||||
member.getId(), member.getProvider(), email);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 그래도 없으면 신규 애플 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단.
|
||||
if (member == null) {
|
||||
if (!appSettingService.isSignupEnabled()) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
|
||||
}
|
||||
member = Member.builder()
|
||||
.name(name)
|
||||
.email(email)
|
||||
.provider("APPLE")
|
||||
.providerId(sub)
|
||||
.appleId(sub)
|
||||
.role("USER")
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
memberMapper.insert(member);
|
||||
log.info("[apple] new member id={} email={}", member.getId(), email);
|
||||
}
|
||||
if (!"ACTIVE".equals(member.getStatus())) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
|
||||
}
|
||||
return issueSession(member, rememberMe);
|
||||
}
|
||||
|
||||
/** 애플 identity token 을 애플 JWKS(공개키)로 서명 검증하고 클레임을 반환. */
|
||||
private com.nimbusds.jwt.JWTClaimsSet verifyAppleToken(String idToken) throws Exception {
|
||||
com.nimbusds.jwt.proc.ConfigurableJWTProcessor<com.nimbusds.jose.proc.SecurityContext> proc = appleJwtProcessor;
|
||||
if (proc == null) {
|
||||
synchronized (this) {
|
||||
if (appleJwtProcessor == null) {
|
||||
com.nimbusds.jose.jwk.source.JWKSource<com.nimbusds.jose.proc.SecurityContext> keySource =
|
||||
com.nimbusds.jose.jwk.source.JWKSourceBuilder
|
||||
.create(new java.net.URL("https://appleid.apple.com/auth/keys"))
|
||||
.retrying(true)
|
||||
.build();
|
||||
com.nimbusds.jwt.proc.DefaultJWTProcessor<com.nimbusds.jose.proc.SecurityContext> p =
|
||||
new com.nimbusds.jwt.proc.DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(new com.nimbusds.jose.proc.JWSVerificationKeySelector<>(
|
||||
com.nimbusds.jose.JWSAlgorithm.RS256, keySource));
|
||||
appleJwtProcessor = p;
|
||||
}
|
||||
proc = appleJwtProcessor;
|
||||
}
|
||||
}
|
||||
// 서명 검증(iss/aud/exp 는 호출부에서 확인)
|
||||
return proc.process(idToken, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
||||
|
||||
Reference in New Issue
Block a user