diff --git a/pida-clients/oauth-client/build.gradle.kts b/pida-clients/oauth-client/build.gradle.kts index b8ff9c1..20ebf0a 100644 --- a/pida-clients/oauth-client/build.gradle.kts +++ b/pida-clients/oauth-client/build.gradle.kts @@ -3,5 +3,9 @@ dependencies { implementation(libs.bouncycastle.bcpkix) implementation(libs.bundles.openfeign) + implementation(libs.jjwt.api) + runtimeOnly(libs.jjwt.jackson) + runtimeOnly(libs.jjwt.impl) + implementation(project(":pida-core:core-domain")) } diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleApi.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleApi.kt index bc9a675..343025f 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleApi.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleApi.kt @@ -3,9 +3,19 @@ package com.pida.client.oauth import com.pida.client.oauth.response.ApplePublicKeysResponse import org.springframework.cloud.openfeign.FeignClient import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam @FeignClient(name = "apple-auth-api", url = "https://appleid.apple.com") internal interface AppleApi { @GetMapping("/auth/keys") fun getApplePublicKeys(): ApplePublicKeysResponse + + @PostMapping("/auth/revoke", consumes = ["application/x-www-form-urlencoded"]) + fun revokeToken( + @RequestParam("client_id") clientId: String, + @RequestParam("client_secret") clientSecret: String, + @RequestParam("token") token: String, + @RequestParam("token_type_hint") tokenTypeHint: String = "refresh_token", + ) } diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt index 4a94b8a..9262306 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleClient.kt @@ -10,8 +10,17 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import com.pida.support.error.AuthenticationErrorException import com.pida.support.error.AuthenticationErrorType +import io.jsonwebtoken.Jwts import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec import java.text.ParseException +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Base64 import java.util.Date @Component @@ -40,6 +49,16 @@ class AppleClient internal constructor( } } + fun revoke(token: String) { + val clientSecret = generateAppleClientSecret() + + appleApi.revokeToken( + appleProperties.bundleId, + clientSecret, + token, + ) + } + fun verify(token: String): Boolean { val signedJWT: SignedJWT val jwtClaims: JWTClaimsSet @@ -54,7 +73,6 @@ class AppleClient internal constructor( return false } val currentDate = Date(System.currentTimeMillis()) - // audience 확인 부분 개선 val bundleId = jwtClaims.audience.firstOrNull() if (bundleId != appleProperties.bundleId) return false @@ -82,4 +100,55 @@ class AppleClient internal constructor( } return false } + + fun generateAppleClientSecret(): String { + val expirationDate = + Date.from( + LocalDateTime + .now() + .plusMinutes(5) + .atZone(ZoneId.systemDefault()) + .toInstant(), + ) + + val teamId = appleProperties.teamId + val clientId = appleProperties.bundleId + val keyId = appleProperties.keyId + + val now = Date() + + val jwtBuilder = + Jwts + .builder() + .header() + .add("kid", keyId) + .add("alg", "ES256") + .and() + .issuer(teamId) + .issuedAt(now) + .expiration(expirationDate) + .audience() + .add("https://appleid.apple.com") + .and() + .subject(clientId) + + return jwtBuilder + .signWith(getPrivateKey()) + .compact() + } + + fun getPrivateKey(): PrivateKey { + val p8 = appleProperties.privateKey + val keyContent = + Files + .readAllLines(Paths.get(p8)) + .filterNot { it.startsWith("-----") } + .joinToString("") + + val decoded = Base64.getDecoder().decode(keyContent) + val keySpec = PKCS8EncodedKeySpec(decoded) + val keyFactory = KeyFactory.getInstance("EC") + + return keyFactory.generatePrivate(keySpec) + } } diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleProperties.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleProperties.kt index b50f967..d700eff 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleProperties.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/AppleProperties.kt @@ -5,4 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("apple") data class AppleProperties( val bundleId: String, + val keyId: String, + val teamId: String, + val privateKey: String, ) diff --git a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt index 1cad4a7..40df332 100644 --- a/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt +++ b/pida-clients/oauth-client/src/main/kotlin/com/pida/client/oauth/OAuthService.kt @@ -25,4 +25,8 @@ class OAuthService( if (!appleClient.verify(token)) throw AuthenticationErrorException(AuthenticationErrorType.INVALID_APPLE_TOKEN) return appleClient.getUserInfo(token) } + + fun revokeApple(refreshToken: String) { + appleClient.revoke(refreshToken) + } } diff --git a/pida-clients/oauth-client/src/main/resources/oauth.yml b/pida-clients/oauth-client/src/main/resources/oauth.yml index 9828eac..bcfaece 100644 --- a/pida-clients/oauth-client/src/main/resources/oauth.yml +++ b/pida-clients/oauth-client/src/main/resources/oauth.yml @@ -22,3 +22,6 @@ spring: apple: bundle-id: ${APPLE_BUNDLE_ID} + key-id: ${APPLE_KEY_ID} + team-id: ${APPLE_TEAM_ID} + private-key: ${APPLE_P8} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/UserController.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/UserController.kt index e8eb35e..752e395 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/UserController.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/UserController.kt @@ -1,7 +1,9 @@ package com.pida.presentation.v1.user import com.pida.auth.AuthenticationService +import com.pida.client.oauth.OAuthService import com.pida.presentation.v1.annotation.ApiV1Controller +import com.pida.presentation.v1.user.request.AppleRefreshTokenRequest import com.pida.presentation.v1.user.request.UpdateNicknameRequest import com.pida.presentation.v1.user.response.UserProfileResponse import com.pida.presentation.v1.user.response.UserWithdrawalResponse @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestBody class UserController( private val userService: UserService, private val authenticationService: AuthenticationService, + private val oAuthService: OAuthService, ) { @Operation(summary = "내 정보 조회", description = "내 정보를 조회합니다.") @GetMapping("/users/me") @@ -35,6 +38,7 @@ class UserController( @DeleteMapping("/users") suspend fun withdrawal( @Parameter(hidden = true, required = false) user: User, + @RequestBody request: AppleRefreshTokenRequest, ): UserWithdrawalResponse { userService.deleteUser( NewUserWithdrawal( @@ -42,6 +46,8 @@ class UserController( ), ) authenticationService.delete(user.key) + + oAuthService.revokeApple(request.refreshToken) return UserWithdrawalResponse("회원탈퇴가 완료되었습니다.") } diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/request/AppleRefreshTokenRequest.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/request/AppleRefreshTokenRequest.kt new file mode 100644 index 0000000..17bd833 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/user/request/AppleRefreshTokenRequest.kt @@ -0,0 +1,8 @@ +package com.pida.presentation.v1.user.request + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "애플 토큰 만료 요청") +data class AppleRefreshTokenRequest( + val refreshToken: String, +)