diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawStrategyRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawStrategyRequest.kt index da1591df..ff9fc72d 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawStrategyRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawStrategyRequest.kt @@ -35,7 +35,13 @@ data class WithdrawStrategyRequest private constructor( example = "r_abc123def456ghi789jkl0mnopqrstu", required = false ) - val appleRefreshToken: String? + val appleRefreshToken: String?, + @field:Schema( + description = "Google 로그인 시 발급받은 리프레시 토큰 (Google 로그인 회원 탈퇴 시에만 필요)", + example = "1//0g_xxxxxxxxxxxxxxxxxxxxxx", + required = false + ) + val googleRefreshToken: String? ) { companion object { fun from(response: WithdrawTargetUserResponse): WithdrawStrategyRequest { @@ -43,7 +49,8 @@ data class WithdrawStrategyRequest private constructor( userId = response.id, providerType = response.providerType, providerId = response.providerId, - appleRefreshToken = response.appleRefreshToken + appleRefreshToken = response.appleRefreshToken, + googleRefreshToken = response.googleRefreshToken ) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt index 33a0c9dd..7295e909 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt @@ -24,6 +24,7 @@ enum class AuthErrorCode( PROVIDER_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_400_11", "요청된 공급자 타입이 실제 사용자의 공급자 타입과 일치하지 않습니다."), APPLE_REFRESH_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_12", "Apple 사용자 탈퇴 시 리프레시 토큰이 누락되었습니다."), KAKAO_UNLINK_FAILED(HttpStatus.BAD_REQUEST, "AUTH_400_15", "카카오 회원탈퇴 처리에 실패했습니다."), + GOOGLE_REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_16", "Google 사용자 탈퇴 시 리프레시 토큰이 누락되었습니다."), /* 401 UNAUTHORIZED */ INVALID_OAUTH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "잘못된 소셜 OAuth 토큰입니다."), @@ -52,7 +53,8 @@ enum class AuthErrorCode( "AUTH_500_06", "Apple에서 초기 로그인 시 리프레시 토큰을 제공하지 않았습니다." ), - KAKAO_UNLINK_RESPONSE_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_08", "카카오 회원탈퇴 응답이 요청과 일치하지 않습니다."); + KAKAO_UNLINK_RESPONSE_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_08", "카카오 회원탈퇴 응답이 요청과 일치하지 않습니다."), + FAILED_TO_REVOKE_TOKEN(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_09", "토큰 철회에 실패했습니다."); override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt b/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt index f6904908..8613ac22 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt @@ -7,7 +7,7 @@ import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.config.GoogleOauthProperties import org.yapp.infra.external.oauth.google.GoogleApi -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo // Changed to GoogleUserInfo @Component class GoogleApiManager( @@ -16,7 +16,7 @@ class GoogleApiManager( ) { private val log = KotlinLogging.logger {} - fun getUserInfo(accessToken: String): GoogleUserInfo { + fun getUserInfo(accessToken: String): GoogleUserInfo { // Changed to GoogleUserInfo return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo) .onSuccess { userInfo -> log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" } @@ -37,4 +37,9 @@ class GoogleApiManager( } } } -} + + fun revokeToken(token: String) { + googleApi.revokeGoogleToken(token) + .getOrThrow() + } +} \ No newline at end of file diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt new file mode 100644 index 00000000..dedd9e28 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt @@ -0,0 +1,32 @@ +package org.yapp.apis.auth.strategy.withdraw + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.yapp.apis.auth.dto.request.WithdrawStrategyRequest +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.manager.GoogleApiManager +import org.yapp.domain.user.ProviderType + +@Component +class GoogleWithdrawStrategy( + private val googleApiManager: GoogleApiManager +) : WithdrawStrategy { + + private val log = KotlinLogging.logger {} + + override fun getProviderType(): ProviderType = ProviderType.GOOGLE + + override fun withdraw(request: WithdrawStrategyRequest) { + val googleRefreshToken = request.googleRefreshToken + ?: throw AuthException(AuthErrorCode.GOOGLE_REFRESH_TOKEN_NOT_FOUND, "Google Refresh Token이 존재하지 않습니다.") + + try { + googleApiManager.revokeToken(googleRefreshToken as String) + log.info { "Google refresh token revoked successfully for user ${request.userId}" } + } catch (e: Exception) { + log.error("Failed to revoke Google token for user ${request.userId}", e) + throw AuthException(AuthErrorCode.FAILED_TO_REVOKE_TOKEN, e.message) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt index d8a6c05d..fde705c3 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -5,9 +5,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "oauth.google") data class GoogleOauthProperties( val url: Url, - val clientId: String + val clientId: String, + val clientSecret: String? = null, + val grantType: String? = null, + val redirectUri: String? = null ) data class Url( - val userInfo: String -) + val userInfo: String, + val tokenUri: String? = null +) \ No newline at end of file diff --git a/apis/src/main/kotlin/org/yapp/apis/user/dto/response/WithdrawTargetUserResponse.kt b/apis/src/main/kotlin/org/yapp/apis/user/dto/response/WithdrawTargetUserResponse.kt index f1fd3929..9b086b4e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/user/dto/response/WithdrawTargetUserResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/user/dto/response/WithdrawTargetUserResponse.kt @@ -21,7 +21,9 @@ data class WithdrawTargetUserResponse private constructor( val providerId: String, @field:Schema(description = "Apple Refresh Token (애플 회원 탈퇴 시 필요, 카카오는 null)") - val appleRefreshToken: String? = null + val appleRefreshToken: String? = null, + @field:Schema(description = "Google Refresh Token (구글 회원 탈퇴 시 필요, 카카오/애플은 null)") + val googleRefreshToken: String? = null ) { companion object { fun from(vo: WithdrawTargetUserVO): WithdrawTargetUserResponse { @@ -29,7 +31,8 @@ data class WithdrawTargetUserResponse private constructor( id = vo.id.value, providerType = vo.providerType, providerId = vo.providerId.value, - appleRefreshToken = vo.appleRefreshToken + appleRefreshToken = vo.appleRefreshToken, + googleRefreshToken = vo.googleRefreshToken ) } } diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index a41c1132..652bd0f9 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -64,7 +64,7 @@ swagger: oauth: google: url: - user-info: https://www.googleapis.com/oauth2/v2/userinfo + user-info: ${GOOGLE_USER_INFO_URI:https://www.googleapis.com/oauth2/v2/userinfo} client-id: ${GOOGLE_CLIENT_ID} --- @@ -95,5 +95,9 @@ springdoc: oauth: google: url: - user-info: https://www.googleapis.com/oauth2/v2/userinfo - client-id: test-client-id + user-info: ${GOOGLE_USER_INFO_URI:https://www.googleapis.com/oauth2/v2/userinfo} + token-uri: ${GOOGLE_TOKEN_URI:test-token-uri} + client-id: ${GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${GOOGLE_CLIENT_SECRET:test-client-secret} + grant-type: ${GOOGLE_GRANT_TYPE:test-grant-type} + redirect-uri: ${GOOGLE_REDIRECT_URI:test-redirect-uri} diff --git a/domain/src/main/kotlin/org/yapp/domain/user/User.kt b/domain/src/main/kotlin/org/yapp/domain/user/User.kt index d9b22605..30d41030 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/User.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/User.kt @@ -16,6 +16,7 @@ data class User private constructor( val role: Role, val termsAgreed: Boolean = false, val appleRefreshToken: String? = null, + val googleRefreshToken: String? = null, val notificationEnabled: Boolean = true, val lastActivity: LocalDateTime? = null, val createdAt: LocalDateTime? = null, @@ -42,6 +43,12 @@ data class User private constructor( ) } + fun updateGoogleRefreshToken(token: String): User { + return this.copy( + googleRefreshToken = token + ) + } + fun updateNotificationEnabled(enabled: Boolean): User { return this.copy( notificationEnabled = enabled @@ -80,6 +87,7 @@ data class User private constructor( role = Role.USER, termsAgreed = termsAgreed, appleRefreshToken = null, + googleRefreshToken = null, notificationEnabled = notificationEnabled, lastActivity = LocalDateTime.now() ) @@ -106,6 +114,7 @@ data class User private constructor( role = role, termsAgreed = termsAgreed, appleRefreshToken = null, + googleRefreshToken = null, notificationEnabled = notificationEnabled, lastActivity = LocalDateTime.now() ) @@ -121,6 +130,7 @@ data class User private constructor( role: Role, termsAgreed: Boolean = false, appleRefreshToken: String? = null, + googleRefreshToken: String? = null, notificationEnabled: Boolean = true, lastActivity: LocalDateTime? = null, createdAt: LocalDateTime? = null, @@ -137,6 +147,7 @@ data class User private constructor( role = role, termsAgreed = termsAgreed, appleRefreshToken = appleRefreshToken, + googleRefreshToken = googleRefreshToken, notificationEnabled = notificationEnabled, lastActivity = lastActivity, createdAt = createdAt, @@ -175,3 +186,4 @@ data class User private constructor( } } } + diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt index 6b0a42fe..8aebc871 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt @@ -99,6 +99,15 @@ class UserDomainService( return UserAuthVO.newInstance(updatedUser) } + fun updateGoogleRefreshToken(userId: UUID, refreshToken: String): UserAuthVO { + val user = userRepository.findById(userId) + ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND) + + val updatedUser = userRepository.save(user.updateGoogleRefreshToken(refreshToken)) + + return UserAuthVO.newInstance(updatedUser) + } + fun deleteUser(userId: UUID) { val user = userRepository.findById(userId) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND) diff --git a/domain/src/main/kotlin/org/yapp/domain/user/vo/WithdrawTargetUserVO.kt b/domain/src/main/kotlin/org/yapp/domain/user/vo/WithdrawTargetUserVO.kt index d14a6532..8a62bdbf 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/vo/WithdrawTargetUserVO.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/vo/WithdrawTargetUserVO.kt @@ -7,7 +7,8 @@ data class WithdrawTargetUserVO private constructor( val id: User.Id, val providerType: ProviderType, val providerId: User.ProviderId, - val appleRefreshToken: String? + val appleRefreshToken: String?, + val googleRefreshToken: String? ) { companion object { fun newInstance(user: User): WithdrawTargetUserVO { @@ -15,7 +16,8 @@ data class WithdrawTargetUserVO private constructor( id = user.id, providerType = user.providerType, providerId = user.providerId, - appleRefreshToken = user.appleRefreshToken + appleRefreshToken = user.appleRefreshToken, + googleRefreshToken = user.googleRefreshToken ) } } diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt index c1dc5726..b3c10ba7 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt @@ -1,7 +1,8 @@ package org.yapp.infra.external.oauth.google import org.springframework.stereotype.Component -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo +import org.springframework.util.LinkedMultiValueMap +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo // Changed to GoogleUserInfo @Component class GoogleApi( @@ -9,14 +10,27 @@ class GoogleApi( ) { companion object { private const val BEARER_PREFIX = "Bearer " + private const val TOKEN = "token" } fun fetchUserInfo( accessToken: String, userInfoUrl: String, - ): Result { + ): Result { // Changed to GoogleUserInfo return runCatching { googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl) } } + + fun revokeGoogleToken( + token: String + ): Result { + val requestBody = LinkedMultiValueMap().apply { + add(TOKEN, token) + } + + return runCatching { + googleRestClient.revoke(requestBody) + } + } } diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt index 773b8e23..42b7d085 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt @@ -2,6 +2,7 @@ package org.yapp.infra.external.oauth.google import org.springframework.stereotype.Component import org.springframework.web.client.RestClient +import org.springframework.util.LinkedMultiValueMap import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component @@ -21,4 +22,12 @@ class GoogleRestClient( .body(GoogleUserInfo::class.java) ?: throw IllegalStateException("Google API 응답이 null 입니다.") } + + fun revoke(requestBody: LinkedMultiValueMap) { + client.post() + .uri("https://oauth2.googleapis.com/revoke") + .body(requestBody) + .retrieve() + .body(Unit::class.java) // Google revoke API typically returns an empty body + } }