diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt index 9b17c05c..e93a0031 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt @@ -32,7 +32,7 @@ data class SocialLoginRequest private constructor( val oauthToken: String? = null, @field:Schema( - description = "Authorization code used to issue Apple access/refresh tokens (required only for Apple login)", + description = "Authorization code used to issue access/refresh tokens (required for Apple and Google login)", example = "c322a426...", required = false ) @@ -63,7 +63,14 @@ data class SocialLoginRequest private constructor( AppleAuthCredentials(request.validOauthToken(), authCode) } - ProviderType.GOOGLE -> GoogleAuthCredentials(request.validOauthToken()) + ProviderType.GOOGLE -> { + val authCode = request.authorizationCode + ?: throw AuthException( + AuthErrorCode.INVALID_REQUEST, + "Google login requires an authorization code." + ) + GoogleAuthCredentials(request.validOauthToken(), authCode) + } } } } 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 8613ac22..dc71c208 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,8 @@ 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 // Changed to GoogleUserInfo +import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component class GoogleApiManager( @@ -16,7 +17,57 @@ class GoogleApiManager( ) { private val log = KotlinLogging.logger {} - fun getUserInfo(accessToken: String): GoogleUserInfo { // Changed to GoogleUserInfo + // Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint. + // The ID token should be validated directly using GoogleIdTokenProcessor. + // If an access token is needed, use the authorization code flow instead. + + fun exchangeAuthorizationCode(authorizationCode: String): GoogleTokenResponse { + val tokenUri = googleOauthProperties.url.tokenUri + ?: throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Google token URI is not configured." + ) + + val redirectUri = googleOauthProperties.redirectUri + ?: throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Google redirect URI is not configured." + ) + + val clientSecret = googleOauthProperties.clientSecret + ?: throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Google client secret is not configured." + ) + + return googleApi.exchangeAuthorizationCode( + code = authorizationCode, + clientId = googleOauthProperties.clientId, + clientSecret = clientSecret, + redirectUri = redirectUri, + tokenExchangeUrl = tokenUri + ) + .onSuccess { tokenResponse -> + log.info { "Successfully exchanged Google authorization code for tokens" } + } + .getOrElse { exception -> + log.error(exception) { "Failed to exchange Google authorization code" } + + when (exception) { + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Google Authorization Code.", + ) + + else -> throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to communicate with Google server.", + ) + } + } + } + + fun getUserInfo(accessToken: String): GoogleUserInfo { return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo) .onSuccess { userInfo -> log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" } @@ -42,4 +93,4 @@ class GoogleApiManager( googleApi.revokeGoogleToken(token) .getOrThrow() } -} \ No newline at end of file +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt index 8358bae8..89d210d0 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt @@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import org.yapp.apis.user.dto.request.FindOrCreateUserRequest import org.yapp.apis.user.dto.request.SaveAppleRefreshTokenRequest +import org.yapp.apis.user.dto.request.SaveGoogleRefreshTokenRequest import org.yapp.apis.user.dto.response.CreateUserResponse import org.yapp.apis.user.service.UserAccountService import org.yapp.globalutils.annotation.ApplicationService @@ -15,17 +16,32 @@ class UserSignInService( ) { @Transactional(propagation = Propagation.REQUIRES_NEW) fun processSignIn( - @Valid request: FindOrCreateUserRequest, appleRefreshToken: String? + @Valid request: FindOrCreateUserRequest, + appleRefreshToken: String?, + googleRefreshToken: String? ): CreateUserResponse { val initialUserResponse = userAccountService.findOrCreateUser(request) - return appleRefreshToken.takeIf { !it.isNullOrBlank() } - ?.let { token -> - userAccountService.updateAppleRefreshToken( - SaveAppleRefreshTokenRequest.of( - initialUserResponse, token - ) + var userResponse = initialUserResponse + + // Update Apple refresh token if provided + if (!appleRefreshToken.isNullOrBlank()) { + userResponse = userAccountService.updateAppleRefreshToken( + SaveAppleRefreshTokenRequest.of( + userResponse, appleRefreshToken + ) + ) + } + + // Update Google refresh token if provided + if (!googleRefreshToken.isNullOrBlank()) { + userResponse = userAccountService.updateGoogleRefreshToken( + SaveGoogleRefreshTokenRequest.of( + userResponse, googleRefreshToken ) - } ?: initialUserResponse + ) + } + + return userResponse } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt index 3374e0c3..c72c3b4c 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt @@ -21,9 +21,9 @@ data class AppleAuthCredentials( data class GoogleAuthCredentials( val idToken: String, + val authorizationCode: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType { return ProviderType.GOOGLE } } - diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt index a491558b..0c9ce6de 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt @@ -5,6 +5,7 @@ import org.yapp.apis.auth.dto.request.* import org.yapp.apis.auth.dto.response.TokenPairResponse import org.yapp.apis.auth.service.* import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials +import org.yapp.apis.auth.strategy.signin.GoogleAuthCredentials import org.yapp.apis.auth.strategy.signin.SignInCredentials import org.yapp.apis.auth.strategy.signin.SignInStrategyResolver import org.yapp.apis.auth.strategy.withdraw.WithdrawStrategyResolver @@ -12,6 +13,7 @@ import org.yapp.apis.user.dto.request.FindOrCreateUserRequest import org.yapp.apis.user.dto.request.FindUserIdentityRequest import org.yapp.apis.user.service.UserAccountService import org.yapp.apis.user.service.UserService +import org.yapp.apis.auth.manager.GoogleApiManager import org.yapp.globalutils.annotation.UseCase import java.util.* @@ -25,7 +27,8 @@ class AuthUseCase( private val userWithdrawalService: UserWithdrawalService, private val refreshTokenService: RefreshTokenService, private val authTokenService: AuthTokenService, - private val appleAuthService: AppleAuthService + private val appleAuthService: AppleAuthService, + private val googleApiManager: GoogleApiManager ) { // 추후 Redis 저장을 비동기로 처리하고 실패 시 재시도 로직 도입 fun signIn(socialLoginRequest: SocialLoginRequest): TokenPairResponse { @@ -34,10 +37,12 @@ class AuthUseCase( val userCreateInfoResponse = strategy.authenticate(credentials) val appleRefreshToken = fetchAppleRefreshTokenIfNeeded(credentials) + val googleRefreshToken = fetchGoogleRefreshTokenIfNeeded(credentials) val createUserResponse = userSignInService.processSignIn( FindOrCreateUserRequest.from(userCreateInfoResponse), - appleRefreshToken + appleRefreshToken, + googleRefreshToken ) return authTokenService.generateTokenPair(GenerateTokenPairRequest.from(createUserResponse)) @@ -77,4 +82,13 @@ class AuthUseCase( return null } + + private fun fetchGoogleRefreshTokenIfNeeded(credentials: SignInCredentials): String? { + if (credentials is GoogleAuthCredentials) { + val tokenResponse = googleApiManager.exchangeAuthorizationCode(credentials.authorizationCode) + return tokenResponse.refreshToken + } + + return null + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt new file mode 100644 index 00000000..eb874a92 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt @@ -0,0 +1,42 @@ +package org.yapp.apis.user.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.yapp.apis.user.dto.response.CreateUserResponse +import java.util.* + +@Schema( + name = "SaveGoogleRefreshTokenRequest", + description = "Request DTO for saving Google refresh token with user ID and authorization code" +) +data class SaveGoogleRefreshTokenRequest private constructor( + @field:Schema( + description = "Unique identifier of the user", + example = "a1b2c3d4-e5f6-7890-1234-56789abcdef0" + ) + @field:NotNull(message = "userId must not be null") + val userId: UUID? = null, + + @field:Schema( + description = "Google refresh token, nullable if not issued yet", + example = "1//0g_xxxxxxxxxxxxxxxxxxxxxx" + ) + @field:NotBlank(message = "googleRefreshToken must not be blank") + val googleRefreshToken: String? = null +) { + fun validUserId(): UUID = userId!! + fun validGoogleRefreshToken(): String = googleRefreshToken!! + + companion object { + fun of( + userResponse: CreateUserResponse, + googleRefreshToken: String + ): SaveGoogleRefreshTokenRequest { + return SaveGoogleRefreshTokenRequest( + userId = userResponse.id, + googleRefreshToken = googleRefreshToken + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/user/service/UserAccountService.kt b/apis/src/main/kotlin/org/yapp/apis/user/service/UserAccountService.kt index 198c10a2..bd1df17c 100644 --- a/apis/src/main/kotlin/org/yapp/apis/user/service/UserAccountService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/user/service/UserAccountService.kt @@ -2,6 +2,7 @@ package org.yapp.apis.user.service import jakarta.validation.Valid import org.yapp.apis.user.dto.request.SaveAppleRefreshTokenRequest +import org.yapp.apis.user.dto.request.SaveGoogleRefreshTokenRequest import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.user.dto.request.FindOrCreateUserRequest @@ -54,6 +55,15 @@ class UserAccountService( return CreateUserResponse.from(userAuthVO) } + fun updateGoogleRefreshToken(@Valid saveGoogleRefreshTokenRequest: SaveGoogleRefreshTokenRequest): CreateUserResponse { + val userAuthVO = userDomainService.updateGoogleRefreshToken( + saveGoogleRefreshTokenRequest.validUserId(), + saveGoogleRefreshTokenRequest.validGoogleRefreshToken() + ) + + return CreateUserResponse.from(userAuthVO) + } + private fun createNewUser(@Valid findOrCreateUserRequest: FindOrCreateUserRequest): UserAuthVO { val email = findOrCreateUserRequest.getOrDefaultEmail() val nickname = findOrCreateUserRequest.getOrDefaultNickname() 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 b3c10ba7..0c035a24 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 @@ -2,7 +2,8 @@ package org.yapp.infra.external.oauth.google import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo // Changed to GoogleUserInfo +import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component class GoogleApi( @@ -16,12 +17,27 @@ class GoogleApi( fun fetchUserInfo( accessToken: String, userInfoUrl: String, - ): Result { // Changed to GoogleUserInfo + ): Result { return runCatching { googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl) } } + fun exchangeAuthorizationCode( + code: String, + clientId: String, + clientSecret: String, + redirectUri: String, + tokenExchangeUrl: String, + ): Result { + return runCatching { + googleRestClient.exchangeAuthorizationCode(code, clientId, clientSecret, redirectUri, tokenExchangeUrl) + } + } + + // Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint. + // The ID token should be validated directly or use the authorization code flow instead. + fun revokeGoogleToken( token: String ): Result { 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 42b7d085..ad223a2c 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 @@ -1,8 +1,10 @@ package org.yapp.infra.external.oauth.google +import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.client.RestClient import org.springframework.util.LinkedMultiValueMap +import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component @@ -23,6 +25,33 @@ class GoogleRestClient( ?: throw IllegalStateException("Google API 응답이 null 입니다.") } + fun exchangeAuthorizationCode( + code: String, + clientId: String, + clientSecret: String, + redirectUri: String, + url: String, + ): GoogleTokenResponse { + val params = LinkedMultiValueMap().apply { + add("code", code) + add("client_id", clientId) + add("client_secret", clientSecret) + add("redirect_uri", redirectUri) + add("grant_type", "authorization_code") + } + + return client.post() + .uri(url) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(GoogleTokenResponse::class.java) + ?: throw IllegalStateException("Google Token API 응답이 null 입니다.") + } + + // Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint. + // The ID token should be validated directly or use the authorization code flow instead. + fun revoke(requestBody: LinkedMultiValueMap) { client.post() .uri("https://oauth2.googleapis.com/revoke") diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleTokenResponse.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleTokenResponse.kt new file mode 100644 index 00000000..a4181911 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleTokenResponse.kt @@ -0,0 +1,23 @@ +package org.yapp.infra.external.oauth.google.response + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GoogleTokenResponse( + @JsonProperty("access_token") + val accessToken: String, + + @JsonProperty("refresh_token") + val refreshToken: String?, + + @JsonProperty("expires_in") + val expiresIn: Int, + + @JsonProperty("token_type") + val tokenType: String, + + @JsonProperty("scope") + val scope: String?, + + @JsonProperty("id_token") + val idToken: String? +)