Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -37,10 +36,9 @@ class AuthController(

@DeleteMapping("/withdraw")
override fun withdraw(
@AuthenticationPrincipal userId: UUID,
@Valid @RequestBody request: WithdrawRequest
@AuthenticationPrincipal userId: UUID
): ResponseEntity<Unit> {
authUseCase.withdraw(userId, request)
authUseCase.withdraw(userId)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -109,7 +108,6 @@ interface AuthControllerApi {
)
@DeleteMapping("/withdraw")
fun withdraw(
@AuthenticationPrincipal userId: UUID,
@Valid @RequestBody request: WithdrawRequest
@AuthenticationPrincipal userId: UUID
): ResponseEntity<Unit>
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 토큰입니다."),
Expand Down Expand Up @@ -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
Expand Down
62 changes: 45 additions & 17 deletions apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand All @@ -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"
)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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
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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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}")
}
}
14 changes: 2 additions & 12 deletions apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
)
Comment on lines +5 to +8
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

관리자 키에 대한 검증 추가를 고려해보세요.

설정 프로퍼티가 올바르게 구현되었지만, adminKey는 중요한 정보이므로 빈 값이나 null 값에 대한 검증을 추가하는 것이 좋겠습니다.

다음과 같이 검증 어노테이션을 추가할 수 있습니다:

+import jakarta.validation.constraints.NotBlank
+
 @ConfigurationProperties(prefix = "oauth.kakao")
 data class KakaoOauthProperties(
+    @field:NotBlank(message = "Kakao admin key must not be blank")
     val adminKey: String
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ConfigurationProperties(prefix = "oauth.kakao")
data class KakaoOauthProperties(
val adminKey: String
)
import jakarta.validation.constraints.NotBlank
@ConfigurationProperties(prefix = "oauth.kakao")
data class KakaoOauthProperties(
@field:NotBlank(message = "Kakao admin key must not be blank")
val adminKey: String
)
🤖 Prompt for AI Agents
In apis/src/main/kotlin/org/yapp/apis/config/KakaoOauthProperties.kt around
lines 5 to 8, the adminKey property lacks validation to prevent null or empty
values. Add validation annotations such as @field:NotBlank to the adminKey
property and ensure the class is annotated with @Validated to enforce these
constraints at runtime, thereby preventing invalid configuration values.

2 changes: 2 additions & 0 deletions apis/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ aladin:
ttb-key: dummy-aladin-key

oauth:
kakao:
admin-key: DUMMYADMINKEY
apple:
client-id: dummy.client.id
key-id: DUMMYKEYID
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,4 +18,10 @@ class KakaoApi(
KakaoUserInfo.from(response)
}
}

fun unlink(adminKey: String, targetId: String): Result<KakaoUnlinkResponse> {
return runCatching {
kakaoRestClient.unlink(adminKey, targetId)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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 입니다.")
}
}
Original file line number Diff line number Diff line change
@@ -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
)