프로젝트를 하며 구글 소셜 로그인, 애플 소셜 로그인, 자체 로그인을 모두 구현해야 하는 상황이 생겼습니다. 애플 소셜 로그인은 처음 도전하는 거라, 중간 과정을 기록하고자 합니다.
정리한 로직은 다음과 같습니다.
AuthenticationServices
기능 호출해 로그인 수행identity Token
전달 : 사용자의 고유 정보가 담긴 JWTauthorization Code
전달 : 일회성 인증 코드identity Token
전달identity Toke
검증 수행애플은 클라이언트에서 로그인 요청 시, identity Token
에 사용자 정보를 담아 전달해줍니다. 때문에, 서버는 그 토큰을 검증한 후 토큰에 있는 정보를 이용해 회원가입을 진행하면 됩니다.
이후, authorizationCode
를 이용해 애플 측에서 토큰을 받아와 발급하는 경우도 있지만, 이번 프로젝트의 경우 자체 JWT를 발급하는 로직으로 구현하였기에 해당 부분은 고려하지 않았습니다.
기존에 oauth2를 이용해 구글 소셜 로그인을 구현해둔 상태라, 최대한 기존 구조를 변경하지 않고 애플 소셜 로그인 기능을 붙여보고자 했습니다.
기존 google login의 경우,
/oauth2/authorization/google
/login/oauth2/code/google
따라서, 4번을 진행할 oAuthLoginSuccessHandler
만 구현하면 됐었습니다.
그러나 이번에 새롭게 구현할 apple login의 경우,
/api/auth/apple/login
authentication
객체 생성oAuthLoginSuccessHandler
호출 (회원가입 진행 및 토큰 발급 + 딥링크)의 구조를 띠게 됩니다.
따라서, 중간 토큰 검증 로직을 추가하고, handler에 AppleUserInfo
를 파싱하는 부분만 수정하면 큰 구조 변경 없이 로그인 기능을 붙일 수 있습니다.
그럼 우선 애플 공개 키로 들어온 identity Token
을 검증해봅시다.
https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature
검증 로직은 다음과 같습니다.
https://appleid.apple.com/auth/keys 에 GET 요청을 보냄
여러 개의 public key가 담긴 리스트를 반환 받음
각 public key는 kid, alg 필드가 존재하며, 이전에 받은 identityToken
header의 kid와 일치하는 key를 선택함
header[’kid’]
는 디코딩해서 사용해야 함위에서 선택한 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)
}
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()!!
}
}
클라이언트로부터 받은 토큰의 헤더를 디코딩해, 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)
}
마지막으로, 방금 생성한 키를 이용해 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)
}
}
이제 검증한 토큰 속 정보를 이용해 회원가입을 진행합니다.
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"
)
}
조금 늦게 등장했지만, 클라이언트에서 호출하는 애플 소셜 로그인 컨트롤러입니다.
앞서 말씀드린대로 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)
}
handler의 전체 코드입니다.
구글 소셜 로그인의 경우, 리다이렉트되어 돌아온 authentication
객체를 처리하고 애플 로그인의 경우 컨트롤러에서 넘겨준 객체를 처리하는 핸들러입니다.
로직은 다음과 같습니다.
OAuth2UserInfo
dto로 변환@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
}
}
애플 자체 토큰을 사용하지 않았기 때문에, 비교적 빠르게 로그인 기능을 구현해볼 수 있었습니다. 중간 로직 정리만 잘 하면 다른 소셜 로그인과 크게 다르지 않았습니다.
다음에 기회가 된다면, 애플 자체 토큰도 사용해보고 포스팅하도록 하겠습니다. 아자아자