[SpringBoot] Apple 소셜 로그인 적용하기

다은·2025년 8월 24일
1

SpringBoot

목록 보기
10/12
post-thumbnail

프로젝트를 하며 구글 소셜 로그인, 애플 소셜 로그인, 자체 로그인을 모두 구현해야 하는 상황이 생겼습니다. 애플 소셜 로그인은 처음 도전하는 거라, 중간 과정을 기록하고자 합니다.


1. 로직

정리한 로직은 다음과 같습니다.

  1. [User → Apple]
    • 유저가 애플 로그인 버튼 클릭
    • iOS의 AuthenticationServices 기능 호출해 로그인 수행
    • 네이티브 인증 화면을 통해 애플 로그인 및 인증 진행
  2. [Apple → Client]
    • identity Token 전달 : 사용자의 고유 정보가 담긴 JWT
    • authorization Code 전달 : 일회성 인증 코드
  3. [Client → Server]
    • 애플 로그인 API 호출
    • identity Token 전달
  4. [Server → Apple]
    • Apple의 공개키 이용해 identity Toke 검증 수행
  5. [Server → Client]
    • 회원가입 로직 처리 및 자체 JWT 발급 후 반환

애플은 클라이언트에서 로그인 요청 시, identity Token에 사용자 정보를 담아 전달해줍니다. 때문에, 서버는 그 토큰을 검증한 후 토큰에 있는 정보를 이용해 회원가입을 진행하면 됩니다.

이후, authorizationCode를 이용해 애플 측에서 토큰을 받아와 발급하는 경우도 있지만, 이번 프로젝트의 경우 자체 JWT를 발급하는 로직으로 구현하였기에 해당 부분은 고려하지 않았습니다.


2. 기존 구조와의 차이점

기존에 oauth2를 이용해 구글 소셜 로그인을 구현해둔 상태라, 최대한 기존 구조를 변경하지 않고 애플 소셜 로그인 기능을 붙여보고자 했습니다.


기존 google login의 경우,

  1. 클라이언트가 security가 생성해준 API 호출
    • /oauth2/authorization/google
  2. 웹 상에서 구글 로그인 진행
  3. security가 생성해준 서버 리다렉 주소를 이용해 인증 진행 및 토큰 발급
    • /login/oauth2/code/google
  4. 서버에서 회원가입 진행 및 토큰 발급
  5. 앱(클라이언트)으로 딥링크 발송

따라서, 4번을 진행할 oAuthLoginSuccessHandler만 구현하면 됐었습니다.


그러나 이번에 새롭게 구현할 apple login의 경우,

  1. 클라이언트가 애플 로그인 진행
  2. 서버에게 Identify token 검증 요청 (API 호출)
    • /api/auth/apple/login
  3. 서버가 pub key 이용해 identify token 검증
  4. 검증 후 authentication 객체 생성
  5. oAuthLoginSuccessHandler 호출 (회원가입 진행 및 토큰 발급 + 딥링크)

의 구조를 띠게 됩니다.


따라서, 중간 토큰 검증 로직을 추가하고, handler에 AppleUserInfo를 파싱하는 부분만 수정하면 큰 구조 변경 없이 로그인 기능을 붙일 수 있습니다.


3. Public Key로 토큰 검증하기

그럼 우선 애플 공개 키로 들어온 identity Token을 검증해봅시다.
https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature


검증 로직은 다음과 같습니다.

  1. https://appleid.apple.com/auth/keys 에 GET 요청을 보냄

  2. 여러 개의 public key가 담긴 리스트를 반환 받음

  3. 각 public key는 kid, alg 필드가 존재하며, 이전에 받은 identityToken header의 kid와 일치하는 key를 선택함

    • token의 header[’kid’]는 디코딩해서 사용해야 함
  4. 위에서 선택한 key를 이용해 identityToken을 검증함

    /**
     * Validate Apple's Identify Token And Get Claims
     */
    private fun validateAppleToken(identityToken: String): Claims {
        val pubKeyResponse = appleClient.getPublicKey()
        val kid = jwtUtil.getKidFromToken(identityToken)

        val matchedPubKey = getMatchedPublicKey(kid, pubKeyResponse.keys)
        val pubKey = createPublicKey(matchedPubKey)

        return jwtUtil.getClaimFromIdentityTokenWithPubKey(identityToken, pubKey)
    }

1. Apple Pub Key 가져오기

public Key를 저장할 DTO는 다음과 같습니다.

data class ApplePublicKeyResponse(
    val keys: List<Key>
)

data class Key(
    val kti: String,
    val kid: String,
    val use: String,
    val alg: String,
    val n: String,
    val e: String
)

webClient를 이용해, apple로부터 N개의 공개키 리스트를 받아옵니다.

@Service
class AppleClientImpl(
    @Value("\${apple.pub-key-uri}") private val publicKeyUri: String,
    private val webClient: WebClient
): AppleClient {

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

    /**
     * Get Public Key From Apple Server
     */
    override fun getPublicKey(): ApplePublicKeyResponse {
        return webClient.get()
            .uri(publicKeyUri)
            .retrieve()
            .onStatus({ it.is4xxClientError }) { response ->
                response.bodyToMono(String::class.java)
                    .flatMap {
                        log.error("Client error body: {}", it)
                        Mono.error(GeneralException(ErrorStatus.APPLE_LOGIN_PUB_KEY_CLIENT_ERROR))
                    }
            }
            .onStatus({ it.is5xxServerError }) { response ->
                response.bodyToMono(String::class.java)
                    .flatMap {
                        log.error("Server error body: {}", it)
                        Mono.error(GeneralException(ErrorStatus.APPLE_LOGIN_PUB_KEY_SERVER_ERROR))
                    }
            }
            .bodyToMono(ApplePublicKeyResponse::class.java)
            .block()!!
    }

}

2. 공개키 추출

클라이언트로부터 받은 토큰의 헤더를 디코딩해, KID 값을 추출합니다.

    fun getKidFromToken(identityToken: String): String {
        val tokenParts = identityToken.split(".")
        if (tokenParts.size < 3) {
            throw GeneralException(ErrorStatus.INVALID_IDENTITY_TOKEN_FORMAT)
        }

        val encodedHeader = tokenParts[0]
        val decodedHeader = String(Base64.getUrlDecoder().decode(encodedHeader))

        val headerNode = objectMapper.readTree(decodedHeader)
        val kid = headerNode.get("kid")?.asText()

        return kid ?: throw GeneralException(ErrorStatus.APPLE_LOGIN_KID_DECODE_SERVER_ERROR)
    }

이 KID값과 동일한 KID값을 가진 공개 키가 우리가 토큰 검증에 사용할 공개 키입니다.

private fun getMatchedPublicKey(kid: String, pubKeyList: List<Key>): Key {
        return pubKeyList.firstOrNull { it.kid == kid}
            ?: throw GeneralException(ErrorStatus.APPLE_LOGIN_NO_MATCHING_PUB_KEY)
    }

이 공개키를 이용해 자체적 키를 생성합니다.
키 생성에는 n, e를 이용해 RSA Pub Key를 생성합니다.

    private fun createPublicKey(matchedPubKey: Key): PublicKey {
        val n = BigInteger(1, Base64.getUrlDecoder().decode(matchedPubKey.n))
        val e = BigInteger(1, Base64.getUrlDecoder().decode(matchedPubKey.e))

        val keySpec = RSAPublicKeySpec(n, e)
        val keyFactory = KeyFactory.getInstance(matchedPubKey.kty)
        return keyFactory.generatePublic(keySpec)
    }

3. 토큰 검증

마지막으로, 방금 생성한 키를 이용해 identityToken을 검증해 Claim을 반환하면 됩니다.

    fun getClaimFromIdentityTokenWithPubKey(identityToken: String, pubKey: PublicKey): Claims {
        try {
            return Jwts.parser()
                .verifyWith(pubKey)
                .build()
                .parseSignedClaims(identityToken)
                .payload
        } catch (e: ExpiredJwtException) {
          log.warn("[*] JWT APPLE Identity Token Expiration error : ${e.message}")
          throw GeneralException(ErrorStatus.EXPIRED_TOKEN_ERROR)
        } catch (e: Exception) {
            log.warn("[*] JWT APPLE Identity Token error : ${e.message}")
            throw GeneralException(ErrorStatus.APPLE_LOGIN_VERIFY_IDENTITY_TOKEN_SERVER_ERROR)
        }
    }



4. Authentication 생성 및 회원가입

1. Authentication 생성

이제 검증한 토큰 속 정보를 이용해 회원가입을 진행합니다.
OAuth2의 소셜 로그인 로직을 이용하면, 자동으로 유저 정보가 OAuth2AuthenticationToken으로 생성되지만, 여기서는 그렇지 않기 때문에, 직접 만들어 처리 핸들러에게 넘겨줍시다.

    /**
     * Valid Apple's Identity Token And Handle Social Sign-Up
     */
    override fun authenticateWithApple(request: AppleLoginRequest): Authentication {
        val claims = validateAppleToken(request.identityToken)

        val attributes = claims
        val authorities = listOf(SimpleGrantedAuthority("ROLE_USER"))
        val principal = DefaultOAuth2User(authorities, attributes, "sub")

        return OAuth2AuthenticationToken(
            principal,
            authorities,
            "apple"
        )
    }

2. Controller

조금 늦게 등장했지만, 클라이언트에서 호출하는 애플 소셜 로그인 컨트롤러입니다.
앞서 말씀드린대로 identity Token을 넘겨 검증한 후, 회원가입을 처리하는 단계로 구성되어있습니다.

4-1에서 Authentication을 생성했으니, 기존에 만들어둔 oAuthLoginSuccessHandler로 객체를 넘겨 회원가입을 마무리해줍니다.

    @PostMapping("/apple/login")
    fun appleLogin(
        @RequestBody request: AppleLoginRequest,
        httpRequest: HttpServletRequest,
        httpResponse: HttpServletResponse
    ) {
        val authentication = authService.authenticateWithApple(request)
        oAuthLoginSuccessHandler.onAuthenticationSuccess(httpRequest, httpResponse, authentication)
    }

3. oAuthLoginSuccessHandler

handler의 전체 코드입니다.
구글 소셜 로그인의 경우, 리다이렉트되어 돌아온 authentication 객체를 처리하고 애플 로그인의 경우 컨트롤러에서 넘겨준 객체를 처리하는 핸들러입니다.


로직은 다음과 같습니다.

  1. 유저에 대한 정보를 받아와 OAuth2UserInfo dto로 변환
  2. 이미 존재하는 유저인지 확인
  3. 존재하지 않으면 User 정보 저장
  4. 자체 JWT 토큰 생성
  5. 앱 딥링크로 리다이렉트 전송
@Component
class OAuthLoginSuccessHandler(
    private val tokenService: TokenService,
    private val userRepository: UserRepository
): AuthenticationSuccessHandler {

    override fun onAuthenticationSuccess(
        request: HttpServletRequest?,
        response: HttpServletResponse?,
        authentication: Authentication
    ) {
        // extract user's oauth information
        val oAuthToken = authentication as OAuth2AuthenticationToken
        val userInfo = getOAuth2UserInfo(oAuthToken)

        // save new user or deal with existing user
        val user = userRepository.findByProviderId(userInfo.getProviderId())
            ?: userRepository.save(
                User(
                    username = userInfo.getName(),
                    providerId = userInfo.getProviderId(),
                    email = userInfo.getEmail(),
                    provider = PROVIDER.from(userInfo.getProvider())
                )
            )

        // create access, refresh token
        val (accessToken, refreshToken) = tokenService.createAndSaveToken(user.userId)

        // set response
        val redirectUri = "APP_LINK?accessToken=$accessToken"
        response?.sendRedirect(redirectUri)
    }

    private fun getOAuth2UserInfo(oAuthToken: OAuth2AuthenticationToken) : OAuth2UserInfo {
        val provider = oAuthToken.authorizedClientRegistrationId
        val principal = oAuthToken.principal

        return when(provider) {
            "google" -> GoogleUserInfo(principal.attributes)
            "apple" -> AppleUserInfo(principal.attributes)
            else -> throw GeneralException(ErrorStatus.INVALID_OAUTH_PROVIDER)
        }
    }

}

여기서 등장하는 OAuthUserInfo란, 자체적으로 유저 정보를 쉽게 가져오기 위해 만든 인터페이스입니다.

interface OAuth2UserInfo {
    fun getProviderId(): String
    fun getName(): String
    fun getEmail(): String
    fun getProvider(): String
}

애플 로그인의 경우 다음과 같이 작성해 핸들러에서 보다 쉽게 유저의 정보를 가져올 수 있겠죠.

class AppleUserInfo(
    private val attributes: Map<String, Any>
) : OAuth2UserInfo {

    override fun getProviderId(): String {
        return attributes["sub"].toString()
    }

    override fun getName(): String {
        return (attributes["email"] as? String)?.substringBefore("@") ?: "Apple"
    }

    override fun getEmail(): String {
        return attributes["email"].toString()
    }

    override fun getProvider(): String {
        return PROVIDER.APPLE.name
    }
}

애플 자체 토큰을 사용하지 않았기 때문에, 비교적 빠르게 로그인 기능을 구현해볼 수 있었습니다. 중간 로직 정리만 잘 하면 다른 소셜 로그인과 크게 다르지 않았습니다.

다음에 기회가 된다면, 애플 자체 토큰도 사용해보고 포스팅하도록 하겠습니다. 아자아자

profile
CS 마스터를 향해 ..

0개의 댓글