Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,10 @@ class AuthenticationController(
@PostMapping("/refresh-token")
fun refreshToken(
@RequestBody @Valid request: TokenRefreshRequest,
@Parameter(hidden = true)
@RequestHeader(HttpHeaders.AUTHORIZATION, required = false) bearerRefreshHeader: String?,
): ResponseEntity<TokenRefreshResponse> {
logger.info("[Auth] POST /refresh-token | hasAuthHeader={} | bodyPresent={}", !bearerRefreshHeader.isNullOrBlank(), !request.refreshToken.isNullOrBlank())
logger.info("[Auth] POST /refresh-token | bodyPresent={} (Authorization header ignored)", !request.refreshToken.isNullOrBlank())
val requestTime = LocalDateTime.now()
val providedRefreshToken = if (!bearerRefreshHeader.isNullOrBlank()) bearerRefreshHeader else request.refreshToken
val tokenDto: TokenDto = authenticationService.refreshToken(requestTime, providedRefreshToken)
val tokenDto: TokenDto = authenticationService.refreshToken(requestTime, request.refreshToken)
val response: TokenRefreshResponse = TokenRefreshResponse.from(tokenDto)

return ResponseEntity.ok(response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package com.yourssu.scouter.common.application.domain.authentication
import com.yourssu.scouter.common.business.domain.authentication.LoginResult

data class LoginResponse(
val tokenType: String,
val accessToken: String,
val refreshToken: String,
) {
companion object {
fun from(loginResult: LoginResult): LoginResponse = LoginResponse(
tokenType = "Bearer",
accessToken = loginResult.accessToken,
refreshToken = loginResult.refreshToken
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package com.yourssu.scouter.common.application.domain.authentication
import com.yourssu.scouter.common.business.domain.authentication.TokenDto

data class TokenRefreshResponse(

val tokenType: String,
val accessToken: String,
val refreshToken: String,
) {

companion object {
fun from(tokenDto: TokenDto) = TokenRefreshResponse(
tokenType = "Bearer",
accessToken = tokenDto.accessToken,
refreshToken = tokenDto.refreshToken,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.yourssu.scouter.common.implement.domain.authentication.PrivateClaims
import com.yourssu.scouter.common.implement.domain.authentication.TokenProcessor
import com.yourssu.scouter.common.implement.domain.authentication.TokenType
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenException
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenMessages
import io.jsonwebtoken.Claims
import org.springframework.core.MethodParameter
import org.springframework.http.HttpHeaders
Expand Down Expand Up @@ -41,7 +42,7 @@ class AuthUserInfoArgumentResolver(
}

val claims: Claims = tokenProcessor.decode(TokenType.ACCESS, accessToken)
?: throw InvalidTokenException("유효한 토큰이 아닙니다.")
?: throw InvalidTokenException(InvalidTokenMessages.INVALID_TOKEN)
val privateClaims = PrivateClaims.from(claims)

return AuthUserInfo(privateClaims.userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
package com.yourssu.scouter.common.business.domain.authentication

import com.yourssu.scouter.common.business.support.exception.NoSuchUserException
import com.yourssu.scouter.common.implement.domain.authentication.BlacklistTokenReader
import com.yourssu.scouter.common.implement.domain.authentication.BlacklistTokenWriter
import com.yourssu.scouter.common.implement.domain.authentication.PrivateClaims
import com.yourssu.scouter.common.implement.domain.authentication.Token
import com.yourssu.scouter.common.implement.domain.authentication.TokenProcessor
import com.yourssu.scouter.common.implement.domain.authentication.TokenType
import com.yourssu.scouter.common.implement.domain.authentication.*
import com.yourssu.scouter.common.implement.domain.user.UserReader
import com.yourssu.scouter.common.implement.domain.user.UserWriter
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenException
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenMessages
import io.jsonwebtoken.Claims
import java.time.LocalDateTime
import org.springframework.stereotype.Service
import java.time.LocalDateTime

@Service
class AuthenticationService(
Expand All @@ -31,15 +27,15 @@ class AuthenticationService(

fun getValidPrivateClaims(tokenType: TokenType, token: String): PrivateClaims {
val claims: Claims = tokenProcessor.decode(tokenType, token)
?: throw InvalidTokenException("유효한 토큰이 아닙니다.")
?: throw InvalidTokenException(InvalidTokenMessages.INVALID_TOKEN)
val privateClaims = PrivateClaims.from(claims)

val userId: Long = privateClaims.userId
if (!userReader.existsById(userId)) {
throw NoSuchUserException("존재하지 않는 유저의 토큰입니다.")
}
if (blacklistTokenReader.isBlacklisted(userId, token)) {
throw InvalidTokenException("로그아웃되었습니다.")
throw InvalidTokenException(InvalidTokenMessages.LOGGED_OUT)
}

return privateClaims
Expand All @@ -56,8 +52,7 @@ class AuthenticationService(
}

fun refreshToken(requestTime: LocalDateTime, refreshToken: String): TokenDto {
val normalizedRefreshToken = if (refreshToken.startsWith("Bearer ")) refreshToken else "Bearer $refreshToken"
val privateClaims: PrivateClaims = getValidPrivateClaims(TokenType.REFRESH, normalizedRefreshToken)
val privateClaims: PrivateClaims = getValidPrivateClaims(TokenType.REFRESH, refreshToken)
val token: Token = tokenProcessor.generateToken(requestTime, privateClaims.toMap())

return TokenDto(token.accessToken, token.refreshToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.yourssu.scouter.common.implement.support.exception

object InvalidTokenMessages {
const val INVALID_TOKEN: String = "유효한 토큰이 아닙니다."
const val NOT_REFRESH_TOKEN: String = "리프레시 토큰이 아닙니다."
const val NOT_ACCESS_TOKEN: String = "액세스 토큰이 아닙니다."
const val LOGGED_OUT: String = "로그아웃되었습니다."
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.yourssu.scouter.common.implement.domain.authentication.Token
import com.yourssu.scouter.common.implement.domain.authentication.TokenProcessor
import com.yourssu.scouter.common.implement.domain.authentication.TokenType
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenException
import com.yourssu.scouter.common.implement.support.exception.InvalidTokenMessages
import io.jsonwebtoken.Claims
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
Expand Down Expand Up @@ -32,11 +33,12 @@ class JwtTokenProcessor(
val issueDate: Date = convertToDate(issueTime)
val key: String = jwtProperties.findTokenKey(tokenType)
val expiredHours: Long = jwtProperties.findExpiredHours(tokenType)
val claimsWithTokenType: Map<String, Any> = HashMap(privateClaims).plus("tokenType" to tokenType.name)

Copilot AI Sep 21, 2025

Copy link

Choose a reason for hiding this comment

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

Creating a new HashMap and then calling plus() is inefficient. Consider using privateClaims + ("tokenType" to tokenType.name) or buildMap { putAll(privateClaims); put("tokenType", tokenType.name) } for better performance.

Suggested change
val claimsWithTokenType: Map<String, Any> = HashMap(privateClaims).plus("tokenType" to tokenType.name)
val claimsWithTokenType: Map<String, Any> = privateClaims + ("tokenType" to tokenType.name)

Copilot uses AI. Check for mistakes.

return TOKEN_PREFIX + Jwts.builder()
return Jwts.builder()
.issuedAt(issueDate)
.expiration(Date(issueDate.time + expiredHours * 60 * 60 * 1000L))
.claims(privateClaims)
.claims(claimsWithTokenType)
.signWith(Keys.hmacShaKeyFor(key.toByteArray(StandardCharsets.UTF_8)))
.compact()
}
Expand All @@ -48,33 +50,46 @@ class JwtTokenProcessor(
}

override fun decode(tokenType: TokenType, token: String): Claims? {
validateBearerToken(token)
val pureToken = findActualToken(token)

return parseToClaims(tokenType, token)
}
// 1) 요청된 타입의 키로 먼저 검증
val requestedKey: String = jwtProperties.findTokenKey(tokenType)
parseWithKey(requestedKey, pureToken)?.let { claims ->
val actualType: String? = claims["tokenType"] as? String
if (actualType != null && !actualType.equals(tokenType.name, ignoreCase = true)) {

Copilot AI Sep 21, 2025

Copy link

Choose a reason for hiding this comment

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

The ignoreCase comparison is inconsistent with the exact string comparison used when setting the tokenType claim. Since tokenType.name always produces uppercase strings, consider using exact equality comparison for consistency: actualType != tokenType.name.

Suggested change
if (actualType != null && !actualType.equals(tokenType.name, ignoreCase = true)) {
if (actualType != null && actualType != tokenType.name) {

Copilot uses AI. Check for mistakes.
val message = if (TokenType.REFRESH == tokenType) InvalidTokenMessages.NOT_REFRESH_TOKEN else InvalidTokenMessages.NOT_ACCESS_TOKEN
throw InvalidTokenException(message)
}
return claims
}

private fun validateBearerToken(token: String) {
if (!token.startsWith(TOKEN_PREFIX)) {
throw InvalidTokenException("Bearer 타입이 아닙니다.")
// 2) 다른 타입의 키로 검증 성공하면 타입 불일치로 간주
val otherType: TokenType = if (TokenType.ACCESS == tokenType) TokenType.REFRESH else TokenType.ACCESS
val otherKey: String = jwtProperties.findTokenKey(otherType)
parseWithKey(otherKey, pureToken)?.let {
val message = if (TokenType.REFRESH == tokenType) InvalidTokenMessages.NOT_REFRESH_TOKEN else InvalidTokenMessages.NOT_ACCESS_TOKEN
throw InvalidTokenException(message)
}
}

private fun parseToClaims(tokenType: TokenType, token: String): Claims? {
val key: String = jwtProperties.findTokenKey(tokenType)
// 3) 어떤 키로도 파싱되지 않으면 유효하지 않은 토큰으로 간주
return null
}

private fun parseWithKey(key: String, pureToken: String): Claims? {
return try {
Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(key.toByteArray(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(findPureToken(token))
.parseSignedClaims(pureToken)
.payload
} catch (_: JwtException) {
null
}
}

private fun findPureToken(token: String): String {
return token.substring(TOKEN_PREFIX.length)
private fun findActualToken(token: String): String {
val trimmed = token.trim()
return if (trimmed.startsWith(TOKEN_PREFIX)) trimmed.substring(TOKEN_PREFIX.length) else trimmed
}

override fun generateToken(issueTime: LocalDateTime, privateClaims: Map<String, Any>): Token {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class JwtTokenProcessorTest {
val actual = tokenProcessor.encode(LocalDateTime.now(), TokenType.ACCESS, privateClaims)

// then
assertThat(actual).contains("Bearer ")
assertThat(actual).doesNotContain("Bearer ")
}

@Test
Expand All @@ -39,9 +39,8 @@ class JwtTokenProcessorTest {
val invalidTypeToken = "Basic12 abcde"

// when & then
Assertions.assertThatThrownBy { tokenProcessor.decode(TokenType.ACCESS, invalidTypeToken) }
.isInstanceOf(InvalidTokenException::class.java)
.hasMessage("Bearer 타입이 아닙니다.")
val actual: Claims? = tokenProcessor.decode(TokenType.ACCESS, invalidTypeToken)
assertThat(actual).isNull()
}

@Test
Expand Down Expand Up @@ -77,4 +76,74 @@ class JwtTokenProcessorTest {
// then
assertThat((actual!![keyName] as Number).toLong()).isEqualTo(userId)
}

@Test
fun `리프레시 토큰이 아닙니다 예외를 던진다`() {
// given
val tokenProcessor = JwtTokenProcessor(jwtProperties)
val claims = mapOf("userId" to 1L)
val accessToken = tokenProcessor.encode(
LocalDateTime.now(ZoneId.of("Asia/Seoul")),
TokenType.ACCESS,
claims
)

// when & then
Assertions.assertThatThrownBy { tokenProcessor.decode(TokenType.REFRESH, accessToken) }
.isInstanceOf(InvalidTokenException::class.java)
.hasMessage("리프레시 토큰이 아닙니다.")
}

@Test
fun `액세스 토큰이 아닙니다 예외를 던진다`() {
// given
val tokenProcessor = JwtTokenProcessor(jwtProperties)
val claims = mapOf("userId" to 1L)
val refreshToken = tokenProcessor.encode(
LocalDateTime.now(ZoneId.of("Asia/Seoul")),
TokenType.REFRESH,
claims
)

// when & then
Assertions.assertThatThrownBy { tokenProcessor.decode(TokenType.ACCESS, refreshToken) }
.isInstanceOf(InvalidTokenException::class.java)
.hasMessage("액세스 토큰이 아닙니다.")
}

@Test
fun `Bearer 접두사가 있어도 디코딩된다`() {
// given
val tokenProcessor = JwtTokenProcessor(jwtProperties)
val claims = mapOf("userId" to 1L)
val token = tokenProcessor.encode(
LocalDateTime.now(ZoneId.of("Asia/Seoul")),
TokenType.ACCESS,
claims
)

// when
val actual: Claims? = tokenProcessor.decode(TokenType.ACCESS, "Bearer $token")

// then
assertThat(actual).isNotNull()
}

@Test
fun `발급된 토큰에 tokenType 클레임이 포함된다`() {
// given
val tokenProcessor = JwtTokenProcessor(jwtProperties)
val claims = mapOf("userId" to 1L)
val accessToken = tokenProcessor.encode(
LocalDateTime.now(ZoneId.of("Asia/Seoul")),
TokenType.ACCESS,
claims
)

// when
val actual: Claims? = tokenProcessor.decode(TokenType.ACCESS, accessToken)

// then
assertThat(actual!!["tokenType"]).isEqualTo("ACCESS")
}
}