diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/AuthenticationController.kt b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/AuthenticationController.kt index 4f71fccd..6df02a49 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/AuthenticationController.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/AuthenticationController.kt @@ -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 { - 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) diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/LoginResponse.kt b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/LoginResponse.kt index 566470a9..abd7a1a3 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/LoginResponse.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/LoginResponse.kt @@ -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 ) diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/TokenRefreshResponse.kt b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/TokenRefreshResponse.kt index aec83d59..ad2f2774 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/TokenRefreshResponse.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/domain/authentication/TokenRefreshResponse.kt @@ -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, ) diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/support/authentication/AuthUserInfoArgumentResolver.kt b/src/main/kotlin/com/yourssu/scouter/common/application/support/authentication/AuthUserInfoArgumentResolver.kt index 7a7edb13..27c74569 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/support/authentication/AuthUserInfoArgumentResolver.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/support/authentication/AuthUserInfoArgumentResolver.kt @@ -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 @@ -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) diff --git a/src/main/kotlin/com/yourssu/scouter/common/application/support/configuration/SwaggerConfiguration.kt b/src/main/kotlin/com/yourssu/scouter/common/application/support/configuration/SwaggerConfiguration.kt index 52d5f295..5b759574 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/application/support/configuration/SwaggerConfiguration.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/application/support/configuration/SwaggerConfiguration.kt @@ -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") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/yourssu/scouter/common/business/domain/authentication/AuthenticationService.kt b/src/main/kotlin/com/yourssu/scouter/common/business/domain/authentication/AuthenticationService.kt index 1597761c..8aa7c7b4 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/business/domain/authentication/AuthenticationService.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/business/domain/authentication/AuthenticationService.kt @@ -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( @@ -31,7 +27,7 @@ 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 @@ -39,7 +35,7 @@ class AuthenticationService( throw NoSuchUserException("존재하지 않는 유저의 토큰입니다.") } if (blacklistTokenReader.isBlacklisted(userId, token)) { - throw InvalidTokenException("로그아웃되었습니다.") + throw InvalidTokenException(InvalidTokenMessages.LOGGED_OUT) } return privateClaims @@ -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) diff --git a/src/main/kotlin/com/yourssu/scouter/common/implement/support/exception/InvalidTokenMessages.kt b/src/main/kotlin/com/yourssu/scouter/common/implement/support/exception/InvalidTokenMessages.kt new file mode 100644 index 00000000..d3324c89 --- /dev/null +++ b/src/main/kotlin/com/yourssu/scouter/common/implement/support/exception/InvalidTokenMessages.kt @@ -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 = "로그아웃되었습니다." +} diff --git a/src/main/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessor.kt b/src/main/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessor.kt index 4f37721f..19e2e8ac 100644 --- a/src/main/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessor.kt +++ b/src/main/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessor.kt @@ -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 @@ -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 = 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() } @@ -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): Token { diff --git a/src/test/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessorTest.kt b/src/test/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessorTest.kt index 48948921..98e35e6b 100644 --- a/src/test/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessorTest.kt +++ b/src/test/kotlin/com/yourssu/scouter/common/implement/support/security/token/JwtTokenProcessorTest.kt @@ -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 @@ -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 @@ -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") + } }