Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum class ErrorCode(
REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 유효하지 않습니다."),

OAUTH2_PROVIDER_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인입니다."),
APPLE_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "Apple 인증 토큰이 유효하지 않습니다."),

NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."),
NICKNAME_INVALID(HttpStatus.BAD_REQUEST, "닉네임은 특수문자를 제외하고 2~10자로 입력해 주세요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ class SecurityConfig(
"/v3/api-docs.yaml",
"/oauth2/**",
"/login/oauth2/**",
"/api/auth/refresh"
"/api/auth/refresh",
"/api/auth/oauth/apple"
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.peakda.server.domain.auth.application

import com.peakda.server.common.security.cookie.CookieProperties
import com.peakda.server.common.security.cookie.CookieUtils
import com.peakda.server.common.security.jwt.JwtProperties
import com.peakda.server.common.security.jwt.JwtTokenGenerator
import com.peakda.server.common.security.principal.PrincipalDetails
import com.peakda.server.domain.auth.oauth.apple.AppleIdTokenVerifier
import com.peakda.server.domain.auth.oauth.model.AppleOAuth2UserInfo
import com.peakda.server.domain.auth.oauth.model.OAuth2LoginType
import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse
import com.peakda.server.domain.auth.signup.application.SignupSessionService
import com.peakda.server.domain.user.entity.User
import com.peakda.server.domain.user.repository.UserRepository
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseCookie
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

/**
* Apple 네이티브 로그인 처리.
*
* iOS 가 보낸 id_token 을 검증한 뒤 기존 회원이면 로그인 토큰을, 신규면 가입 세션 토큰을 쿠키로 발급한다.
* 쿠키 세팅 방식은 웹 리다이렉트 플로우의 OAuth2AuthenticationSuccessHandler 와 동일하다.
*/
@Service
class AppleLoginService(
private val appleIdTokenVerifier: AppleIdTokenVerifier,
private val userRepository: UserRepository,
private val signupSessionService: SignupSessionService,
private val jwtTokenGenerator: JwtTokenGenerator,
private val refreshTokenService: RefreshTokenService,
private val cookieProperties: CookieProperties,
private val jwtProperties: JwtProperties,
) {

private val log = LoggerFactory.getLogger(this::class.java)

@Transactional
fun login(identityToken: String, response: HttpServletResponse): AppleLoginResponse {
val claims = appleIdTokenVerifier.verify(identityToken)
val userInfo = AppleOAuth2UserInfo(sub = claims.sub, email = claims.email)

val user = userRepository.findByProviderAndProviderId(OAuth2LoginType.APPLE, userInfo.getProviderId())
return if (user != null) {
issueLoginCookies(user, response)
AppleLoginResponse(signupRequired = false)
} else {
issueSignupCookie(userInfo, response)
AppleLoginResponse(signupRequired = true)
}
}

private fun issueLoginCookies(user: User, response: HttpServletResponse) {
val userId = requireNotNull(user.id)
log.info("Apple 로그인 성공. userId={}", userId)

val tokenResponse = jwtTokenGenerator.generateToken(
userId = userId,
email = user.email,
authorities = listOf("${PrincipalDetails.ROLE_PREFIX}${user.role.name}"),
)
refreshTokenService.saveRefreshToken(userId, tokenResponse.refreshToken)

response.addCookie(
CookieUtils.createAccessTokenCookie(
token = tokenResponse.accessToken,
maxAge = jwtProperties.accessTokenValidityInSeconds,
properties = cookieProperties,
),
)
response.addCookie(
CookieUtils.createRefreshTokenCookie(
token = tokenResponse.refreshToken,
maxAge = jwtProperties.refreshTokenValidityInSeconds,
properties = cookieProperties,
),
)
response.addCookie(CookieUtils.deleteSignupTokenCookie(cookieProperties))
}

private fun issueSignupCookie(userInfo: AppleOAuth2UserInfo, response: HttpServletResponse) {
val signupSession = signupSessionService.createOrRefresh(OAuth2LoginType.APPLE, userInfo)
log.info("Apple 회원가입 필요. signupSessionId={}", signupSession.id)

response.addCookie(CookieUtils.createSignupTokenCookie(signupSession.token, cookieProperties))
response.addCookie(CookieUtils.deleteAccessTokenCookie(cookieProperties))
response.addCookie(CookieUtils.deleteRefreshTokenCookie(cookieProperties))
}

private fun HttpServletResponse.addCookie(cookie: ResponseCookie) {
addHeader("Set-Cookie", cookie.toString())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.peakda.server.domain.auth.oauth.apple

/** Apple id_token 검증 결과에서 추출한 사용자 식별 정보. */
data class AppleClaims(
val sub: String,
val email: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.peakda.server.domain.auth.oauth.apple

import com.peakda.server.common.exception.BusinessException
import com.peakda.server.common.exception.ErrorCode

/** Apple id_token 검증 실패 (서명·issuer·audience·만료 등). */
class AppleTokenInvalidException : BusinessException(ErrorCode.APPLE_TOKEN_INVALID)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.peakda.server.domain.auth.oauth.apple

import io.jsonwebtoken.Claims
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.LocatorAdapter
import io.jsonwebtoken.ProtectedHeader
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.security.Key

/**
* Apple 네이티브 로그인의 id_token(JWT)을 검증한다.
*
* 1. 헤더의 `kid` 로 [ApplePublicKeyClient] 에서 서명 공개키를 찾아 서명을 검증한다.
* 2. `iss == https://appleid.apple.com`, `aud == app.apple.client-id`, 만료 여부를 검증한다.
* 3. `sub`(providerId)·`email` 을 추출해 [AppleClaims] 로 반환한다.
*
* 검증에 실패하면 [AppleTokenInvalidException] 을 던진다.
*/
@Component
class AppleIdTokenVerifier(
private val applePublicKeyClient: ApplePublicKeyClient,
private val appleProperties: AppleProperties,
) {

private val log = LoggerFactory.getLogger(this::class.java)

fun verify(identityToken: String): AppleClaims {
val claims = parseClaims(identityToken)
return AppleClaims(
sub = claims.subject ?: throw AppleTokenInvalidException(),
email = claims["email"] as? String,
)
}

private fun parseClaims(identityToken: String): Claims {
try {
return Jwts.parser()
.keyLocator(keyLocator)
.requireIssuer(AppleProperties.ISSUER)
.requireAudience(appleProperties.clientId)
.build()
.parseSignedClaims(identityToken)
.payload
} catch (e: JwtException) {
log.debug("Apple id_token 검증 실패: {}", e.message)
throw AppleTokenInvalidException()
} catch (e: IllegalArgumentException) {
log.debug("Apple id_token 검증 실패: {}", e.message)
throw AppleTokenInvalidException()
}
}

private val keyLocator = object : LocatorAdapter<Key>() {
override fun locate(header: ProtectedHeader): Key? =
header.keyId?.let { applePublicKeyClient.findPublicKey(it) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.peakda.server.domain.auth.oauth.apple

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

/**
* Apple 네이티브 로그인(id_token 검증) 설정.
*
* [clientId] 는 id_token 의 `aud` 클레임과 일치해야 하는 Apple 앱의 Bundle ID(또는 Services ID)다.
* issuer / JWKS URL 은 Apple 고정값이므로 상수로 둔다.
*/
@ConfigurationProperties(prefix = "app.apple")
data class AppleProperties(
val clientId: String,
) {
companion object {
const val ISSUER = "https://appleid.apple.com"
const val JWKS_URL = "https://appleid.apple.com/auth/keys"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.peakda.server.domain.auth.oauth.apple

import io.jsonwebtoken.security.JwkSet
import io.jsonwebtoken.security.Jwks
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import java.security.Key
import java.util.concurrent.atomic.AtomicReference

/**
* Apple 공개키(JWKS)를 조회하고 `kid` 로 서명 키를 해석한다.
*
* Apple 은 키를 주기적으로 회전하므로 결과를 캐시하되, 요청한 `kid` 가 캐시에 없으면
* (= 키 회전 직후) 한 번 강제로 다시 조회한다.
*/
@Component
class ApplePublicKeyClient(
restClientBuilder: RestClient.Builder,
) {

private val log = LoggerFactory.getLogger(this::class.java)
private val restClient: RestClient = restClientBuilder.build()
private val cachedKeys = AtomicReference<Map<String, Key>>(emptyMap())

/**
* 주어진 [kid] 에 해당하는 Apple 공개키를 반환한다. 캐시 미스 시 JWKS 를 강제 갱신해 한 번 더 시도한다.
* 해당 키를 끝내 찾지 못하면 null 을 반환한다.
*/
fun findPublicKey(kid: String): Key? {
cachedKeys.get()[kid]?.let { return it }
return refresh()[kid]
}

private fun refresh(): Map<String, Key> {
val keys = fetchKeys()
cachedKeys.set(keys)
return keys
}

private fun fetchKeys(): Map<String, Key> {
val body = restClient.get()
.uri(AppleProperties.JWKS_URL)
.retrieve()
.body(String::class.java)
?: throw IllegalStateException("Apple JWKS 응답이 비어 있습니다.")

val jwkSet: JwkSet = Jwks.setParser().build().parse(body)
return jwkSet.getKeys().associate { jwk ->
requireNotNull(jwk.getId()) { "Apple JWK 에 kid 가 없습니다." } to jwk.toKey()
}.also { log.debug("Apple JWKS refreshed. kids={}", it.keys) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.peakda.server.domain.auth.oauth.model

/**
* Apple id_token 검증 결과로부터 만든 사용자 정보.
*
* 다른 provider 와 달리 OAuth2 userinfo 응답 맵이 아니라 검증된 id_token 클레임의
* `sub`(providerId)·`email` 로 구성한다. Apple 은 이름·프로필 이미지를 토큰에 담지 않으므로
* [getProfileImageUrl] 은 항상 null 이다.
*/
class AppleOAuth2UserInfo(
private val sub: String,
private val email: String?,
) : OAuth2UserInfo(emptyMap()) {

override fun getProviderId(): String = sub

override fun getEmail(): String? = email

override fun getProfileImageUrl(): String? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.peakda.server.domain.auth.presentation
import com.peakda.server.common.response.ApiResponse
import com.peakda.server.common.security.principal.PrincipalDetails
import com.peakda.server.common.security.principal.SignupSessionPrincipal
import com.peakda.server.domain.auth.application.AppleLoginService
import com.peakda.server.domain.auth.application.AuthService
import com.peakda.server.domain.auth.presentation.request.AppleLoginRequest
import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse
import com.peakda.server.domain.auth.presentation.response.UserInfoResponse
import com.peakda.server.domain.auth.signup.presentation.request.SignupCompleteRequest
import com.peakda.server.domain.auth.signup.presentation.response.NicknameCheckResponse
Expand All @@ -20,8 +23,17 @@ import org.springframework.web.multipart.MultipartFile
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService,
private val appleLoginService: AppleLoginService,
) : AuthControllerDocs {

override fun appleLogin(
request: AppleLoginRequest,
response: HttpServletResponse,
): ResponseEntity<ApiResponse<AppleLoginResponse>> {
val result = appleLoginService.login(request.identityToken, response)
return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK, result))
}

override fun getCurrentUser(
principal: PrincipalDetails,
): ResponseEntity<ApiResponse<UserInfoResponse>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.peakda.server.common.openapi.ApiErrorResponses
import com.peakda.server.common.response.ApiResponse
import com.peakda.server.common.security.principal.PrincipalDetails
import com.peakda.server.common.security.principal.SignupSessionPrincipal
import com.peakda.server.domain.auth.presentation.request.AppleLoginRequest
import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse
import com.peakda.server.domain.auth.presentation.response.UserInfoResponse
import com.peakda.server.domain.auth.signup.presentation.request.SignupCompleteRequest
import com.peakda.server.domain.auth.signup.presentation.response.NicknameCheckResponse
Expand Down Expand Up @@ -33,6 +35,20 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBod
@Tag(name = "Auth", description = "로그인, 회원가입, 토큰 관리 API")
interface AuthControllerDocs {

@Operation(
summary = "Apple 네이티브 로그인",
description = "iOS Apple 로그인 SDK 가 발급한 identity token 을 검증한다. " +
"기존 회원이면 access-token·refresh-token 쿠키를 발급(signupRequired=false)하고, " +
"신규 사용자면 signup-token 쿠키를 발급(signupRequired=true)하여 회원가입 완료가 필요함을 알린다.",
)
@ApiErrorResponses(ErrorCode.INVALID_REQUEST, ErrorCode.APPLE_TOKEN_INVALID)
@PostMapping("/oauth/apple")
fun appleLogin(
@Valid @RequestBody request: AppleLoginRequest,
@Parameter(hidden = true)
response: HttpServletResponse,
): ResponseEntity<ApiResponse<AppleLoginResponse>>

@Operation(
summary = "내 정보 조회",
description = "access-token 쿠키로 현재 로그인한 사용자의 정보를 조회합니다.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.peakda.server.domain.auth.presentation.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank

@Schema(description = "Apple 네이티브 로그인 요청")
data class AppleLoginRequest(
@field:Schema(
description = "iOS Apple 로그인 SDK 가 발급한 identity token (JWT)",
example = "eyJraWQiOiJ...",
requiredMode = Schema.RequiredMode.REQUIRED,
)
@field:NotBlank
val identityToken: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.peakda.server.domain.auth.presentation.response

import io.swagger.v3.oas.annotations.media.Schema

@Schema(description = "Apple 로그인 결과")
data class AppleLoginResponse(
@field:Schema(
description = "추가 회원가입이 필요한지 여부. " +
"true 면 signup-token 쿠키가 발급되어 회원가입 완료(/api/auth/signup/complete)가 필요하고, " +
"false 면 access-token·refresh-token 쿠키가 발급되어 로그인이 완료된 상태다.",
example = "false",
)
val signupRequired: Boolean,
)
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ app:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
oauth2:
redirect-uri: ${OAUTH2_REDIRECT_URI}
apple:
client-id: ${APPLE_CLIENT_ID}
openapi:
servers:
local: http://localhost:8080
Expand Down
Loading