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
@@ -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">