Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,21 @@ class AuthenticationController(
}

@SecurityRequirements // 로그인을 필요로 하지 않는 곳은 전역 인증을 사용하지않도록 초기화
@Operation(summary = "토큰 재발급")
@Operation(
summary = "토큰 재발급",
description = "Authorization 헤더는 무시되며, 바디의 refreshToken(순수 JWT)만 사용합니다.",
responses = [
ApiResponse(responseCode = "200", description = "OK"),
ApiResponse(responseCode = "401", description = "Auth-001 (리프레시 토큰이 아닙니다. 등)")
]
)
@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
Expand Up @@ -66,7 +66,13 @@ class SwaggerConfiguration {
private fun info() : Info {
return Info()
.title("Scouter API")
.description("Scouter API")
.version("v1.0.0")
.description(
"Changelog v1.1.0\n" +
"- Token 응답 표준화: tokenType + accessToken + refreshToken\n" +
"- 발급: 접두사 제거(순수 JWT), 수신: Bearer 유무 허용\n" +
"- claims.tokenType 추가 및 타입 불일치 상세 오류메시지 (Auth-001)\n" +
"- POST /refresh-token: Authorization 헤더 무시, body.refreshToken만 사용\n" +
)
.version("v1.1.0")
}
}
}
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,15 @@ class JwtTokenProcessor(
val issueDate: Date = convertToDate(issueTime)
val key: String = jwtProperties.findTokenKey(tokenType)
val expiredHours: Long = jwtProperties.findExpiredHours(tokenType)
val claimsWithTokenType: Map<String, Any> = buildMap {
putAll(privateClaims)
put("tokenType", tokenType.name)
}

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 +53,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 != tokenType.name) {
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")
}
}