Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
| 데이터 접근 | Spring Data JPA + Hibernate (jsonb/배열은 hypersistence-utils), 통계 집계는 네이티브 SQL |
| Database | AWS RDS PostgreSQL (로컬: Docker `flori-pg`) |
| 스키마 관리 | DDL 직접 관리(`docs/sql/*.sql`, Flyway 미사용) + `ddl-auto: validate`로 엔티티↔DB 정합 검증 |
| 인증 | Spring Security + 자체 JWT(access + refresh rotation) + **registerToken**(가입 대기, 5분). **소셜 전용**(카카오·구글·네이버 OAuth), 비밀번호 없음 |
| 인증 | Spring Security + 자체 JWT(access + refresh rotation) + **registerToken**(가입 대기, 5분). **소셜 전용**(카카오·구글·네이버·애플 OAuth), 비밀번호 없음. 애플은 웹 OAuth 4번째 공급자(`AppleOAuthClientImpl`, ES256 client_secret + id_token JWKS 검증) + 탈퇴 시 토큰 revoke(`apple_refresh_token`·`apple_pending_credentials`) |
| 검증 | Jakarta Bean Validation |
| 스토리지 | AWS S3 + CloudFront (presigned PUT URL 발급) |
| 푸시 | FCM (Firebase Admin SDK, 모바일) + Web Push/VAPID (브라우저 PWA). 구독의 p256dh/auth 유무로 전송 경로 분기(`PushDispatcher`) |
Expand Down
12 changes: 12 additions & 0 deletions docs/sql/all-tables-ddl.sql
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ CREATE TABLE users (
provider_id VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
apple_refresh_token TEXT, -- 애플 로그인 유저의 revoke용 refresh token(암호화). 그 외 provider는 NULL.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Expand Down Expand Up @@ -1186,3 +1187,14 @@ CREATE TABLE shop_invites (
);

CREATE INDEX idx_shop_invites_shop_id ON shop_invites (shop_id);

-- =============================================
-- 애플 로그인 신규 유저 온보딩 이관용 임시 자격증명 (2026-07-13, session2-apple-login)
-- 애플은 최초 인가 시에만 refresh_token을 내려준다. 온보딩(register/complete) 완료 전까지
-- User 행이 없으므로, 완료 전까지 여기 임시 보관했다가 이관 후 삭제한다(PK=providerId(sub)).
-- =============================================
CREATE TABLE IF NOT EXISTS apple_pending_credentials (
provider_id VARCHAR(255) PRIMARY KEY,
refresh_token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
START TRANSACTION;
DROP TABLE IF EXISTS apple_pending_credentials;
ALTER TABLE users DROP COLUMN IF EXISTS apple_refresh_token;
COMMIT;
9 changes: 9 additions & 0 deletions docs/sql/migration/26-07-13-users-apple-refresh-token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- 애플 로그인: 탈퇴 시 revoke 용 refresh token 저장(암호화) + 신규 유저 온보딩 이관용 임시 테이블.
START TRANSACTION;
ALTER TABLE users ADD COLUMN apple_refresh_token TEXT;
CREATE TABLE apple_pending_credentials (
provider_id VARCHAR(255) PRIMARY KEY,
refresh_token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMIT;
10 changes: 10 additions & 0 deletions src/main/kotlin/kr/ai/flori/auth/controller/AuthController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kr.ai.flori.auth.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import kr.ai.flori.auth.dto.AppleOAuthRequest
import kr.ai.flori.auth.dto.GoogleOAuthRequest
import kr.ai.flori.auth.dto.KakaoOAuthRequest
import kr.ai.flori.auth.dto.LogoutRequest
Expand Down Expand Up @@ -53,6 +54,15 @@ class AuthController(
@Valid @RequestBody request: GoogleOAuthRequest,
): OAuthResult = authService.oauthLogin("GOOGLE", request.code, request.redirectUri, null)

@Operation(
summary = "애플 로그인",
description = "애플 인증코드 검증. 기존 사용자면 토큰, 신규면 registerToken+소셜 기본값 반환(가입 필요).",
)
@PostMapping("/oauth/apple")
fun appleLogin(
@Valid @RequestBody request: AppleOAuthRequest,
): OAuthResult = authService.oauthLogin("APPLE", request.code, request.redirectUri, null)

@Operation(
summary = "네이버 로그인",
description = "네이버 인증코드 검증. 기존 사용자면 토큰, 신규면 registerToken+소셜 기본값 반환(가입 필요). state 필수.",
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/kr/ai/flori/auth/dto/AuthDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ data class NaverOAuthRequest(
val state: String,
)

@Schema(description = "애플 OAuth 로그인 요청(인증코드 교환).")
data class AppleOAuthRequest(
@field:NotBlank(message = "인증코드는 필수입니다")
@field:Schema(description = "애플 authorize에서 받은 authorization code")
val code: String,
@field:NotBlank(message = "redirectUri는 필수입니다")
@field:Schema(description = "authorize에 사용한 redirect URI(코드 교환 시 일치 필요)")
val redirectUri: String,
)

@Schema(description = "이메일 변경 요청(로그인 사용자). 형식 검증 + 중복 검사 후 저장한다.")
data class UpdateEmailRequest(
@field:Email(message = "이메일 형식이 올바르지 않습니다")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.ai.flori.auth.entity

import jakarta.persistence.Column
import jakarta.persistence.Convert
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import kr.ai.flori.auth.oauth.AppleRefreshTokenConverter
import java.time.Instant

/**
* 신규 애플 유저의 refresh token 임시 보관(온보딩 미완료 구간). PK=providerId(sub).
* registerComplete 에서 user로 이관 후 삭제. 잔여 행은 스윕(A7)이 1일 후 정리.
*/
@Entity
@Table(name = "apple_pending_credentials")
class ApplePendingCredential(
@Id
@Column(name = "provider_id")
val providerId: String,
@Convert(converter = AppleRefreshTokenConverter::class)
@Column(name = "refresh_token", nullable = false)
var refreshToken: String,
) {
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package kr.ai.flori.auth.oauth

import io.jsonwebtoken.Jwts
import org.springframework.stereotype.Component
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Instant
import java.util.Base64
import java.util.Date

/**
* 애플 토큰/revoke 엔드포인트용 client_secret(ES256 JWT)을 매 호출 생성한다.
* client_secret은 정적 문자열이 아니라 .p8 EC 개인키로 서명한 단명(5분) JWT다.
*/
@Component
class AppleClientSecretGenerator(
private val properties: AppleProperties,
) {
fun generate(): String {
val now = Instant.now()
return Jwts
.builder()
.header()
.keyId(properties.keyId)
.and()
.issuer(properties.teamId)
.subject(properties.clientId)
.audience()
.add(APPLE_ISS)
.and()
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(CLIENT_SECRET_TTL_SEC)))
.signWith(loadPrivateKey(), Jwts.SIG.ES256)
.compact()
}

private fun loadPrivateKey(): PrivateKey {
val der =
Base64.getDecoder().decode(
properties.privateKey
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("\\s".toRegex(), ""),
)
return KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(der))
}

private companion object {
const val APPLE_ISS = "https://appleid.apple.com"
const val CLIENT_SECRET_TTL_SEC = 300L
}
}
101 changes: 101 additions & 0 deletions src/main/kotlin/kr/ai/flori/auth/oauth/AppleIdTokenVerifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package kr.ai.flori.auth.oauth

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import kr.ai.flori.common.error.AppException
import kr.ai.flori.common.error.CommonErrorCode
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.Base64

/** 애플 id_token 검증 결과(제공자 중립 필드만). */
data class AppleIdentity(
val sub: String,
val email: String?,
)

/**
* 애플 id_token(RS256)을 JWKS로 서명 검증하고 iss/aud/exp 를 확인한다.
* code 교환 응답의 id_token은 TLS로 애플에서 직접 받지만 방어적으로 전부 검증한다(illdan 누락분 보강).
*/
@Component
open class AppleIdTokenVerifier(
builder: RestClient.Builder,
private val properties: AppleProperties,
) {
private val client = builder.build()
private val mapper = ObjectMapper()

open fun verify(idToken: String): AppleIdentity {
val publicKey = resolvePublicKey(kidOf(idToken))
val claims =
try {
Jwts
.parser()
.verifyWith(publicKey)
.requireIssuer(APPLE_ISS)
.build()
.parseSignedClaims(idToken)
.payload
} catch (e: JwtException) {
throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 id_token 검증에 실패했습니다", e)
}
// aud 클레임이 아예 없는(claims.audience == null) 위조/기형 토큰도 불일치로 취급한다.
// null 그대로 in 검사하면 NPE가 나고, 이 검사는 위 try/catch 밖이라 그대로 500으로 새어나간다.
if (properties.clientId !in (claims.audience ?: emptySet())) {
throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 id_token aud 불일치")
}
val sub = claims.subject ?: throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 id_token sub 없음")
return AppleIdentity(sub = sub, email = claims["email"] as? String)
}

private fun kidOf(idToken: String): String {
val header = idToken.substringBefore(".")
val json = String(Base64.getUrlDecoder().decode(header))
return mapper.readTree(json).get("kid")?.asText()
?: throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 id_token kid 없음")
}

private fun resolvePublicKey(kid: String): PublicKey {
val keys =
try {
client
.get()
.uri("$APPLE_ISS/auth/keys")
.retrieve()
.body(AppleJwksResponse::class.java)
} catch (e: RestClientException) {
throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 공개키 조회에 실패했습니다", e)
}
val jwk =
keys?.keys?.firstOrNull { it.kid == kid }
?: throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 공개키(kid=$kid)를 찾을 수 없습니다")
val decoder = Base64.getUrlDecoder()
val n = BigInteger(1, decoder.decode(jwk.n))
val e = BigInteger(1, decoder.decode(jwk.e))
return KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
}

private companion object {
const val APPLE_ISS = "https://appleid.apple.com"
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
private data class AppleJwksResponse(
val keys: List<AppleJwk>,
)

@JsonIgnoreProperties(ignoreUnknown = true)
private data class AppleJwk(
val kid: String,
val n: String,
val e: String,
)
76 changes: 76 additions & 0 deletions src/main/kotlin/kr/ai/flori/auth/oauth/AppleOAuthClientImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package kr.ai.flori.auth.oauth

import com.fasterxml.jackson.annotation.JsonProperty
import kr.ai.flori.common.error.AppException
import kr.ai.flori.common.error.CommonErrorCode
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException

/**
* 애플 OAuth 실연동: code → 토큰 교환(client_secret=ES256 JWT) → id_token(RS256) 검증 → 신원.
* 애플은 userinfo 엔드포인트가 없어 id_token 자체가 프로필이다. 이름은 여기서 다루지 않는다(웹이 form_post로 처리).
* 빈 이름 "APPLE"로 AuthService Map 주입에 참여. provider="APPLE", providerId=sub.
*/
@Component("APPLE")
class AppleOAuthClientImpl(
builder: RestClient.Builder,
private val properties: AppleProperties,
private val clientSecretGenerator: AppleClientSecretGenerator,
private val idTokenVerifier: AppleIdTokenVerifier,
) : SocialOAuthClient {
private val client = builder.build()

override fun authenticate(
code: String,
redirectUri: String,
state: String?,
): SocialUserInfo {
val token = exchangeToken(code, redirectUri)
val idToken = token.idToken ?: throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 토큰 응답에 id_token 없음")
val identity = idTokenVerifier.verify(idToken)
return SocialUserInfo(
provider = "APPLE",
providerId = identity.sub,
email = identity.email,
nickname = null,
appleRefreshToken = token.refreshToken,
)
}

private fun exchangeToken(
code: String,
redirectUri: String,
): AppleTokenResponse {
val form =
LinkedMultiValueMap<String, String>().apply {
add("grant_type", "authorization_code")
add("client_id", properties.clientId)
add("client_secret", clientSecretGenerator.generate())
add("redirect_uri", redirectUri)
add("code", code)
}
return try {
client
.post()
.uri("$APPLE_BASE/auth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(AppleTokenResponse::class.java)
} catch (e: RestClientException) {
throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 인증코드 교환에 실패했습니다", e)
} ?: throw AppException(CommonErrorCode.INVALID_TOKEN, "애플 토큰 응답이 비어 있습니다")
}

private companion object {
const val APPLE_BASE = "https://appleid.apple.com"
}
}

private data class AppleTokenResponse(
@JsonProperty("id_token") val idToken: String? = null,
@JsonProperty("refresh_token") val refreshToken: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.ai.flori.auth.oauth

import kr.ai.flori.auth.repository.ApplePendingCredentialRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Duration
import java.time.Instant

/** 온보딩 미완료로 남은 애플 pending 자격을 30일 후 정리(내부 하이진). */
@Component
class ApplePendingCredentialSweeper(
private val repository: ApplePendingCredentialRepository,
) {
@Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul")
@Transactional
fun sweep() {
// 애플은 refresh_token을 최초 인가 시에만 내려준다. 스윕 윈도가 너무 짧으면 온보딩을
// 중도 이탈했다가 뒤늦게 돌아온 유저의 pending 행이 먼저 삭제되어 토큰이 영구 유실되고,
// 탈퇴 시 revoke가 조용히 no-op 된다. 30일로 넓혀 지연 온보딩을 실질적으로 커버하면서도
// 오래된 행은 계속 정리한다.
repository.deleteByCreatedAtBefore(Instant.now().minus(Duration.ofDays(PENDING_SWEEP_WINDOW_DAYS)))
}

private companion object {
const val PENDING_SWEEP_WINDOW_DAYS = 30L
}
}
12 changes: 12 additions & 0 deletions src/main/kotlin/kr/ai/flori/auth/oauth/AppleProperties.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.ai.flori.auth.oauth

import org.springframework.boot.context.properties.ConfigurationProperties

/** 애플 Sign in with Apple 자격증명. clientId=Services ID, privateKey=.p8 PEM 전문. 서버 전용. */
@ConfigurationProperties(prefix = "apple")
data class AppleProperties(
val clientId: String = "",
val teamId: String = "",
val keyId: String = "",
val privateKey: String = "",
)
Loading
Loading