From 3f3ffded61e4167c0d7bff1744bd4152714025a1 Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 26 Apr 2025 22:15:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?ASAP-425=20"=EA=B3=B5=ED=86=B5=20OAuth=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC(AbstractOAuthRetrieveHandler)=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Google,=20Kakao,=20Naver=20OAu?= =?UTF-8?q?th=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/AbstractOAuthRetrieveHandler.kt | 49 ++++++++ .../platform/GoogleOAuthRetrieveHandler.kt | 34 ++---- .../platform/KakaoOAuthRetrieveHandler.kt | 36 ++---- .../platform/NaverOAuthRetrieveHandler.kt | 46 +++++++ .../oauth/platform/naver/NaverApiResponse.kt | 20 ++++ .../platform/NaverOAuthRetrieveHandlerTest.kt | 113 ++++++++++++++++++ 6 files changed, 253 insertions(+), 45 deletions(-) create mode 100644 Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/AbstractOAuthRetrieveHandler.kt create mode 100644 Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandler.kt create mode 100644 Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt create mode 100644 Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandlerTest.kt diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/AbstractOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/AbstractOAuthRetrieveHandler.kt new file mode 100644 index 0000000..9091768 --- /dev/null +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/AbstractOAuthRetrieveHandler.kt @@ -0,0 +1,49 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import com.asap.client.oauth.exception.OAuthException +import org.springframework.web.reactive.function.client.WebClient + +abstract class AbstractOAuthRetrieveHandler( + private val webClient: WebClient, +) : OAuthRetrieveHandler { + + override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse { + val response = webClient + .get() + .uri(getApiEndpoint()) + .header("Authorization", "Bearer ${request.accessToken}") + .retrieve() + .onStatus({ it.isError }, { + throw OAuthException.OAuthRetrieveFailedException(getErrorMessage()) + }) + .bodyToMono(getResponseType()) + .block() + + if (response == null) { + throw OAuthException.OAuthRetrieveFailedException(getErrorMessage()) + } + + return mapToOAuthResponse(response) + } + + /** + * Returns the API endpoint URI for the specific OAuth provider + */ + protected abstract fun getApiEndpoint(): String + + /** + * Returns the error message for the specific OAuth provider + */ + protected abstract fun getErrorMessage(): String + + /** + * Returns the response type class for the specific OAuth provider + */ + protected abstract fun getResponseType(): Class + + /** + * Maps the provider-specific response to a common OAuthResponse + */ + protected abstract fun mapToOAuthResponse(response: T): OAuthRetrieveHandler.OAuthResponse +} \ No newline at end of file diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt index bea04ae..dd9c4b8 100644 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt @@ -1,37 +1,27 @@ package com.asap.client.oauth.platform import com.asap.client.oauth.OAuthRetrieveHandler -import com.asap.client.oauth.exception.OAuthException import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient @Component class GoogleOAuthRetrieveHandler( - @Qualifier("googleWebClient") private val googleWebClient: WebClient, -) : OAuthRetrieveHandler { - override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse { - val googleUserInfo = - googleWebClient - .get() - .uri("/userinfo/v2/me") - .header("Authorization", "Bearer ${request.accessToken}") - .retrieve() - .onStatus({ it.isError }, { - throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.") - }) - .bodyToMono(GoogleUserInfo::class.java) - .block() + @Qualifier("googleWebClient") googleWebClient: WebClient, +) : AbstractOAuthRetrieveHandler(googleWebClient) { - if (googleUserInfo == null) { - throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.") - } + override fun getApiEndpoint(): String = "/userinfo/v2/me" + override fun getErrorMessage(): String = "Google 사용자 정보를 가져오는데 실패했습니다." + + override fun getResponseType(): Class = GoogleUserInfo::class.java + + override fun mapToOAuthResponse(response: GoogleUserInfo): OAuthRetrieveHandler.OAuthResponse { return OAuthRetrieveHandler.OAuthResponse( - username = googleUserInfo.name, - socialId = googleUserInfo.id, - email = googleUserInfo.email, - profileImage = googleUserInfo.picture, + username = response.name, + socialId = response.id, + email = response.email, + profileImage = response.picture, ) } diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt index cc16d22..a34e686 100644 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt @@ -1,7 +1,6 @@ package com.asap.client.oauth.platform import com.asap.client.oauth.OAuthRetrieveHandler -import com.asap.client.oauth.exception.OAuthException import com.fasterxml.jackson.annotation.JsonProperty import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component @@ -9,30 +8,21 @@ import org.springframework.web.reactive.function.client.WebClient @Component class KakaoOAuthRetrieveHandler( - @Qualifier("kakaoWebClient") private val kakaoWebClient: WebClient, -) : OAuthRetrieveHandler { - override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse { - val kakaoUserInfo = - kakaoWebClient - .get() - .uri("/v2/user/me") - .header("Authorization", "Bearer ${request.accessToken}") - .retrieve() - .onStatus({ it.isError }, { - throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.") - }) - .bodyToMono(KakaoUserInfo::class.java) - .block() - - if (kakaoUserInfo == null) { - throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.") - } + @Qualifier("kakaoWebClient") kakaoWebClient: WebClient, +) : AbstractOAuthRetrieveHandler(kakaoWebClient) { + override fun getApiEndpoint(): String = "/v2/user/me" + + override fun getErrorMessage(): String = "Kakao 사용자 정보를 가져오는데 실패했습니다." + + override fun getResponseType(): Class = KakaoUserInfo::class.java + + override fun mapToOAuthResponse(response: KakaoUserInfo): OAuthRetrieveHandler.OAuthResponse { return OAuthRetrieveHandler.OAuthResponse( - username = kakaoUserInfo.properties.nickname, - socialId = kakaoUserInfo.id, - profileImage = kakaoUserInfo.properties.profileImage, - email = kakaoUserInfo.kakaoAccount.email, + username = response.properties.nickname, + socialId = response.id, + profileImage = response.properties.profileImage, + email = response.kakaoAccount.email, ) } diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandler.kt new file mode 100644 index 0000000..1724e78 --- /dev/null +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandler.kt @@ -0,0 +1,46 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +@Component +class NaverOAuthRetrieveHandler( + @Qualifier("naverWebClient") naverWebClient: WebClient, +) : AbstractOAuthRetrieveHandler(naverWebClient) { + + override fun getApiEndpoint(): String = "/v1/nid/me" + + override fun getErrorMessage(): String = "네이버 사용자 정보를 가져오는데 실패했습니다." + + override fun getResponseType(): Class = NaverApiResponse::class.java + + override fun mapToOAuthResponse(response: NaverApiResponse): OAuthRetrieveHandler.OAuthResponse { + return OAuthRetrieveHandler.OAuthResponse( + username = response.response.nickname, + socialId = response.response.id, + email = response.response.email, + profileImage = response.response.profile_image, + ) + } + + data class NaverApiResponse( + val resultcode: String, + val message: String, + val response: NaverUserResponse, + ) + + data class NaverUserResponse( + val id: String, + val nickname: String, + val name: String, + val email: String, + val gender: String, + val age: String, + val birthday: String, + val profile_image: String, + val birthyear: String, + val mobile: String, + ) +} diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt new file mode 100644 index 0000000..6e594f3 --- /dev/null +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt @@ -0,0 +1,20 @@ +package com.asap.client.oauth.platform.naver + +data class NaverApiResponse( + val resultcode: String, + val message: String, + val response: NaverUserResponse +) + +data class NaverUserResponse( + val id: String, + val nickname: String, + val name: String, + val email: String, + val gender: String, + val age: String, + val birthday: String, + val profile_image: String, + val birthyear: String, + val mobile: String +) \ No newline at end of file diff --git a/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandlerTest.kt b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandlerTest.kt new file mode 100644 index 0000000..2995104 --- /dev/null +++ b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/NaverOAuthRetrieveHandlerTest.kt @@ -0,0 +1,113 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import com.asap.client.oauth.exception.OAuthException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient + +class NaverOAuthRetrieveHandlerTest : + BehaviorSpec({ + var mockWebServer = MockWebServer().also { + it.start() + } + var naverWebClient: WebClient = + WebClient + .builder() + .baseUrl(mockWebServer.url("/").toString()) + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .build() + var naverOAuthRetrieveHandler = NaverOAuthRetrieveHandler(naverWebClient) + + given("OAuth 요청이 성공적으로 처리되었을 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + val responseBody = + """ + { + "resultcode": "00", + "message": "success", + "response": { + "id": "12345", + "nickname": "Test User", + "name": "Test Name", + "email": "test@example.com", + "gender": "M", + "age": "20-29", + "birthday": "01-01", + "profile_image": "https://example.com/profile.jpg", + "birthyear": "1990", + "mobile": "010-1234-5678" + } + } + """.trimIndent() + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(responseBody), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + val response = naverOAuthRetrieveHandler.getOAuthInfo(request) + + then("올바른 OAuthResponse를 반환해야 한다") { + response.username shouldBe "Test User" + response.socialId shouldBe "12345" + response.email shouldBe "test@example.com" + response.profileImage shouldBe "https://example.com/profile.jpg" + + // 요청 검증 + val recordedRequest = mockWebServer.takeRequest() + recordedRequest.path shouldBe "/v1/nid/me" + recordedRequest.getHeader("Authorization") shouldBe "Bearer test-access-token" + } + } + } + + given("API가 오류를 반환할 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\": \"invalid_token\"}"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + shouldThrow { + naverOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + + given("응답이 null일 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("null"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + shouldThrow { + naverOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + }) \ No newline at end of file From 21cc02e612c65b3987a5dee118e8685951b3d46e Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 26 Apr 2025 22:15:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?ASAP-425=20=EB=84=A4=EC=9D=B4=EB=B2=84=20OA?= =?UTF-8?q?uth=20=EC=97=B0=EB=8F=99=EC=9D=84=20=EC=9C=84=ED=95=9C=20WebCli?= =?UTF-8?q?ent=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt index 9c65a73..89bddf2 100644 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt @@ -25,4 +25,13 @@ class OAuthWebClientConfig { .baseUrl("https://oauth2.googleapis.com") .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) .build() + + @Bean + @Qualifier("naverWebClient") + fun naverWebClient(): WebClient = + WebClient + .builder() + .baseUrl("https://openapi.naver.com") + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .build() } From 29fe2938bf6f859c0d5f853703b8e7f7b52f061a Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 26 Apr 2025 22:17:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?ASAP-425=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/platform/naver/NaverApiResponse.kt | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt deleted file mode 100644 index 6e594f3..0000000 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/naver/NaverApiResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.asap.client.oauth.platform.naver - -data class NaverApiResponse( - val resultcode: String, - val message: String, - val response: NaverUserResponse -) - -data class NaverUserResponse( - val id: String, - val nickname: String, - val name: String, - val email: String, - val gender: String, - val age: String, - val birthday: String, - val profile_image: String, - val birthyear: String, - val mobile: String -) \ No newline at end of file