diff --git a/.gitignore b/.gitignore index 9be6eb35..957cdbc2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ out/ ### Resources ### **/src/main/resources/application-*.yml **/src/main/resources/google-services.json +**/src/main/resources/*.p8 diff --git a/adapters/out-web/build.gradle.kts b/adapters/out-web/build.gradle.kts index 946ac0fd..ad250a64 100644 --- a/adapters/out-web/build.gradle.kts +++ b/adapters/out-web/build.gradle.kts @@ -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 { diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/common/config/RestTemplateConfig.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/config/RestTemplateConfig.kt new file mode 100644 index 00000000..fb0ac122 --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/config/RestTemplateConfig.kt @@ -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() +} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKey.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKey.kt new file mode 100644 index 00000000..11e305d9 --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKey.kt @@ -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, +) diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKeys.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKeys.kt new file mode 100644 index 00000000..93d2af9c --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/dto/ApplePublicKeys.kt @@ -0,0 +1,11 @@ +package com.pokit.auth.common.dto + +data class ApplePublicKeys( + val keys: List, +) { + fun getMatchedKey(alg: String, kid: String): ApplePublicKey? { + return keys.firstOrNull { key -> + key.alg == alg && key.kid == kid + } + } +} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleKeyGenerator.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleKeyGenerator.kt new file mode 100644 index 00000000..831d1675 --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleKeyGenerator.kt @@ -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, 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) + } +} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleTokenParser.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleTokenParser.kt new file mode 100644 index 00000000..d353adab --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/common/support/AppleTokenParser.kt @@ -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>() {} + + fun parseHeader(idToken: String): Map { + 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 + } +} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/auth/impl/AppleApiAdapter.kt b/adapters/out-web/src/main/kotlin/com/pokit/auth/impl/AppleApiAdapter.kt new file mode 100644 index 00000000..955c8e13 --- /dev/null +++ b/adapters/out-web/src/main/kotlin/com/pokit/auth/impl/AppleApiAdapter.kt @@ -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 { + 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 + } +} diff --git a/application/src/main/kotlin/com/pokit/auth/port/out/AppleApiClient.kt b/application/src/main/kotlin/com/pokit/auth/port/out/AppleApiClient.kt new file mode 100644 index 00000000..5eea228a --- /dev/null +++ b/application/src/main/kotlin/com/pokit/auth/port/out/AppleApiClient.kt @@ -0,0 +1,7 @@ +package com.pokit.auth.port.out + +import com.pokit.user.dto.UserInfo + +interface AppleApiClient { + fun getUserInfo(idToken: String): UserInfo +} diff --git a/application/src/main/kotlin/com/pokit/auth/port/service/AuthService.kt b/application/src/main/kotlin/com/pokit/auth/port/service/AuthService.kt index b1541572..729b4032 100644 --- a/application/src/main/kotlin/com/pokit/auth/port/service/AuthService.kt +++ b/application/src/main/kotlin/com/pokit/auth/port/service/AuthService.kt @@ -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 @@ -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 { @@ -23,8 +24,8 @@ class AuthService( val userInfo = when (platformType) { - AuthPlatform.GOOGLE -> googleApiClient.getUserInfo(request.authorizationCode) - AuthPlatform.APPLE -> UserInfo("apple@apple.com") // TODO + AuthPlatform.GOOGLE -> googleApiClient.getUserInfo(request.idToken) + AuthPlatform.APPLE -> appleApiClient.getUserInfo(request.idToken) } val userEmail = userInfo.email diff --git a/application/src/test/kotlin/com/pokit/auth/port/service/AuthServiceTest.kt b/application/src/test/kotlin/com/pokit/auth/port/service/AuthServiceTest.kt index 211a8521..bcf37d4c 100644 --- a/application/src/test/kotlin/com/pokit/auth/port/service/AuthServiceTest.kt +++ b/application/src/test/kotlin/com/pokit/auth/port/service/AuthServiceTest.kt @@ -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 @@ -17,8 +19,9 @@ import io.mockk.mockk class AuthServiceTest : BehaviorSpec({ val googleApiClient = mockk() val tokenProvider = mockk() - val userRepository = mockk() - val authService = AuthService(googleApiClient, tokenProvider, userRepository) + val userPort = mockk() + val appleApiClient = mockk() + val authService = AuthService(googleApiClient, appleApiClient, tokenProvider, userPort) Given("사용자가 로그인할 때") { val request = AuthFixture.getGoogleSigniInRequest() @@ -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("구글 플랫폼으로 올바른 인증코드로 요청하면") { diff --git a/domain/src/main/kotlin/com/pokit/token/dto/request/SignInRequest.kt b/domain/src/main/kotlin/com/pokit/token/dto/request/SignInRequest.kt index cb9269f4..01fbcdcc 100644 --- a/domain/src/main/kotlin/com/pokit/token/dto/request/SignInRequest.kt +++ b/domain/src/main/kotlin/com/pokit/token/dto/request/SignInRequest.kt @@ -2,5 +2,5 @@ package com.pokit.token.dto.request data class SignInRequest( val authPlatform: String, - val authorizationCode: String, + val idToken: String, ) diff --git a/domain/src/main/kotlin/com/pokit/token/exception/AuthErrorCode.kt b/domain/src/main/kotlin/com/pokit/token/exception/AuthErrorCode.kt index cc0d127e..9160ea77 100644 --- a/domain/src/main/kotlin/com/pokit/token/exception/AuthErrorCode.kt +++ b/domain/src/main/kotlin/com/pokit/token/exception/AuthErrorCode.kt @@ -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"), }