Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ dependencies {

// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")

// Caffeine
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ class AuthController(
private val authService: AuthService,
) {
@Operation(
summary = "카카오 로그인 API",
description = "카카오 로그인을 진행합니다."
summary = "소셜 로그인 API",
description = "소셜 로그인을 진행합니다."
)
@PostMapping("/login/kakao")
@PostMapping("/login")
fun signIn(
@Valid @RequestBody signInRequest: SignInRequest
): ResponseEntity<ApiResponse<SignInResponse>> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.appjam.smashing.domain.auth.dto.command

import org.appjam.smashing.domain.auth.enums.ProviderType

data class SignInRequestCommand(
val accessToken: String,
val idToken: String,
val provider: ProviderType,
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package org.appjam.smashing.domain.auth.dto.command

import org.appjam.smashing.domain.auth.enums.ProviderType
import org.appjam.smashing.domain.sport.enums.ExperienceRange
import org.appjam.smashing.domain.user.enums.Gender

data class SignUpRequestCommand(
val kakaoId: String,
val socialId: String,
val provider: ProviderType,
val nickname: String,
val gender: Gender,
val openChatUrl: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ package org.appjam.smashing.domain.auth.dto.request

import jakarta.validation.constraints.NotBlank
import org.appjam.smashing.domain.auth.dto.command.SignInRequestCommand
import org.appjam.smashing.domain.auth.enums.ProviderType
import org.appjam.smashing.global.common.validator.annotation.ValidEnum
import org.appjam.smashing.global.extensions.ofIgnoreCase

data class SignInRequest(
@field:NotBlank(message = "엑세스 토큰을 입력해주세요.")
val accessToken: String?,
@field:NotBlank(message = "idToken을 입력해주세요.")
val idToken: String?,
@field:NotBlank(message = "provider를 입력해주세요.")
@field:ValidEnum(message = "잘못된 provider 값입니다.", enumClass = ProviderType::class)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

provider는 현재 enum validation만 하고 toCommand()에서 바로 provider!!로 사용하고 있어서, null/blank가 validation에서 걸러지지 않으면 500으로 떨어질 수 있을 것 같아요.

@notblank까지 같이 두어서 요청 검증 단계에서 400으로 막아주면 더 안전할 수 있을 것같습니다!

@leeeyubin leeeyubin Apr 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

예리하십니다.. 추가했습니다! f903972

val provider: String?,
) {
fun toCommand(): SignInRequestCommand =
SignInRequestCommand(
accessToken = accessToken!!,
)
fun toCommand() = SignInRequestCommand(
idToken = idToken!!,
provider = ofIgnoreCase<ProviderType>(provider!!),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package org.appjam.smashing.domain.auth.dto.request

import jakarta.validation.constraints.NotBlank
import org.appjam.smashing.domain.auth.dto.command.SignUpRequestCommand
import org.appjam.smashing.domain.auth.enums.ProviderType
import org.appjam.smashing.domain.sport.enums.ExperienceRange
import org.appjam.smashing.domain.user.enums.Gender
import org.appjam.smashing.global.common.validator.annotation.ValidEnum
import org.appjam.smashing.global.extensions.ofIgnoreCase

data class SignUpRequest(
@field:NotBlank(message = "kakaoId를 입력해주세요.")
val kakaoId: String?,
@field:NotBlank(message = "socialId를 입력해주세요.")
val socialId: String?,
@field:ValidEnum(message = "잘못된 provider 값입니다.", enumClass = ProviderType::class)
val provider: String?,
@field:NotBlank(message = "nickname을 입력해주세요.")
val nickname: String?,
@field:NotBlank(message = "gender를 입력해주세요.")
Expand All @@ -26,7 +29,8 @@ data class SignUpRequest(
val region: String?,
) {
fun toCommand() = SignUpRequestCommand(
kakaoId = kakaoId!!,
socialId = socialId!!,
provider = ofIgnoreCase<ProviderType>(provider!!),
nickname = nickname!!,
gender = ofIgnoreCase<Gender>(gender!!),
openChatUrl = openChatUrl!!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package org.appjam.smashing.domain.auth.dto.response
data class SignInResponse(
val accessToken: String?,
val refreshToken: String?,
val kakaoId: String,
val socialId: String,
val userId: String?,
val nickname: String?,
) {
Expand All @@ -13,13 +13,13 @@ data class SignInResponse(
fun from(
accessToken: String? = null,
refreshToken: String? = null,
kakaoId: String,
socialId: String,
userId: String? = null,
nickname: String? = null,
) = SignInResponse(
accessToken = accessToken,
refreshToken = refreshToken,
kakaoId = kakaoId,
socialId = socialId,
userId = userId,
nickname = nickname,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.appjam.smashing.domain.auth.dto.response

import org.appjam.smashing.domain.auth.enums.ProviderType

data class SocialType(
val provider: ProviderType,
val socialId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.appjam.smashing.domain.auth.enums

enum class ProviderType {
KAKAO, APPLE
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ class AuthService(
fun signIn(
requestCommand: SignInRequestCommand,
): SignInResponse {
val kakaoId = socialAuthServiceManager.getKakaoId(requestCommand.accessToken)
val (provider, socialId) = socialAuthServiceManager.getSocialId(requestCommand)

val user = userRepository.findByKakaoId(kakaoId)
?: return SignInResponse.from(
kakaoId = kakaoId,
)
val user = userRepository.findBySocialIdAndProvider(
socialId = socialId,
provider = provider,
) ?: return SignInResponse.from(socialId = socialId)

val userId = user.id ?: throw CustomException(ErrorCode.USER_NOT_FOUND)

Expand All @@ -56,7 +56,7 @@ class AuthService(
return SignInResponse.from(
accessToken = token.accessToken.token,
refreshToken = token.refreshToken.token,
kakaoId = kakaoId,
socialId = socialId,
userId = userId,
nickname = user.nickname,
)
Expand All @@ -81,7 +81,8 @@ class AuthService(

val user = userRepository.save(
User.create(
kakaoId = requestCommand.kakaoId,
socialId = requestCommand.socialId,
provider = requestCommand.provider,
nickname = requestCommand.nickname,
gender = requestCommand.gender,
openchatUrl = requestCommand.openChatUrl.trim(),
Expand Down Expand Up @@ -173,8 +174,8 @@ class AuthService(
}

private fun validateUser(requestCommand: SignUpRequestCommand) {
if (userRepository.existsByKakaoId(requestCommand.kakaoId)) {
throw CustomException(ErrorCode.DUPLICATE_KAKAO_ID)
if (userRepository.existsBySocialId(requestCommand.socialId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

로그인 조회는 (socialId, provider) 조합으로 하고 있는데, 가입 시 중복 체크는 existsBySocialId()만 사용하고 있어서 기준이 조금 달라질 수 있을 것같아요.
현재 구조라면 중복 판단도 provider까지 포함해서 맞춰주는 게 더 자연스러울 수 있을 것같습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

좋은 의견 감사합니다 :)
제가 생각을 해보았는데요! provider는 KAKAO 소셜 로그인인지, APPLE 소셜 로그인인지 기록하는 용이기 때문에 서로 다른 유저간 중복이 될 수밖에 없다고 판단이 들었습니다!

throw CustomException(ErrorCode.DUPLICATE_SOCIAL_ID)
}
Comment on lines 176 to 179

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

중복 체크는 provider까지 포함해야 합니다.

signIn()은 바로 위에서 (socialId, provider)로 조회하는데, 가입 검증만 existsBySocialId()를 쓰면 Apple/Kakao 간에 우연히 같은 문자열이 나온 경우 정상 가입까지 막아버립니다. 중복 판단 기준을 로그인 조회 기준과 동일하게 맞춰주세요.

예시 수정
-        if (userRepository.existsBySocialId(requestCommand.socialId)) {
+        if (userRepository.existsBySocialIdAndProvider(
+                socialId = requestCommand.socialId,
+                provider = requestCommand.provider,
+            )) {
             throw CustomException(ErrorCode.DUPLICATE_SOCIAL_ID)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/kotlin/org/appjam/smashing/domain/auth/service/AuthService.kt`
around lines 176 - 179, validateUser currently checks only socialId and can
block different-provider accounts with identical socialId; update validateUser
to check both socialId and provider (use repository method like
existsBySocialIdAndProvider or add a query that accepts requestCommand.provider)
so the duplicate check matches the same criteria used by signIn (which looks up
by socialId and provider). Ensure you reference requestCommand.socialId and
requestCommand.provider and throw CustomException(ErrorCode.DUPLICATE_SOCIAL_ID)
when that combined-existence check returns true.


if (userRepository.existsByNickname(requestCommand.nickname)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.appjam.smashing.domain.auth.social

import com.fasterxml.jackson.databind.JsonNode
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import org.appjam.smashing.global.exception.CustomException
import org.appjam.smashing.global.exception.ErrorCode
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import java.util.concurrent.TimeUnit

@Component
class OidcJwksClient(
private val restTemplate: RestTemplate,
) {
private val cache: Cache<String, JsonNode> = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.maximumSize(10)
.build()

fun getKeys(
jwksUri: String,
): JsonNode = cache.get(jwksUri) {
restTemplate.getForObject(jwksUri, JsonNode::class.java)
?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)
} ?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)
Comment on lines +21 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

JWKS 키 롤오버 및 네트워크 오류 처리 보완 필요.

두 가지 우려가 있습니다.

  1. 키 롤오버 시 복구 지연 (major): JWKS를 expireAfterWrite(1, HOURS)로 캐시하는데, 애플/카카오가 예정보다 일찍 서명 키를 롤링하면 캐시된 JWKS에 토큰의 kid가 존재하지 않아 OidcTokenValidator.getPublicKey()에서 INVALID_ID_TOKEN이 발생하고, 이 상태가 최대 1시간 지속됩니다. 호출자(OidcTokenValidator)가 kid를 못 찾았을 때 캐시를 무효화(cache.invalidate(jwksUri))하고 1회 재조회하도록 협조하거나, 본 클라이언트에 refresh(jwksUri) 메서드를 제공하는 것이 안전합니다.

  2. 네트워크 오류가 토큰 오류로 둔갑 (major): restTemplate.getForObject가 타임아웃/5xx로 예외(ResourceAccessException, HttpServerErrorException 등)를 던지면 캐시 로더에서 그대로 전파되고, 상위 OidcTokenValidator.extractSocialId의 포괄적 try-catch에서 INVALID_ID_TOKEN(401)으로 변환됩니다. 실제로는 서버/네트워크 장애이므로 INVALID_ID_TOKEN과 구분되는 에러코드(예: JWKS_FETCH_FAILED 5xx 계열)로 매핑하는 것이 관측성과 클라이언트 재시도 판단에 유리합니다.

또한 Line 26의 바깥쪽 ?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)은 로더가 이미 null일 때 throw하므로 실질적으로 도달하지 않습니다(Caffeine의 get(key, loader)는 로더가 null 반환 시 null 반환). 중복이지만 방어적 코드로 유지해도 무방합니다.

🛠️ 제안: 강제 갱신 메서드 추가 + 네트워크 예외 분리
 `@Component`
 class OidcJwksClient(
     private val restTemplate: RestTemplate,
 ) {
     private val cache: Cache<String, JsonNode> = Caffeine.newBuilder()
         .expireAfterWrite(1, TimeUnit.HOURS)
         .maximumSize(10)
         .build()

     fun getKeys(
         jwksUri: String,
     ): JsonNode = cache.get(jwksUri) {
-        restTemplate.getForObject(jwksUri, JsonNode::class.java)
-            ?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)
+        fetch(jwksUri)
     } ?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)
+
+    fun refresh(jwksUri: String): JsonNode {
+        cache.invalidate(jwksUri)
+        return getKeys(jwksUri)
+    }
+
+    private fun fetch(jwksUri: String): JsonNode = try {
+        restTemplate.getForObject(jwksUri, JsonNode::class.java)
+            ?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)
+    } catch (e: RestClientException) {
+        throw CustomException(ErrorCode.JWKS_FETCH_FAILED) // 신규 에러코드 권장
+    }
 }

그리고 OidcTokenValidator.getPublicKey()에서 kid 미일치 시 jwksClient.refresh(jwksUri)로 1회 재시도 후에도 없으면 INVALID_ID_TOKEN을 던지는 흐름을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/kotlin/org/appjam/smashing/domain/auth/social/OidcJwksClient.kt`
around lines 21 - 26, The getKeys loader currently treats any failure as
INVALID_ID_TOKEN and caches JWKS for 1 hour, causing long failures on key
rollover and conflating network errors with invalid tokens; add a
refresh(jwksUri: String) method on OidcJwksClient that invalidates the Caffeine
cache (cache.invalidate(jwksUri)) and re-fetches keys, and modify getKeys to
catch network-related exceptions from restTemplate.getForObject (e.g.,
ResourceAccessException, HttpServerErrorException) and throw a new
CustomException(ErrorCode.JWKS_FETCH_FAILED) so callers can distinguish
transient fetch errors; also update OidcTokenValidator.getPublicKey to call
jwksClient.refresh(jwksUri) once when a kid is not found before ultimately
throwing CustomException(ErrorCode.INVALID_ID_TOKEN).


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.appjam.smashing.domain.auth.social

import org.appjam.smashing.domain.auth.enums.ProviderType
import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "oidc")
data class OidcProperties(
val kakaoClientId: String,
val appleClientId: String,
) {
fun getClientId(providerType: ProviderType): String = when (providerType) {
ProviderType.KAKAO -> kakaoClientId
ProviderType.APPLE -> appleClientId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.appjam.smashing.domain.auth.social

import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.Jwts
import org.appjam.smashing.domain.auth.enums.ProviderType
import org.appjam.smashing.global.exception.CustomException
import org.appjam.smashing.global.exception.ErrorCode
import org.springframework.stereotype.Component
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.*

@Component
class OidcTokenValidator(
private val oidcProperties: OidcProperties,
private val jwksClient: OidcJwksClient,
) {
fun extractSocialId(
idToken: String,
providerType: ProviderType,
): String {
// 회웑 정보 가져오기
val (iss, jwksUri) = when (providerType) {
ProviderType.KAKAO -> KAKAO_ISS to KAKAO_JWKS_URI
ProviderType.APPLE -> APPLE_ISS to APPLE_JWKS_URI
}
val clientId = oidcProperties.getClientId(providerType)

val publicKey = getPublicKey(
idToken = idToken,
jwksUri = jwksUri,
)

val claims = Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(idToken)
.body

// 회원 검증
if (claims.issuer != iss) throw CustomException(ErrorCode.INVALID_ISS)
if (claims.audience != clientId) throw CustomException(ErrorCode.INVALID_AUD)

return claims.subject
}

private fun getPublicKey(
idToken: String,
jwksUri: String,
): PublicKey {
val header = String(Base64.getUrlDecoder().decode(idToken.split(".")[0]))
val kid = ObjectMapper().readTree(header).get(KID).asText()

val keys = jwksClient.getKeys(jwksUri)

val key = keys[KEYS].find { it[KID].asText() == kid }
?: throw CustomException(ErrorCode.INVALID_ID_TOKEN)

val n = BigInteger(1, Base64.getUrlDecoder().decode(key[N].asText()))
val e = BigInteger(1, Base64.getUrlDecoder().decode(key[E].asText()))

return KeyFactory.getInstance(RSA)
.generatePublic(RSAPublicKeySpec(n, e))
}

companion object {
private const val KID = "kid"
private const val KEYS = "keys"
private const val N = "n"
private const val E = "e"
private const val RSA = "RSA"
private const val KAKAO_JWKS_URI = "https://kauth.kakao.com/.well-known/jwks.json"
private const val KAKAO_ISS = "https://kauth.kakao.com"
private const val APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys"
private const val APPLE_ISS = "https://appleid.apple.com"
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
package org.appjam.smashing.domain.auth.social

import org.appjam.smashing.domain.auth.social.kakao.KakaoAuthTokenValidator
import org.appjam.smashing.domain.auth.dto.command.SignInRequestCommand
import org.appjam.smashing.domain.auth.dto.response.SocialType
import org.springframework.stereotype.Component

@Component
class SocialAuthServiceManager(
private val kakaoAuthTokenValidator: KakaoAuthTokenValidator,
private val oidcTokenValidator: OidcTokenValidator,
) {
fun getKakaoId(authAccessToken: String): String = kakaoAuthTokenValidator.extractKakaoId(authAccessToken)
fun getSocialId(command: SignInRequestCommand): SocialType {
val socialId = oidcTokenValidator.extractSocialId(
idToken = command.idToken,
providerType = command.provider
)

return SocialType(
provider = command.provider,
socialId = socialId,
)
}
}

This file was deleted.

This file was deleted.

Loading