diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt index b3b2b71c..d67b5dd3 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt @@ -6,7 +6,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import org.yapp.apis.auth.dto.request.SocialLoginRequest import org.yapp.apis.auth.dto.request.TokenRefreshRequest -import org.yapp.apis.auth.dto.request.WithdrawRequest import org.yapp.apis.auth.dto.response.AuthResponse import org.yapp.apis.auth.usecase.AuthUseCase import java.util.* @@ -37,10 +36,9 @@ class AuthController( @DeleteMapping("/withdraw") override fun withdraw( - @AuthenticationPrincipal userId: UUID, - @Valid @RequestBody request: WithdrawRequest + @AuthenticationPrincipal userId: UUID ): ResponseEntity { - authUseCase.withdraw(userId, request) + authUseCase.withdraw(userId) return ResponseEntity.noContent().build() } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt index c2ca97d0..5a40bfbf 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt @@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.yapp.apis.auth.dto.request.SocialLoginRequest import org.yapp.apis.auth.dto.request.TokenRefreshRequest -import org.yapp.apis.auth.dto.request.WithdrawRequest import org.yapp.apis.auth.dto.response.AuthResponse import org.yapp.globalutils.exception.ErrorResponse import java.util.* @@ -109,7 +108,6 @@ interface AuthControllerApi { ) @DeleteMapping("/withdraw") fun withdraw( - @AuthenticationPrincipal userId: UUID, - @Valid @RequestBody request: WithdrawRequest + @AuthenticationPrincipal userId: UUID ): ResponseEntity } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawRequest.kt deleted file mode 100644 index 1cc1a228..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/WithdrawRequest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.yapp.apis.auth.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import org.yapp.domain.user.ProviderType - -@Schema( - name = "WithdrawRequest", - description = "DTO for user withdrawal requests" -) -data class WithdrawRequest private constructor( - @Schema( - description = "Type of social login provider for withdrawal", - example = "APPLE", - required = true - ) - @field:NotNull(message = "Provider type is not-null") - val providerType: ProviderType? = null -) { - fun validProviderType(): ProviderType = providerType!! -} 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 bd0f8837..0b0024ff 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 @@ -1,27 +1,32 @@ package org.yapp.apis.auth.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.WithdrawTargetUserResponse import org.yapp.domain.user.ProviderType import java.util.* @Schema(description = "회원 탈퇴 처리 시 내부적으로 사용되는 요청 DTO") data class WithdrawStrategyRequest private constructor( + @field:NotNull(message = "사용자 ID는 필수 값입니다.") @Schema( description = "사용자 고유 ID", - example = "123e4567-e89b-12d3-a456-426614174000", + example = "123e4567-e89b-12d3-a456-426614174000" ) val userId: UUID, + @field:NotNull(message = "소셜 로그인 제공자 타입은 필수 값입니다.") @Schema( description = "소셜 로그인 제공자 타입", - example = "KAKAO", + example = "KAKAO" ) val providerType: ProviderType, + @field:NotBlank(message = "소셜 로그인 제공자로부터 발급받은 고유 ID는 필수 값입니다.") @Schema( description = "소셜 로그인 제공자로부터 발급받은 고유 ID", - example = "21412412412", + example = "21412412412" ) val providerId: String, 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 41b3358e..3072939c 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 @@ -22,6 +22,7 @@ enum class AuthErrorCode( INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_10", "유효하지 않은 Apple 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", "카카오 회원탈퇴 처리에 실패했습니다."), /* 401 UNAUTHORIZED */ INVALID_OAUTH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "잘못된 소셜 OAuth 토큰입니다."), @@ -49,7 +50,8 @@ enum class AuthErrorCode( HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_06", "Apple에서 초기 로그인 시 리프레시 토큰을 제공하지 않았습니다." - ); + ), + KAKAO_UNLINK_RESPONSE_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_08", "카카오 회원탈퇴 응답이 요청과 일치하지 않습니다."); override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt b/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt index b93a5acd..7b2d3595 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt @@ -3,15 +3,17 @@ package org.yapp.apis.auth.manager import mu.KotlinLogging import org.springframework.stereotype.Component import org.springframework.web.client.HttpClientErrorException -import org.springframework.web.client.HttpServerErrorException import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.config.KakaoOauthProperties import org.yapp.infra.external.oauth.kakao.KakaoApi +import org.yapp.infra.external.oauth.kakao.response.KakaoUnlinkResponse import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo @Component class KakaoApiManager( - private val kakaoApi: KakaoApi + private val kakaoApi: KakaoApi, + private val kakaoOauthProperties: KakaoOauthProperties ) { private val log = KotlinLogging.logger {} @@ -24,22 +26,48 @@ class KakaoApiManager( log.error("Failed to fetch Kakao user info", exception) when (exception) { - is HttpClientErrorException -> { - throw AuthException( - AuthErrorCode.INVALID_OAUTH_TOKEN, - "Invalid Kakao OAuth token." - ) - } - - is HttpServerErrorException, is Exception -> { - throw AuthException( - AuthErrorCode.OAUTH_SERVER_ERROR, - "Failed to communicate with Kakao server." - ) - } - - else -> throw exception + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Kakao Access Token." + ) + + else -> throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to communicate with Kakao server." + ) + } + } + } + + fun unlink(targetId: String): KakaoUnlinkResponse { + return kakaoApi.unlink(kakaoOauthProperties.adminKey, targetId) + .onSuccess { response -> + log.info("Successfully unlinked Kakao user with targetId: $targetId, responseId: ${response.id}") + validateUnlinkResponse(response, targetId) + } + .getOrElse { exception -> + log.error("Failed to unlink Kakao user with targetId: $targetId", exception) + + when (exception) { + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.KAKAO_UNLINK_FAILED, + "Failed to unlink Kakao user due to client error: ${exception.message}" + ) + + else -> throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to unlink Kakao user due to server error: ${exception.message}" + ) } } } + + private fun validateUnlinkResponse(response: KakaoUnlinkResponse, expectedTargetId: String) { + if (response.id != expectedTargetId) { + throw AuthException( + AuthErrorCode.KAKAO_UNLINK_RESPONSE_MISMATCH, + "Kakao unlink response ID does not match target ID" + ) + } + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/AppleWithdrawStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/AppleWithdrawStrategy.kt index b124daf5..e1063c92 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/AppleWithdrawStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/AppleWithdrawStrategy.kt @@ -1,6 +1,8 @@ package org.yapp.apis.auth.strategy.withdraw +import jakarta.validation.Valid import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated import org.yapp.apis.auth.dto.request.WithdrawStrategyRequest import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException @@ -8,12 +10,13 @@ import org.yapp.apis.auth.manager.AppleApiManager import org.yapp.domain.user.ProviderType @Component +@Validated class AppleWithdrawStrategy( private val appleApiManager: AppleApiManager ) : WithdrawStrategy { override fun getProviderType(): ProviderType = ProviderType.APPLE - override fun withdraw(request: WithdrawStrategyRequest) { + override fun withdraw(@Valid request: WithdrawStrategyRequest) { val appleRefreshToken = request.appleRefreshToken ?: throw AuthException(AuthErrorCode.APPLE_REFRESH_TOKEN_MISSING) diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/KakaoWithdrawStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/KakaoWithdrawStrategy.kt index f6b45ca0..2339452d 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/KakaoWithdrawStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/KakaoWithdrawStrategy.kt @@ -1,17 +1,27 @@ package org.yapp.apis.auth.strategy.withdraw +import jakarta.validation.Valid +import mu.KotlinLogging import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated import org.yapp.apis.auth.dto.request.WithdrawStrategyRequest import org.yapp.apis.auth.manager.KakaoApiManager import org.yapp.domain.user.ProviderType @Component +@Validated class KakaoWithdrawStrategy( private val kakaoApiManager: KakaoApiManager ) : WithdrawStrategy { + private val log = KotlinLogging.logger {} + override fun getProviderType() = ProviderType.KAKAO - override fun withdraw(request: WithdrawStrategyRequest) { -// kakaoApiManager.unlink(request.providerId) + override fun withdraw(@Valid request: WithdrawStrategyRequest) { + log.info("Starting Kakao withdrawal for user: ${request.userId}, providerId: ${request.providerId}") + + val unlinkResponse = kakaoApiManager.unlink(request.providerId) + + log.info("Successfully unlinked Kakao user. Response ID: ${unlinkResponse.id}") } } 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 822cc1ec..a9f44cd9 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 @@ -3,8 +3,6 @@ package org.yapp.apis.auth.usecase import org.springframework.transaction.annotation.Transactional import org.yapp.apis.auth.dto.request.* import org.yapp.apis.auth.dto.response.TokenPairResponse -import org.yapp.apis.auth.exception.AuthErrorCode -import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.auth.service.* import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials import org.yapp.apis.auth.strategy.signin.SignInCredentials @@ -63,17 +61,9 @@ class AuthUseCase( authTokenService.deleteRefreshTokenForSignOutOrWithdraw(DeleteTokenRequest.from(refreshTokenResponse)) } - fun withdraw(userId: UUID, withdrawRequest: WithdrawRequest) { + fun withdraw(userId: UUID) { val withdrawTargetUserResponse = userAccountService.findWithdrawUserById(userId) - - if (withdrawTargetUserResponse.providerType != withdrawRequest.validProviderType()) { - throw AuthException( - AuthErrorCode.PROVIDER_TYPE_MISMATCH, - "The provider type in the request does not match the user's actual provider type." - ) - } - - val strategy = withdrawStrategyResolver.resolve(withdrawRequest.validProviderType()) + val strategy = withdrawStrategyResolver.resolve(withdrawTargetUserResponse.providerType) strategy.withdraw(WithdrawStrategyRequest.from(withdrawTargetUserResponse)) userWithdrawalService.processWithdrawal(userId) diff --git a/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt index 060a5a87..30366d27 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt @@ -4,5 +4,5 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration -@EnableConfigurationProperties(AppleOauthProperties::class) +@EnableConfigurationProperties(AppleOauthProperties::class, KakaoOauthProperties::class) class AuthConfig diff --git a/apis/src/main/kotlin/org/yapp/apis/config/KakaoOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/KakaoOauthProperties.kt new file mode 100644 index 00000000..f9f66d5d --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/KakaoOauthProperties.kt @@ -0,0 +1,8 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.kakao") +data class KakaoOauthProperties( + val adminKey: String +) diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index 1be2c434..a7f71c88 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -79,6 +79,8 @@ aladin: ttb-key: dummy-aladin-key oauth: + kakao: + admin-key: DUMMYADMINKEY apple: client-id: dummy.client.id key-id: DUMMYKEYID diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt index a8206efc..5c8160ef 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt @@ -1,6 +1,7 @@ package org.yapp.infra.external.oauth.kakao import org.springframework.stereotype.Component +import org.yapp.infra.external.oauth.kakao.response.KakaoUnlinkResponse import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo @Component @@ -17,4 +18,10 @@ class KakaoApi( KakaoUserInfo.from(response) } } + + fun unlink(adminKey: String, targetId: String): Result { + return runCatching { + kakaoRestClient.unlink(adminKey, targetId) + } + } } diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt index 05d33a49..91f37529 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt @@ -1,23 +1,49 @@ package org.yapp.infra.external.oauth.kakao import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap import org.springframework.web.client.RestClient import org.yapp.infra.external.oauth.kakao.response.KakaoResponse +import org.yapp.infra.external.oauth.kakao.response.KakaoUnlinkResponse @Component class KakaoRestClient( builder: RestClient.Builder ) { + companion object { + private const val BASE_URL = "https://kapi.kakao.com" + private const val HEADER_AUTHORIZATION = "Authorization" + private const val AUTH_SCHEME_KAKAOAK = "KakaoAK" + private const val PARAM_TARGET_ID_TYPE = "target_id_type" + private const val PARAM_TARGET_ID = "target_id" + private const val VALUE_USER_ID = "user_id" + } + private val client = builder - .baseUrl("https://kapi.kakao.com") + .baseUrl(BASE_URL) .build() fun getUserInfo(bearerToken: String): KakaoResponse { return client.get() .uri("/v2/user/me") - .header("Authorization", bearerToken) + .header(HEADER_AUTHORIZATION, bearerToken) .retrieve() .body(KakaoResponse::class.java) ?: throw IllegalStateException("Kakao API 응답이 null 입니다.") } + + fun unlink(adminKey: String, targetId: String): KakaoUnlinkResponse { + val requestBody: MultiValueMap = LinkedMultiValueMap() + requestBody.add(PARAM_TARGET_ID_TYPE, VALUE_USER_ID) + requestBody.add(PARAM_TARGET_ID, targetId) + + return client.post() + .uri("/v1/user/unlink") + .header(HEADER_AUTHORIZATION, "$AUTH_SCHEME_KAKAOAK $adminKey") + .body(requestBody) + .retrieve() + .body(KakaoUnlinkResponse::class.java) + ?: throw IllegalStateException("Kakao unlink API 응답이 null 입니다.") + } } diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/response/KakaoUnlinkResponse.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/response/KakaoUnlinkResponse.kt new file mode 100644 index 00000000..af24a3ab --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/response/KakaoUnlinkResponse.kt @@ -0,0 +1,8 @@ +package org.yapp.infra.external.oauth.kakao.response + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoUnlinkResponse private constructor( + @JsonProperty("id") + val id: String +)