From 1fff6546df1c687eeed9a965cc18351d1eed71e1 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:53:46 +0900 Subject: [PATCH 1/7] [BOOK-469] feat: apis,infra google social login (#141) --- .../auth/dto/request/SocialLoginRequest.kt | 3 + .../apis/auth/manager/GoogleApiManager.kt | 20 +++ .../strategy/signin/GoogleAuthCredentials.kt | 11 ++ .../strategy/signin/GoogleSignInStrategy.kt | 53 ++++++++ apis/src/main/resources/application.yml | 5 + .../main/resources/static/kakao-login.html | 115 +++++++++++++----- .../org/yapp/domain/user/ProviderType.kt | 2 +- infra/build.gradle.kts | 3 + .../oauth/google/response/GoogleUserInfo.kt | 12 ++ 9 files changed, 193 insertions(+), 31 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt 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 83e2c3ff..9b17c05c 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 @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials +import org.yapp.apis.auth.strategy.signin.GoogleAuthCredentials import org.yapp.apis.auth.strategy.signin.KakaoAuthCredentials import org.yapp.apis.auth.strategy.signin.SignInCredentials import org.yapp.domain.user.ProviderType @@ -61,6 +62,8 @@ data class SocialLoginRequest private constructor( ) AppleAuthCredentials(request.validOauthToken(), authCode) } + + ProviderType.GOOGLE -> GoogleAuthCredentials(request.validOauthToken()) } } } 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 new file mode 100644 index 00000000..09f2ade1 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt @@ -0,0 +1,20 @@ +package org.yapp.apis.auth.manager + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleApiManager( + @Value("\${oauth.google.url.user-info}") private val userInfoUrl: String, + private val restClient: RestClient, +) { + fun getUserInfo(accessToken: String): GoogleUserInfo { + return restClient.get() + .uri(userInfoUrl) + .headers { it.setBearerAuth(accessToken) } + .retrieve() + .body(GoogleUserInfo::class.java)!! + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt new file mode 100644 index 00000000..075a50e0 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt @@ -0,0 +1,11 @@ +package org.yapp.apis.auth.strategy.signin + +import org.yapp.domain.user.ProviderType + +data class GoogleAuthCredentials( + val accessToken: String, +) : SignInCredentials() { + override fun getProviderType(): ProviderType { + return ProviderType.GOOGLE + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt new file mode 100644 index 00000000..f91f956b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt @@ -0,0 +1,53 @@ +package org.yapp.apis.auth.strategy.signin + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.yapp.apis.auth.dto.response.UserCreateInfoResponse +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.manager.GoogleApiManager +import org.yapp.apis.auth.util.NicknameGenerator +import org.yapp.domain.user.ProviderType +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleSignInStrategy( + private val googleApiManager: GoogleApiManager +) : SignInStrategy { + + private val log = KotlinLogging.logger {} + + override fun getProviderType(): ProviderType = ProviderType.GOOGLE + + override fun authenticate(credentials: SignInCredentials): UserCreateInfoResponse { + return try { + val googleCredentials = validateCredentials(credentials) + val googleUser = googleApiManager.getUserInfo(googleCredentials.accessToken) + createUserInfo(googleUser) + } catch (exception: Exception) { + log.error("Google authentication failed", exception) + when (exception) { + is AuthException -> throw exception + else -> throw AuthException(AuthErrorCode.FAILED_TO_GET_USER_INFO, exception.message) + } + } + } + + private fun validateCredentials(credentials: SignInCredentials): GoogleAuthCredentials { + return credentials as? GoogleAuthCredentials + ?: throw AuthException( + AuthErrorCode.INVALID_CREDENTIALS, + "Credentials must be GoogleAuthCredentials" + ) + } + + private fun createUserInfo(googleUser: GoogleUserInfo): UserCreateInfoResponse { + return UserCreateInfoResponse.of( + email = googleUser.email ?: ("google_${googleUser.id}@google.com"), + nickname = NicknameGenerator.generate(), + profileImageUrl = googleUser.picture, + providerType = ProviderType.GOOGLE, + providerId = googleUser.id + ) + } +} diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index dcd73de6..cf00b4db 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -61,6 +61,11 @@ swagger: description: YAPP API Documentation for Development version: v1.0.0-dev +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo + --- spring: config: diff --git a/apis/src/main/resources/static/kakao-login.html b/apis/src/main/resources/static/kakao-login.html index 1c63c4d9..c5994710 100644 --- a/apis/src/main/resources/static/kakao-login.html +++ b/apis/src/main/resources/static/kakao-login.html @@ -53,38 +53,50 @@ color: #000; } - .apple-btn { - background-color: #000; - color: #fff; - } - - - - -

소셜 로그인 테스트

- -
-
카카오 로그인
-
애플 로그인
-
- -
-

카카오 계정으로 로그인하려면 아래 버튼을 클릭하세요.

-
- -
-
- -
-

애플 계정으로 로그인하려면 아래 버튼을 클릭하세요.

-
- -
-
- + .apple-btn { + background-color: #000; + color: #fff; + } + .google-btn { + background-color: #4285F4; + color: #fff; + } + + + + +

소셜 로그인 테스트

+ +
+
카카오 로그인
+
애플 로그인
+
구글 로그인
+
+ +
+

카카오 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+
+ +
+

애플 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+
+ +
+

구글 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+

 
+
 
 
 
@@ -93,6 +105,26 @@ 

소셜 로그인 테스트

src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"> + // Google Sign-In Initialization + var GoogleAuth; // GoogleAuth object + + function initGoogleAuth() { + gapi.client.init({ + clientId: document.querySelector('meta[name="google-signin-client_id"]').content, + scope: 'profile email' + }).then(function () { + GoogleAuth = gapi.auth2.getAuthInstance(); + // Attach the click listener to the Google login button + document.getElementById('google-login-btn').addEventListener('click', () => { + GoogleAuth.signIn().then(onSignIn, (error) => { + console.error('Google Sign-In failed:', error); + document.getElementById('result').textContent = 'Google 로그인 실패: ' + JSON.stringify(error); + }); + }); + }); + } + + function handleGoogleClientLoad() { + gapi.load('client:auth2', initGoogleAuth); + } + + diff --git a/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt index 4a8aad81..ba085c91 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt @@ -4,5 +4,5 @@ package org.yapp.domain.user * Enum representing different authentication providers. */ enum class ProviderType { - KAKAO, APPLE + KAKAO, APPLE, GOOGLE } diff --git a/infra/build.gradle.kts b/infra/build.gradle.kts index 68634557..bdc24c85 100644 --- a/infra/build.gradle.kts +++ b/infra/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { implementation(Dependencies.Spring.BOOT_STARTER_DATA_REDIS) implementation(Dependencies.Spring.KOTLIN_REFLECT) + + implementation(Dependencies.Spring.BOOT_STARTER_OAUTH2_CLIENT) + implementation(Dependencies.RestClient.HTTP_CLIENT5) implementation(Dependencies.RestClient.HTTP_CORE5) diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt new file mode 100644 index 00000000..7f938491 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.external.oauth.google.response + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GoogleUserInfo( + @JsonProperty("id") + val id: String, + @JsonProperty("email") + val email: String?, + @JsonProperty("picture") + val picture: String?, +) From ae6b5cae5d273a3b7c61c8f15d2975c5b0c36e81 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:21:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[BOOK-469]=20fix(ci):=20ci=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 프로필에 oauth 설정 추가 - 로컬에서 sonar task 스킵되도록 수정 --- apis/src/main/resources/application.yml | 5 +++++ build.gradle.kts | 1 + 2 files changed, 6 insertions(+) diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index cf00b4db..aaa824b0 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -90,3 +90,8 @@ springdoc: enabled: false api-docs: enabled: false + +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo diff --git a/build.gradle.kts b/build.gradle.kts index d6f4ce65..6ca01291 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -192,6 +192,7 @@ sonar { // SonarQube 태스크가 통합 JaCoCo 리포트에 의존하도록 설정 tasks.named("sonar") { dependsOn("jacocoRootReport") + onlyIf { System.getenv("SONAR_TOKEN") != null } } /** From bed59baef9fbc49a41a121836ca9452c5e203486 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:50:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[BOOK-469]=20fix:=20apis,infra=20-=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80=20=EC=86=8C=EC=85=9C=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apis/auth/manager/GoogleApiManager.kt | 38 ++++++++++++++----- .../strategy/signin/GoogleAuthCredentials.kt | 11 ------ .../auth/strategy/signin/SignInCredentials.kt | 13 ++++++- .../yapp/apis/config/GoogleOauthProperties.kt | 12 ++++++ .../org/yapp/apis/config/PropertiesConfig.kt | 8 ++++ .../infra/external/oauth/google/GoogleApi.kt | 22 +++++++++++ .../external/oauth/google/GoogleRestClient.kt | 24 ++++++++++++ 7 files changed, 106 insertions(+), 22 deletions(-) delete mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt 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 09f2ade1..f6904908 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 @@ -1,20 +1,40 @@ package org.yapp.apis.auth.manager -import org.springframework.beans.factory.annotation.Value +import mu.KotlinLogging import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient +import org.springframework.web.client.HttpClientErrorException +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 @Component class GoogleApiManager( - @Value("\${oauth.google.url.user-info}") private val userInfoUrl: String, - private val restClient: RestClient, + private val googleApi: GoogleApi, + private val googleOauthProperties: GoogleOauthProperties, ) { + private val log = KotlinLogging.logger {} + fun getUserInfo(accessToken: String): GoogleUserInfo { - return restClient.get() - .uri(userInfoUrl) - .headers { it.setBearerAuth(accessToken) } - .retrieve() - .body(GoogleUserInfo::class.java)!! + return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo) + .onSuccess { userInfo -> + log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" } + } + .getOrElse { exception -> + log.error(exception) { "Failed to fetch Google user info" } + + when (exception) { + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Google Access Token.", + ) + + else -> throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to communicate with Google server.", + ) + } + } } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt deleted file mode 100644 index 075a50e0..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleAuthCredentials.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.yapp.apis.auth.strategy.signin - -import org.yapp.domain.user.ProviderType - -data class GoogleAuthCredentials( - val accessToken: String, -) : SignInCredentials() { - override fun getProviderType(): ProviderType { - return ProviderType.GOOGLE - } -} 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 a274481c..ba921e8f 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 @@ -7,14 +7,23 @@ sealed class SignInCredentials { } data class KakaoAuthCredentials( - val accessToken: String + val accessToken: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType = ProviderType.KAKAO } data class AppleAuthCredentials( val idToken: String, - val authorizationCode: String + val authorizationCode: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType = ProviderType.APPLE } + +data class GoogleAuthCredentials( + val accessToken: String, +) : SignInCredentials() { + override fun getProviderType(): ProviderType { + return ProviderType.GOOGLE + } +} + diff --git a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt new file mode 100644 index 00000000..4e59c83b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -0,0 +1,12 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.google") +data class GoogleOauthProperties( + val url: Url +) + +data class Url( + val userInfo: String +) diff --git a/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt new file mode 100644 index 00000000..13bbe364 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt @@ -0,0 +1,8 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(GoogleOauthProperties::class) +class PropertiesConfig 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 new file mode 100644 index 00000000..c1dc5726 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt @@ -0,0 +1,22 @@ +package org.yapp.infra.external.oauth.google + +import org.springframework.stereotype.Component +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleApi( + private val googleRestClient: GoogleRestClient +) { + companion object { + private const val BEARER_PREFIX = "Bearer " + } + + fun fetchUserInfo( + accessToken: String, + userInfoUrl: String, + ): Result { + return runCatching { + googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl) + } + } +} 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 new file mode 100644 index 00000000..773b8e23 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt @@ -0,0 +1,24 @@ +package org.yapp.infra.external.oauth.google + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleRestClient( + builder: RestClient.Builder +) { + private val client = builder.build() + + fun getUserInfo( + bearerToken: String, + url: String, + ): GoogleUserInfo { + return client.get() + .uri(url) + .header("Authorization", bearerToken) + .retrieve() + .body(GoogleUserInfo::class.java) + ?: throw IllegalStateException("Google API 응답이 null 입니다.") + } +} From 7aa722bc3eff2b2a7ef11a1e02e4e6684b91e563 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:41:45 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[BOOK-469]=20feat:=20apis,infra=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A0=95(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apis/build.gradle.kts | 4 ++ .../yapp/apis/auth/exception/AuthErrorCode.kt | 1 + .../helper/google/GoogleIdTokenProcessor.kt | 34 ++++++++++++++++ .../apis/auth/manager/GoogleApiManager.kt | 40 ------------------- .../strategy/signin/GoogleSignInStrategy.kt | 18 ++++----- .../auth/strategy/signin/SignInCredentials.kt | 2 +- .../yapp/apis/config/GoogleOauthProperties.kt | 3 +- apis/src/main/resources/application.yml | 1 + buildSrc/src/main/kotlin/Dependencies.kt | 6 +++ .../infra/external/oauth/google/GoogleApi.kt | 22 ---------- .../external/oauth/google/GoogleRestClient.kt | 24 ----------- .../oauth/google/response/GoogleUserInfo.kt | 12 ------ 12 files changed, 58 insertions(+), 109 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt delete mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt delete mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt delete mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt delete mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt diff --git a/apis/build.gradle.kts b/apis/build.gradle.kts index c6ab8ff1..12b005c7 100644 --- a/apis/build.gradle.kts +++ b/apis/build.gradle.kts @@ -22,6 +22,10 @@ dependencies { implementation(Dependencies.BouncyCastle.BC_PROV) implementation(Dependencies.BouncyCastle.BC_PKIX) + implementation(Dependencies.Google.API_CLIENT) + implementation(Dependencies.Google.HTTP_CLIENT_APACHE) + implementation(Dependencies.Google.HTTP_CLIENT_GSON) + kapt(Dependencies.Spring.CONFIGURATION_PROCESSOR) testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) 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 3072939c..33a0c9dd 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 @@ -20,6 +20,7 @@ enum class AuthErrorCode( USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_08", "사용자를 찾을 수 없습니다."), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_09", "이메일을 찾을 수 없습니다."), INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_10", "유효하지 않은 Apple ID 토큰입니다."), + INVALID_GOOGLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_13", "유효하지 않은 Google ID 토큰입니다."), 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", "카카오 회원탈퇴 처리에 실패했습니다."), diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt new file mode 100644 index 00000000..7531bea6 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt @@ -0,0 +1,34 @@ +package org.yapp.apis.auth.helper.google + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.apache.v2.ApacheHttpTransport +import com.google.api.client.json.gson.GsonFactory +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.config.GoogleOauthProperties +import org.yapp.globalutils.annotation.Helper +import java.util.Collections + +@Helper +class GoogleIdTokenProcessor( + private val googleOauthProperties: GoogleOauthProperties, +) { + private val verifier: GoogleIdTokenVerifier = GoogleIdTokenVerifier.Builder( + ApacheHttpTransport(), + GsonFactory.getDefaultInstance() + ) + .setAudience(Collections.singletonList(googleOauthProperties.clientId)) + .build() + + fun parseAndValidate(idToken: String): GoogleIdToken.Payload { + try { + val googleIdToken = verifier.verify(idToken) + ?: throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, "Invalid ID token") + + return googleIdToken.payload + } catch (e: Exception) { + throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, e.message) + } + } +} 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 deleted file mode 100644 index f6904908..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.yapp.apis.auth.manager - -import mu.KotlinLogging -import org.springframework.stereotype.Component -import org.springframework.web.client.HttpClientErrorException -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 - -@Component -class GoogleApiManager( - private val googleApi: GoogleApi, - private val googleOauthProperties: GoogleOauthProperties, -) { - private val log = KotlinLogging.logger {} - - 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}" } - } - .getOrElse { exception -> - log.error(exception) { "Failed to fetch Google user info" } - - when (exception) { - is HttpClientErrorException -> throw AuthException( - AuthErrorCode.INVALID_OAUTH_TOKEN, - "Invalid Google Access Token.", - ) - - else -> throw AuthException( - AuthErrorCode.OAUTH_SERVER_ERROR, - "Failed to communicate with Google server.", - ) - } - } - } -} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt index f91f956b..7d673b8a 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt @@ -1,18 +1,18 @@ package org.yapp.apis.auth.strategy.signin +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken import mu.KotlinLogging import org.springframework.stereotype.Component import org.yapp.apis.auth.dto.response.UserCreateInfoResponse import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.apis.auth.manager.GoogleApiManager +import org.yapp.apis.auth.helper.google.GoogleIdTokenProcessor import org.yapp.apis.auth.util.NicknameGenerator import org.yapp.domain.user.ProviderType -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component class GoogleSignInStrategy( - private val googleApiManager: GoogleApiManager + private val googleIdTokenProcessor: GoogleIdTokenProcessor, ) : SignInStrategy { private val log = KotlinLogging.logger {} @@ -22,8 +22,8 @@ class GoogleSignInStrategy( override fun authenticate(credentials: SignInCredentials): UserCreateInfoResponse { return try { val googleCredentials = validateCredentials(credentials) - val googleUser = googleApiManager.getUserInfo(googleCredentials.accessToken) - createUserInfo(googleUser) + val googleUserPayload = googleIdTokenProcessor.parseAndValidate(googleCredentials.idToken) + createUserInfo(googleUserPayload) } catch (exception: Exception) { log.error("Google authentication failed", exception) when (exception) { @@ -41,13 +41,13 @@ class GoogleSignInStrategy( ) } - private fun createUserInfo(googleUser: GoogleUserInfo): UserCreateInfoResponse { + private fun createUserInfo(googleUser: GoogleIdToken.Payload): UserCreateInfoResponse { return UserCreateInfoResponse.of( - email = googleUser.email ?: ("google_${googleUser.id}@google.com"), + email = googleUser.email ?: ("google_${googleUser.subject}@google.com"), nickname = NicknameGenerator.generate(), - profileImageUrl = googleUser.picture, + profileImageUrl = googleUser["picture"] as? String, providerType = ProviderType.GOOGLE, - providerId = googleUser.id + providerId = googleUser.subject ) } } 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 ba921e8f..3374e0c3 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 @@ -20,7 +20,7 @@ data class AppleAuthCredentials( } data class GoogleAuthCredentials( - val accessToken: String, + val idToken: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType { return ProviderType.GOOGLE 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 4e59c83b..d8a6c05d 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -4,7 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "oauth.google") data class GoogleOauthProperties( - val url: Url + val url: Url, + val clientId: String ) data class Url( diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index aaa824b0..d9293ac7 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -65,6 +65,7 @@ oauth: google: url: user-info: https://www.googleapis.com/oauth2/v2/userinfo + client-id: ${GOOGLE_CLIENT_ID} --- spring: diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index e214e3bc..34827e6e 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -80,4 +80,10 @@ object Dependencies { object Firebase { const val FIREBASE_ADMIN = "com.google.firebase:firebase-admin:9.2.0" } + + object Google { + const val API_CLIENT = "com.google.api-client:google-api-client:2.2.0" + const val HTTP_CLIENT_APACHE = "com.google.http-client:google-http-client-apache-v2:1.43.3" + const val HTTP_CLIENT_GSON = "com.google.http-client:google-http-client-gson:1.43.3" + } } 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 deleted file mode 100644 index c1dc5726..00000000 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.yapp.infra.external.oauth.google - -import org.springframework.stereotype.Component -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo - -@Component -class GoogleApi( - private val googleRestClient: GoogleRestClient -) { - companion object { - private const val BEARER_PREFIX = "Bearer " - } - - fun fetchUserInfo( - accessToken: String, - userInfoUrl: String, - ): Result { - return runCatching { - googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl) - } - } -} 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 deleted file mode 100644 index 773b8e23..00000000 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.yapp.infra.external.oauth.google - -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo - -@Component -class GoogleRestClient( - builder: RestClient.Builder -) { - private val client = builder.build() - - fun getUserInfo( - bearerToken: String, - url: String, - ): GoogleUserInfo { - return client.get() - .uri(url) - .header("Authorization", bearerToken) - .retrieve() - .body(GoogleUserInfo::class.java) - ?: throw IllegalStateException("Google API 응답이 null 입니다.") - } -} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt deleted file mode 100644 index 7f938491..00000000 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.yapp.infra.external.oauth.google.response - -import com.fasterxml.jackson.annotation.JsonProperty - -data class GoogleUserInfo( - @JsonProperty("id") - val id: String, - @JsonProperty("email") - val email: String?, - @JsonProperty("picture") - val picture: String?, -) From 71400d5904753f3572ac3374d6dfdb4a9e6e46ce Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:03:23 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[BOOK-469]=20feat:=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4=20=EA=B5=AC=ED=98=84(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/WithdrawStrategyRequest.kt | 11 +++++-- .../yapp/apis/auth/exception/AuthErrorCode.kt | 4 ++- .../apis/auth/manager/GoogleApiManager.kt | 11 +++++-- .../withdraw/GoogleWithdrawStrategy.kt | 32 +++++++++++++++++++ .../yapp/apis/config/GoogleOauthProperties.kt | 10 ++++-- .../response/WithdrawTargetUserResponse.kt | 7 ++-- apis/src/main/resources/application.yml | 9 ++++-- .../main/kotlin/org/yapp/domain/user/User.kt | 12 +++++++ .../org/yapp/domain/user/UserDomainService.kt | 9 ++++++ .../domain/user/vo/WithdrawTargetUserVO.kt | 6 ++-- .../infra/external/oauth/google/GoogleApi.kt | 18 +++++++++-- .../external/oauth/google/GoogleRestClient.kt | 9 ++++++ 12 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt 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 d9293ac7..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,4 +95,9 @@ springdoc: 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} + 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 + } } From ec049dc922ec16693573b737a55c66cd837a2fd9 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Sun, 8 Feb 2026 15:22:06 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[BOOK-469]=20feat:=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apis/auth/manager/GoogleApiManager.kt | 40 ++++++++++++++-- .../infra/external/oauth/google/GoogleApi.kt | 28 ++++++++++- .../external/oauth/google/GoogleRestClient.kt | 48 +++++++++++++++++++ .../google/response/GoogleTokenResponse.kt | 23 +++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleTokenResponse.kt 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..ee34dc20 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,40 @@ class GoogleApiManager( ) { private val log = KotlinLogging.logger {} - fun getUserInfo(accessToken: String): GoogleUserInfo { // Changed to GoogleUserInfo + fun exchangeToken(idToken: String): GoogleTokenResponse { + val tokenUri = googleOauthProperties.url.tokenUri + ?: throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Google token URI is not configured." + ) + + return googleApi.exchangeIdToken( + idToken = idToken, + clientId = googleOauthProperties.clientId, + clientSecret = googleOauthProperties.clientSecret ?: "", + tokenExchangeUrl = tokenUri + ) + .onSuccess { tokenResponse -> + log.info { "Successfully exchanged Google ID token for access token" } + } + .getOrElse { exception -> + log.error(exception) { "Failed to exchange Google ID token" } + + when (exception) { + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Google ID Token.", + ) + + 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 +76,4 @@ class GoogleApiManager( googleApi.revokeGoogleToken(token) .getOrThrow() } -} \ No newline at end of file +} 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..7ddfe8f9 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,35 @@ 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) + } + } + + fun exchangeIdToken( + idToken: String, + clientId: String, + clientSecret: String, + tokenExchangeUrl: String, + ): Result { + return runCatching { + googleRestClient.exchangeIdToken(idToken, clientId, clientSecret, tokenExchangeUrl) + } + } + 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..f6c04220 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,52 @@ 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 입니다.") + } + + fun exchangeIdToken( + idToken: String, + clientId: String, + clientSecret: String, + url: String, + ): GoogleTokenResponse { + val params = LinkedMultiValueMap().apply { + add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + add("assertion", idToken) + add("client_id", clientId) + add("client_secret", clientSecret) + } + + return client.post() + .uri(url) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(GoogleTokenResponse::class.java) + ?: throw IllegalStateException("Google Token API 응답이 null 입니다.") + } + 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? +) From fe703f4b92483b09c526eb190dace196fba3b11c Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:37:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[BOOK-469]=20feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=A1=9C=EC=A7=81=EC=88=98=EC=A0=95(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/SocialLoginRequest.kt | 11 ++++- .../apis/auth/manager/GoogleApiManager.kt | 31 ++++++++++---- .../apis/auth/service/UserSignInService.kt | 32 ++++++++++---- .../auth/strategy/signin/SignInCredentials.kt | 2 +- .../org/yapp/apis/auth/usecase/AuthUseCase.kt | 18 +++++++- .../request/SaveGoogleRefreshTokenRequest.kt | 42 +++++++++++++++++++ .../apis/user/service/UserAccountService.kt | 10 +++++ .../infra/external/oauth/google/GoogleApi.kt | 12 +----- .../external/oauth/google/GoogleRestClient.kt | 23 +--------- 9 files changed, 130 insertions(+), 51 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt 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 ee34dc20..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 @@ -17,29 +17,46 @@ class GoogleApiManager( ) { private val log = KotlinLogging.logger {} - fun exchangeToken(idToken: String): GoogleTokenResponse { + // 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." ) - return googleApi.exchangeIdToken( - idToken = idToken, + 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 = googleOauthProperties.clientSecret ?: "", + clientSecret = clientSecret, + redirectUri = redirectUri, tokenExchangeUrl = tokenUri ) .onSuccess { tokenResponse -> - log.info { "Successfully exchanged Google ID token for access token" } + log.info { "Successfully exchanged Google authorization code for tokens" } } .getOrElse { exception -> - log.error(exception) { "Failed to exchange Google ID token" } + log.error(exception) { "Failed to exchange Google authorization code" } when (exception) { is HttpClientErrorException -> throw AuthException( AuthErrorCode.INVALID_OAUTH_TOKEN, - "Invalid Google ID Token.", + "Invalid Google Authorization Code.", ) else -> throw AuthException( 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 7ddfe8f9..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 @@ -35,16 +35,8 @@ class GoogleApi( } } - fun exchangeIdToken( - idToken: String, - clientId: String, - clientSecret: String, - tokenExchangeUrl: String, - ): Result { - return runCatching { - googleRestClient.exchangeIdToken(idToken, clientId, clientSecret, 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 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 f6c04220..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 @@ -49,27 +49,8 @@ class GoogleRestClient( ?: throw IllegalStateException("Google Token API 응답이 null 입니다.") } - fun exchangeIdToken( - idToken: String, - clientId: String, - clientSecret: String, - url: String, - ): GoogleTokenResponse { - val params = LinkedMultiValueMap().apply { - add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") - add("assertion", idToken) - add("client_id", clientId) - add("client_secret", clientSecret) - } - - 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()