feat(auth): 애플 로그인 API — Sign in with Apple identity token 검증(JWKS)
Deploy / deploy (push) Failing after 12m48s

- 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:
sb
2026-07-04 17:01:31 +09:00
parent 2f376e6f77
commit a10e16c1af
9 changed files with 177 additions and 3 deletions
+2
View File
@@ -24,6 +24,8 @@ dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'
// 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용)
implementation 'org.springframework.security:spring-security-crypto'
// Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반
implementation 'com.nimbusds:nimbus-jose-jwt:9.40'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
@@ -69,6 +69,12 @@ public class AuthController {
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
}
/** 애플 로그인/가입 (비보호) — Sign in with Apple identity token 검증 후 세션 발급 */
@PostMapping("/apple")
public LoginResponse appleLogin(@Valid @RequestBody com.sb.web.auth.dto.AppleLoginRequest req) {
return authService.appleLogin(req.getIdentityToken(), req.getName(), req.isRememberMe());
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
authService.logout(AuthInterceptor.resolveToken(request));
@@ -26,6 +26,7 @@ public class Member implements Serializable {
private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
private String appleId; // 애플 sub (계정 연결용 — 구글과 동일)
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
@@ -0,0 +1,19 @@
package com.sb.web.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 애플 로그인 요청 — 프론트(Sign in with Apple)에서 받은 identity token(JWT).
* 이름은 애플이 <b>최초 인증 시에만</b> 클라이언트에 주므로, 신규 가입 시 채우도록 함께 전달받는다.
*/
@Data
public class AppleLoginRequest {
@NotBlank(message = "토큰이 없습니다.")
private String identityToken;
private String name; // 최초 로그인 시에만 제공될 수 있음(이후 null)
private boolean rememberMe;
}
@@ -31,6 +31,15 @@ public interface MemberMapper {
/** 기존 계정에 구글 sub 연결 (provider 유지). */
int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId);
/** 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두). */
Member findByAppleId(@Param("appleId") String appleId);
/** 애플 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 애플 미연결만). */
Member findByEmailForAppleLink(@Param("email") String email);
/** 기존 계정에 애플 sub 연결 (provider 유지). */
int linkApple(@Param("id") Long id, @Param("appleId") String appleId);
int countByLoginId(@Param("loginId") String loginId);
int insert(Member member);
@@ -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 를 재수화한다.
+2
View File
@@ -79,6 +79,8 @@ app:
public-url: ${PUBLIC_URL:https://app.sblog.kr}
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
google-client-id: ${GOOGLE_CLIENT_ID:}
# 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값.
apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget}
# ===== Logging =====
logging:
+4
View File
@@ -27,6 +27,10 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
-- 애플 로그인: 애플 sub 를 apple_id 로 저장(구글과 동일 — LOCAL 계정에 연결해도 provider 유지). 멱등.
ALTER TABLE member ADD COLUMN IF NOT EXISTS apple_id VARCHAR(100) NULL COMMENT '애플 sub (계정 연결용)' AFTER google_id;
ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_apple_id (apple_id);
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
+23 -3
View File
@@ -12,6 +12,7 @@
<result property="provider" column="provider"/>
<result property="providerId" column="provider_id"/>
<result property="googleId" column="google_id"/>
<result property="appleId" column="apple_id"/>
<result property="googlePicture" column="google_picture"/>
<result property="profileImage" column="profile_image"/>
<result property="role" column="role"/>
@@ -26,7 +27,7 @@
</resultMap>
<sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at
id, login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at
</sql>
<select id="findById" parameterType="long" resultMap="memberResultMap">
@@ -68,15 +69,34 @@
UPDATE member SET google_id = #{googleId}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두 apple_id 로 찾음) -->
<select id="findByAppleId" parameterType="string" resultMap="memberResultMap">
SELECT <include refid="columns"/> FROM member WHERE apple_id = #{appleId}
</select>
<!-- 애플 로그인 시 같은 이메일의 기존 계정 1건(연결 대상). LOCAL 계정 우선, 애플 미연결만. -->
<select id="findByEmailForAppleLink" parameterType="string" resultMap="memberResultMap">
SELECT <include refid="columns"/>
FROM member
WHERE email = #{email} AND status = 'ACTIVE' AND apple_id IS NULL
ORDER BY (provider = 'LOCAL') DESC, id ASC
LIMIT 1
</select>
<!-- 기존 계정에 애플 연결 (provider 는 그대로 유지) -->
<update id="linkApple">
UPDATE member SET apple_id = #{appleId}, updated_at = NOW() WHERE id = #{id}
</update>
<select id="countByLoginId" parameterType="string" resultType="int">
SELECT COUNT(*) FROM member WHERE login_id = #{loginId}
</select>
<insert id="insert" parameterType="com.sb.web.auth.domain.Member"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, google_picture, role, status, created_at, updated_at)
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, role, status, created_at, updated_at)
VALUES (#{loginId}, #{password}, #{name}, #{email},
#{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
#{provider}, #{providerId}, #{googleId}, #{appleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
</insert>
<update id="updatePassword">