Skip to content

Commit

Permalink
[feat #13] 애플 로그인 구현 (#15)
Browse files Browse the repository at this point in the history
* chore : 애플 프라이빗 키 추가

* feat : 애플 API Client 구현

* feat : 애플 API 요청 시 필요한 support 클래스

* feat : 애플 API 통신 시 필요 dto

* feat : 애플 관련 yml 프로퍼티 클래스

* feat : RestTemplate 빈 등록

* feat : 로그인 서비스 애플 타입 구현

* feat : out-web 모듈 jwt 의존성 추가

* feat : 애플 api 관련 에러코드 추가

* refactor : idToken 요청하는 rest 호출 코드 제거

* refactor : 로그인 요청 dto 네이밍 변경

* refactor : 네이밍 변경에 따른 테스트 수정

* refactor : 엘비스 연산자 컨벤션
  • Loading branch information
dlswns2480 authored Jul 14, 2024
1 parent 195880d commit d419332
Show file tree
Hide file tree
Showing 13 changed files with 169 additions and 9 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ out/
### Resources ###
**/src/main/resources/application-*.yml
**/src/main/resources/google-services.json
**/src/main/resources/*.p8
4 changes: 4 additions & 0 deletions adapters/out-web/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ dependencies {
implementation(project(":application"))
implementation(project(":domain"))

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter")
implementation("com.google.firebase:firebase-admin:8.1.0")
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
}

tasks {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pokit.auth.common.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestTemplate

@Configuration
class RestTemplateConfig {
@Bean
fun restTemplate() = RestTemplate()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.pokit.auth.common.dto

data class ApplePublicKey(
val kty: String,
val kid: String,
val use: String,
val alg: String,
val n: String,
val e: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pokit.auth.common.dto

data class ApplePublicKeys(
val keys: List<ApplePublicKey>,
) {
fun getMatchedKey(alg: String, kid: String): ApplePublicKey? {
return keys.firstOrNull { key ->
key.alg == alg && key.kid == kid
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.pokit.auth.common.support

import com.pokit.auth.common.dto.ApplePublicKey
import com.pokit.auth.common.dto.ApplePublicKeys
import com.pokit.common.exception.ClientValidationException
import com.pokit.token.exception.AuthErrorCode
import org.springframework.stereotype.Component
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.Base64

@Component
class AppleKeyGenerator {
fun generatePublicKey(headers: Map<String, String>, publicKeys: ApplePublicKeys): PublicKey {
val alg = headers["alg"]
?: throw ClientValidationException(AuthErrorCode.INVALID_ID_TOKEN)
val kid = headers["kid"]
?: throw ClientValidationException(AuthErrorCode.INVALID_ID_TOKEN)
val publicKey = publicKeys.getMatchedKey(alg, kid) ?: throw ClientValidationException(AuthErrorCode.INVALID_ID_TOKEN)

return getPublicKey(publicKey)
}

private fun getPublicKey(publicKey: ApplePublicKey): PublicKey {
val nBytes = Base64.getUrlDecoder().decode(publicKey.n)
val eBytes = Base64.getUrlDecoder().decode(publicKey.e)

val publicKeySpec = RSAPublicKeySpec(BigInteger(1, nBytes), BigInteger(1, eBytes))

val keyFactory = KeyFactory.getInstance(publicKey.kty)
return keyFactory.generatePublic(publicKeySpec)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.pokit.auth.common.support

import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.security.PublicKey
import java.util.*

@Component
class AppleTokenParser(
private val objectMapper: ObjectMapper
) {
private val typeReference = object : TypeReference<Map<String, String>>() {}

fun parseHeader(idToken: String): Map<String, String> {
val header = idToken.split("\\.")[0]
val decodedHeader = String(Base64.getDecoder().decode(header), StandardCharsets.UTF_8)
return objectMapper.readValue(decodedHeader, typeReference)
}

fun parseClaims(idToken: String, publicKey: PublicKey): Claims {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(idToken)
.payload
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.pokit.auth.impl

import com.pokit.auth.common.dto.ApplePublicKeys
import com.pokit.auth.common.support.AppleKeyGenerator
import com.pokit.auth.common.support.AppleTokenParser
import com.pokit.auth.port.out.AppleApiClient
import com.pokit.user.dto.UserInfo
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder


@Component
class AppleApiAdapter(
private val restTemplate: RestTemplate,
private val appleKeyGenerator: AppleKeyGenerator,
private val appleTokenParser: AppleTokenParser
) : AppleApiClient{
override fun getUserInfo(idToken: String): UserInfo {
val claims = decodeAndVerifyIdToken(idToken) // id token을 통해 사용자 정보 추출
val email = claims["email"] as String

return UserInfo(email = email)
}

// 애플에게 공개 키 요청 후 공개키로 idToken 내 고객 정보 추출
private fun decodeAndVerifyIdToken(idToken: String): Map<String, Any> {
val appleKeyUrl = "https://appleid.apple.com/auth/keys"
val url = UriComponentsBuilder
.fromUriString(appleKeyUrl)
.encode()
.build()
.toUri()

val publicKeys = restTemplate.getForObject(
url,
ApplePublicKeys::class.java
)

val header = appleTokenParser.parseHeader(idToken)
val publicKey = appleKeyGenerator.generatePublicKey(header, publicKeys)
val claims = appleTokenParser.parseClaims(idToken, publicKey)
return claims
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.pokit.auth.port.out

import com.pokit.user.dto.UserInfo

interface AppleApiClient {
fun getUserInfo(idToken: String): UserInfo
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package com.pokit.auth.port.service

import com.pokit.auth.port.`in`.AuthUseCase
import com.pokit.auth.port.`in`.TokenProvider
import com.pokit.auth.port.out.AppleApiClient
import com.pokit.auth.port.out.GoogleApiClient
import com.pokit.token.dto.request.SignInRequest
import com.pokit.token.model.AuthPlatform
import com.pokit.token.model.Token
import com.pokit.user.dto.UserInfo
import com.pokit.user.model.Role
import com.pokit.user.model.User
import com.pokit.user.port.out.UserPort
Expand All @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service
@Service
class AuthService(
private val googleApiClient: GoogleApiClient,
private val appleApiClient: AppleApiClient,
private val tokenProvider: TokenProvider,
private val userPort: UserPort,
) : AuthUseCase {
Expand All @@ -23,8 +24,8 @@ class AuthService(

val userInfo =
when (platformType) {
AuthPlatform.GOOGLE -> googleApiClient.getUserInfo(request.authorizationCode)
AuthPlatform.APPLE -> UserInfo("[email protected]") // TODO
AuthPlatform.GOOGLE -> googleApiClient.getUserInfo(request.idToken)
AuthPlatform.APPLE -> appleApiClient.getUserInfo(request.idToken)
}

val userEmail = userInfo.email
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package com.pokit.auth.port.service

import com.pokit.auth.AuthFixture
import com.pokit.auth.port.`in`.TokenProvider
import com.pokit.auth.port.out.AppleApiClient
import com.pokit.auth.port.out.GoogleApiClient
import com.pokit.common.exception.ClientValidationException
import com.pokit.out.persistence.user.persist.UserRepository
import com.pokit.user.UserFixture
import com.pokit.user.port.out.UserRepository
import com.pokit.user.port.out.UserPort
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.annotation.DisplayName
import io.kotest.core.spec.style.BehaviorSpec
Expand All @@ -17,8 +19,9 @@ import io.mockk.mockk
class AuthServiceTest : BehaviorSpec({
val googleApiClient = mockk<GoogleApiClient>()
val tokenProvider = mockk<TokenProvider>()
val userRepository = mockk<UserRepository>()
val authService = AuthService(googleApiClient, tokenProvider, userRepository)
val userPort = mockk<UserPort>()
val appleApiClient = mockk<AppleApiClient>()
val authService = AuthService(googleApiClient, appleApiClient, tokenProvider, userPort)

Given("사용자가 로그인할 때") {
val request = AuthFixture.getGoogleSigniInRequest()
Expand All @@ -27,8 +30,8 @@ class AuthServiceTest : BehaviorSpec({
val userInfo = UserFixture.getUserInfo()
val token = AuthFixture.getToken()

every { googleApiClient.getUserInfo(request.authorizationCode) } returns userInfo
every { userRepository.findByEmail(userInfo.email) } returns user
every { googleApiClient.getUserInfo(request.idToken) } returns userInfo
every { userPort.loadByEmail(userInfo.email) } returns user
every { tokenProvider.createToken(user.id) } returns token

When("구글 플랫폼으로 올바른 인증코드로 요청하면") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ package com.pokit.token.dto.request

data class SignInRequest(
val authPlatform: String,
val authorizationCode: String,
val idToken: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ enum class AuthErrorCode(
WRONG_SIGNATURE("JWT 서명이 서버에 산정된 서명과 일치하지 않습니다.", "A_005"),
TOKEN_REQUIRED("토큰이 비어있습니다.", "A_006"),
INVALID_PLATFORM("플랫폼 타입이 올바르지 않습니다.", "A_007"),
INVALID_ID_TOKEN("ID TOKEN 값이 올바르지 않습니다.", "A_008"),
}

0 comments on commit d419332

Please sign in to comment.