From 3aeac012aa9bc3124dc4b2e3c4d173609359938e Mon Sep 17 00:00:00 2001 From: DongGeon0908 Date: Mon, 29 Jul 2024 20:00:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EC=BD=94=EB=93=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gradle.yml | 3 ++ .../client/kakao/SuspendableClient.kt | 17 ++++++ .../client/kakao/client/KaKaoOAuthClient.kt | 5 ++ .../client/SuspendableKakaoOAuthClient.kt | 22 ++++++++ .../kakao/config/KakaoOAuthClientConfig.kt | 52 +++++++++++++++++++ .../domain/auth/application/OAuthFacade.kt | 22 ++++++++ .../domain/auth/model/OAuthProvider.kt | 6 +++ .../response/OAuthAuthorizeCodeResponse.kt | 7 +++ .../domain/auth/resource/OAuthResource.kt | 30 +++++++++++ .../domain/dev/application/DevOAuthService.kt | 24 +++++++++ .../dev/model/request/DevRedirectedRequest.kt | 12 +++++ .../model/response/DevOAuthCodeResponse.kt | 7 +++ .../model/response/DevRedirectedResponse.kt | 9 ++++ .../domain/dev/resource/DevOAuthResource.kt | 38 ++++++++++++++ .../alignlab/exception/AlignlabException.kt | 2 +- .../com/hero/alignlab/exception/ErrorCode.kt | 3 ++ src/main/resources/config/application.yml | 9 ++++ 17 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/hero/alignlab/client/kakao/SuspendableClient.kt create mode 100644 src/main/kotlin/com/hero/alignlab/client/kakao/client/KaKaoOAuthClient.kt create mode 100644 src/main/kotlin/com/hero/alignlab/client/kakao/client/SuspendableKakaoOAuthClient.kt create mode 100644 src/main/kotlin/com/hero/alignlab/client/kakao/config/KakaoOAuthClientConfig.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/auth/application/OAuthFacade.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/auth/model/OAuthProvider.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/auth/model/response/OAuthAuthorizeCodeResponse.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/auth/resource/OAuthResource.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/dev/application/DevOAuthService.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/dev/model/request/DevRedirectedRequest.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevOAuthCodeResponse.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevRedirectedResponse.kt create mode 100644 src/main/kotlin/com/hero/alignlab/domain/dev/resource/DevOAuthResource.kt diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 6e94ae8..70d0c33 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -58,6 +58,9 @@ jobs: encrypt.algorithm: ${{ secrets.ENCRYPT_ALGORITHM }} # dev resource 환경 변수 주입 hero-alignlab.dev.resource.key: ${{ secrets.HERO_ALIGNLAB_DEV_RESOURCE_KEY }} + # OAuth-Kakao 환경 변수 주입 + oauth.kakao.rest-api-key: ${{ secrets.OAUTH_KAKAO_REST_API_KEY }} + oauth.kakao.client-secret-code: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET_CODE }} # Secret Setup - application-prod.yml - name: Inject env-values to application-prod.yml diff --git a/src/main/kotlin/com/hero/alignlab/client/kakao/SuspendableClient.kt b/src/main/kotlin/com/hero/alignlab/client/kakao/SuspendableClient.kt new file mode 100644 index 0000000..e00eb8f --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/client/kakao/SuspendableClient.kt @@ -0,0 +1,17 @@ +package com.hero.alignlab.client.kakao + +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.awaitBodyOrNull + +abstract class SuspendableClient( + protected val client: WebClient, +) { + protected suspend inline fun WebClient.RequestHeadersSpec<*>.request(): T { + return this.retrieve().awaitBody() + } + + protected suspend inline fun WebClient.RequestHeadersSpec<*>.requestOrNull(): T? { + return this.retrieve().awaitBodyOrNull() + } +} diff --git a/src/main/kotlin/com/hero/alignlab/client/kakao/client/KaKaoOAuthClient.kt b/src/main/kotlin/com/hero/alignlab/client/kakao/client/KaKaoOAuthClient.kt new file mode 100644 index 0000000..24c4f6d --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/client/kakao/client/KaKaoOAuthClient.kt @@ -0,0 +1,5 @@ +package com.hero.alignlab.client.kakao.client + +interface KaKaoOAuthClient { + suspend fun getOAuthAuthorizeCode(redirectUrl: String? = null) +} diff --git a/src/main/kotlin/com/hero/alignlab/client/kakao/client/SuspendableKakaoOAuthClient.kt b/src/main/kotlin/com/hero/alignlab/client/kakao/client/SuspendableKakaoOAuthClient.kt new file mode 100644 index 0000000..b424b0c --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/client/kakao/client/SuspendableKakaoOAuthClient.kt @@ -0,0 +1,22 @@ +package com.hero.alignlab.client.kakao.client + +import com.hero.alignlab.client.kakao.SuspendableClient +import com.hero.alignlab.client.kakao.config.KakaoOAuthClientConfig +import org.springframework.web.reactive.function.client.WebClient + +class SuspendableKakaoOAuthClient( + client: WebClient, + private val config: KakaoOAuthClientConfig.Config, +) : KaKaoOAuthClient, SuspendableClient(client) { + override suspend fun getOAuthAuthorizeCode(redirectUrl: String?) { + client + .get() + .uri("/authorize") { builder -> + builder + .queryParam("response_type", "code") + .queryParam("client_id", config.restApiKey) + .queryParam("redirect_uri", redirectUrl ?: config.redirectUrl) + .build() + }.requestOrNull() + } +} diff --git a/src/main/kotlin/com/hero/alignlab/client/kakao/config/KakaoOAuthClientConfig.kt b/src/main/kotlin/com/hero/alignlab/client/kakao/config/KakaoOAuthClientConfig.kt new file mode 100644 index 0000000..796bd6a --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/client/kakao/config/KakaoOAuthClientConfig.kt @@ -0,0 +1,52 @@ +package com.hero.alignlab.client.kakao.config + +import com.hero.alignlab.client.WebClientFactory +import com.hero.alignlab.client.kakao.client.KaKaoOAuthClient +import com.hero.alignlab.client.kakao.client.SuspendableKakaoOAuthClient +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.validation.annotation.Validated + +@Validated +@Configuration +class KakaoOAuthClientConfig { + private val logger = KotlinLogging.logger { } + + @Bean + @ConditionalOnProperty(prefix = "oauth.kakao", name = ["url"]) + @ConfigurationProperties(prefix = "oauth.kakao") + fun kakaoOAuthConfig() = Config() + + @Bean + @ConditionalOnBean(name = ["kakaoOAuthConfig"]) + @ConditionalOnMissingBean(KaKaoOAuthClient::class) + fun kakaoOAuthClient( + @Valid kakaoOAuthConfig: Config + ): KaKaoOAuthClient { + logger.info { "initialized kakaoOAuthClient. $kakaoOAuthConfig" } + + val webclient = WebClientFactory.generate(kakaoOAuthConfig.url) + + return SuspendableKakaoOAuthClient(webclient, kakaoOAuthConfig) + } + + data class Config( + @field:NotBlank + var url: String = "", + @field:NotBlank + var restApiKey: String = "", + @field:NotBlank + var clientSecretCode: String = "", + @field:NotBlank + var redirectUrl: String = "", + @field:NotBlank + var devRedirectUrl: String = "", + ) +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/application/OAuthFacade.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/application/OAuthFacade.kt new file mode 100644 index 0000000..bcd2c21 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/application/OAuthFacade.kt @@ -0,0 +1,22 @@ +package com.hero.alignlab.domain.auth.application + +import com.hero.alignlab.client.kakao.client.KaKaoOAuthClient +import com.hero.alignlab.domain.auth.model.OAuthProvider +import com.hero.alignlab.domain.auth.model.response.OAuthAuthorizeCodeResponse +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +@Service +class OAuthFacade( + private val kaKaoOAuthClient: KaKaoOAuthClient, +) { + private val logger = KotlinLogging.logger { } + + suspend fun getOAuthAuthorizeCode(provider: OAuthProvider): OAuthAuthorizeCodeResponse { + when (provider) { + OAuthProvider.kakao -> kaKaoOAuthClient.getOAuthAuthorizeCode() + } + + return OAuthAuthorizeCodeResponse(provider) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/OAuthProvider.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/OAuthProvider.kt new file mode 100644 index 0000000..e503093 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/OAuthProvider.kt @@ -0,0 +1,6 @@ +package com.hero.alignlab.domain.auth.model + +enum class OAuthProvider { + kakao, + ; +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/OAuthAuthorizeCodeResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/OAuthAuthorizeCodeResponse.kt new file mode 100644 index 0000000..7187452 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/OAuthAuthorizeCodeResponse.kt @@ -0,0 +1,7 @@ +package com.hero.alignlab.domain.auth.model.response + +import com.hero.alignlab.domain.auth.model.OAuthProvider + +data class OAuthAuthorizeCodeResponse( + val provider: OAuthProvider, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/resource/OAuthResource.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/resource/OAuthResource.kt new file mode 100644 index 0000000..17703ca --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/resource/OAuthResource.kt @@ -0,0 +1,30 @@ +package com.hero.alignlab.domain.auth.resource + +import com.hero.alignlab.common.extension.wrapOk +import com.hero.alignlab.domain.auth.application.OAuthFacade +import com.hero.alignlab.domain.auth.model.OAuthProvider +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "OAuth 인증 및 인가 관리") +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class OAuthResource( + private val oAuthFacade: OAuthFacade, +) { + /** + * OAuth로 인가코드를 부여받고, 이를 Redirect Url로 반환. + * - redirectUrl은 Client의 주소값, 만약 변경시 yml 및 각 클라이언트 구조 변경 필요. + * - [Kakao Rest Auth](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api) + */ + @Operation(summary = "인가 코드 받기") + @GetMapping("/api/v1/oauth/{provider}/authorize") + suspend fun getOAuthAuthorizeCode( + @RequestParam provider: OAuthProvider, + ) = oAuthFacade.getOAuthAuthorizeCode(provider).wrapOk() +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/dev/application/DevOAuthService.kt b/src/main/kotlin/com/hero/alignlab/domain/dev/application/DevOAuthService.kt new file mode 100644 index 0000000..03f27bf --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/dev/application/DevOAuthService.kt @@ -0,0 +1,24 @@ +package com.hero.alignlab.domain.dev.application + +import com.hero.alignlab.client.kakao.client.KaKaoOAuthClient +import com.hero.alignlab.client.kakao.config.KakaoOAuthClientConfig +import com.hero.alignlab.domain.auth.model.OAuthProvider +import com.hero.alignlab.domain.dev.model.response.DevOAuthCodeResponse +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +@Service +class DevOAuthService( + private val kaKaoOAuthClient: KaKaoOAuthClient, + private val config: KakaoOAuthClientConfig.Config, +) { + private val logger = KotlinLogging.logger { } + + suspend fun getOAuthAuthorizeCode(provider: OAuthProvider): DevOAuthCodeResponse { + when (provider) { + OAuthProvider.kakao -> kaKaoOAuthClient.getOAuthAuthorizeCode(config.devRedirectUrl) + } + + return DevOAuthCodeResponse(provider) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/dev/model/request/DevRedirectedRequest.kt b/src/main/kotlin/com/hero/alignlab/domain/dev/model/request/DevRedirectedRequest.kt new file mode 100644 index 0000000..7d1cd8e --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/dev/model/request/DevRedirectedRequest.kt @@ -0,0 +1,12 @@ +package com.hero.alignlab.domain.dev.model.request + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class DevRedirectedRequest( + val code: String?, + val error: String?, + val errorDescription: String?, + val state: String?, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevOAuthCodeResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevOAuthCodeResponse.kt new file mode 100644 index 0000000..8de93ad --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevOAuthCodeResponse.kt @@ -0,0 +1,7 @@ +package com.hero.alignlab.domain.dev.model.response + +import com.hero.alignlab.domain.auth.model.OAuthProvider + +data class DevOAuthCodeResponse( + val provider: OAuthProvider, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevRedirectedResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevRedirectedResponse.kt new file mode 100644 index 0000000..cf337a4 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/dev/model/response/DevRedirectedResponse.kt @@ -0,0 +1,9 @@ +package com.hero.alignlab.domain.dev.model.response + +import com.hero.alignlab.domain.auth.model.OAuthProvider +import com.hero.alignlab.domain.dev.model.request.DevRedirectedRequest + +data class DevRedirectedResponse( + val provider: OAuthProvider, + val requestParams: DevRedirectedRequest, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/dev/resource/DevOAuthResource.kt b/src/main/kotlin/com/hero/alignlab/domain/dev/resource/DevOAuthResource.kt new file mode 100644 index 0000000..d109cdc --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/dev/resource/DevOAuthResource.kt @@ -0,0 +1,38 @@ +package com.hero.alignlab.domain.dev.resource + +import com.hero.alignlab.common.extension.wrapOk +import com.hero.alignlab.domain.auth.model.OAuthProvider +import com.hero.alignlab.domain.dev.application.DevOAuthService +import com.hero.alignlab.domain.dev.model.request.DevRedirectedRequest +import com.hero.alignlab.domain.dev.model.response.DevRedirectedResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springdoc.core.annotations.ParameterObject +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Dev OAuth 인증 및 인가 관리") +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class DevOAuthResource( + private val devOAuthService: DevOAuthService, +) { + @Operation(summary = "인가 코드 받기") + @GetMapping("/api/dev/v1/oauth/{provider}/authorize") + suspend fun getDevOAuthAuthorizeCode( + @PathVariable provider: OAuthProvider, + ) = devOAuthService.getOAuthAuthorizeCode(provider).wrapOk() + + @Operation(summary = "OAuth Redirect Test") + @GetMapping("/api/dev/v1/oauth/{provider}/authorize/redirected") + suspend fun redirectedDevOAuthAuthorizeCode( + @PathVariable provider: OAuthProvider, + @ParameterObject request: DevRedirectedRequest, + ) = DevRedirectedResponse( + provider = provider, + requestParams = request, + ).wrapOk() +} diff --git a/src/main/kotlin/com/hero/alignlab/exception/AlignlabException.kt b/src/main/kotlin/com/hero/alignlab/exception/AlignlabException.kt index 70400b1..a822237 100644 --- a/src/main/kotlin/com/hero/alignlab/exception/AlignlabException.kt +++ b/src/main/kotlin/com/hero/alignlab/exception/AlignlabException.kt @@ -10,7 +10,7 @@ class NotFoundException(errorCode: ErrorCode) : AlignlabException(errorCode) class InvalidTokenException(errorCode: ErrorCode) : AlignlabException(errorCode) -class InvalidRequestException(errorCode: ErrorCode) : AlignlabException(errorCode) +class InvalidRequestException(errorCode: ErrorCode, message: String? = null) : AlignlabException(errorCode, message) class FailToCreateException(errorCode: ErrorCode) : AlignlabException(errorCode) diff --git a/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt b/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt index 55c9cf9..4be6c9d 100644 --- a/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt +++ b/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt @@ -28,6 +28,9 @@ enum class ErrorCode(val status: HttpStatus, val description: String) { INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "잘못된 oauth 벤더입니다."), DUPLICATED_USERNAME_ERROR(HttpStatus.BAD_REQUEST, "중복된 아이디 입니다."), + /** OAuth Error Code */ + NOT_FOUND_OAUTH_PROVIDER_ERROR(HttpStatus.NOT_FOUND, "현재 미지원하는 제공자입니다."), + /** User Error Code */ NOT_FOUND_USER_ERROR(HttpStatus.NOT_FOUND, "유저 정보를 찾을 수 없습니다."), diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 807b12a..65b10ca 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -47,6 +47,15 @@ auth: jwt: secret: +oauth: + kakao: + url: https://kauth.kakao.com/oauth + rest-api-key: + client-secret-code: + # redirect url for get OAuth Authorize Code + redirect-url: http://localhost:3000 + dev-redirect-url: https://api.alignlab.site/api/dev/v1/oauth/kakao/authorize/redirected + encrypt: key: algorithm: