JWT
- RFC 7519 웹 표준으로 지정되어 있는 JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web Token
- 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
- URL로 이용할 수 있는 문자열로만 구성되어 있으며, 디지털 서명이 적용되어 있어 신뢰할 수 있으며 HTTP 구성요소 어디든 위치할 수 있음
- 주로 서버와의 통신에서 권한 인가를 위해 사용됨
JWT의 장점
- 중앙의 인증서버, 데이터 스토어에 대한 의존성 없음, 시스템 수평 확장 유리
- Base64 URL Safe Encoding > URL, Cookie, Header 모두 사용 가능
JWT의 단점
- Payload의 정보가 많아지면 네트워크 사용량 증가(트래픽 증가), 데이터 설계 고려 필요
- 토큰이 클라이언트에 저장, 서버에서 클라이언트의 토큰을 조작할 수 없음
JWT의 구조
Header
Signature를 해싱하기 위해 JWT가 어떻게 서명되었는지, 어떤 알고리즘으로 서명되었는지를 지정하는 메타데이터로 Base64URL로 인코딩
{
"alg": "HS256",
"typ": "JWT"
}
- “alg” : 알고리즘, JWT를 서명하는 데 사용된 알고리즘을 지정하는 필수 키
- “typ” : 토큰 타입, JWT의 타입을 지정하는 선택 키
- “typ”키의 타입 종류
- JWT(JSON Web Token) : 일반적인 JWT 타입, 대부분 이 값으로 설정
- JWS(JSON Web Signature) : JWT의 일부로 JWT가 서명되었음을 나타냄
- JWE(JSON Web Encryption) : JWT의 일부로 JWT가 암호화되었음을 나타냄
- 사용자 정의 타입 : 특정 도메인 또는 업무 영역에 특화된 JWT를 사용하는 경우 사용
Payload
서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용들을 담고 있음
Signature
토큰의 유효성 검증을 위한 문자로 이 문자열을 통해 서버에서는 이 토큰이 유효한 토큰인지를 검증할 수 있음
JWT 코드 작성하기
build.gradle.kts 내 dependencies에 다음 코드 추가
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")
CustomUserDetails 클래스에 사용자의 정보를 Map 타입으로 반환하는 함수 추가
class CustomUserDetails(
private val user: User
) : UserDetails {
// ...
fun getClaims(): Map<String, Any> {
val dataMap = mutableMapOf<String, Any>(
"email" to user.email,
"role" to user.role,
)
return dataMap
}
}
예외 처리를 위한 클래스 추가
data class CustomJwtException(
override val message: String,
): RuntimeException(message)
JWT 문자열 생성과 검증을 위한 클래스 추가 및 IntelliJ에서 [Edit Configurations...] → 환경 변수로 JWT_KEY 설정
@Component
class JwtUtil {
@Value("\${JWT_KEY}")
private lateinit var key: String
fun generateToken(valueMap: Map<String, Any>, min: Long): String { // JWT 문자열 생성
val key: SecretKey?
try {
key = Keys.hmacShaKeyFor(this.key.toByteArray(StandardCharsets.UTF_8))
} catch (e: Exception) {
throw RuntimeException(e.message)
}
return Jwts.builder()
.setHeader(mapOf<String, Any>("typ" to "JWT"))
.setClaims(valueMap)
.setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant()))
.signWith(key)
.compact()
}
fun validateToken(token: String): Map<String, Any> {
val claim: Map<String, Any>?
try {
val key = Keys.hmacShaKeyFor(this.key.toByteArray(StandardCharsets.UTF_8))
claim = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token) // 파싱 및 검증, 실패 시 Error
.body
} catch (e: MalformedJwtException) {
throw CustomJwtException("MalFormed")
} catch (e: ExpiredJwtException) {
throw CustomJwtException("Expired")
} catch (e: InvalidClaimException) {
throw CustomJwtException("Invalid")
} catch (e: JwtException) {
throw CustomJwtException("JWTError")
} catch (e: Exception) {
throw CustomJwtException("Error")
}
return claim
}
}
로그인 성공 시 동작하는 AuthenticationSuccessHandler 구현
@Component
class CustomAuthenticationSuccessHandler(
private val jwtUtil: JwtUtil
) : AuthenticationSuccessHandler {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val user: CustomUserDetails = authentication.principal as CustomUserDetails
val claims: MutableMap<String, Any> = user.getClaims().toMutableMap()
val accessToken = jwtUtil.generateToken(claims, 10) // 10분
val refreshToken = jwtUtil.generateToken(claims, 60 * 24) // 24시간
claims["accessToken"] = accessToken
claims["refreshToken"] = refreshToken
response.status = HttpStatus.OK.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
jacksonObjectMapper().writeValue(response.writer, claims)
}
}
프로젝트를 실행해 로그인 성공 시 응답 메시지가 정상적으로 전송되는지 확인
Access Token Check Filter 생성
class JwtCheckFilter(
private val jwtUtil: JwtUtil
) : OncePerRequestFilter() { // OncePerRequestFilter : 모든 요청에 대해 체크할 때 사용
override fun shouldNotFilter(request: HttpServletRequest): Boolean { // 필터로 체크하지 않을 경로 or 메서드 지정
val path = request.requestURI
val urls = listOf("/swagger-ui", "/v3/api-docs", "/signin", "/signup", "/api/user/refresh")
return urls.any { path.startsWith(it) } // path의 접두사와 일치하는 URI가 있으면 필터 체크 X
}
override fun doFilterInternal( // 모든 요청에 대해 체크하려고 할 때 사용
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val authHeader = request.getHeader("Authorization")
try {
val accessToken = authHeader.substring(7) // Bearer
val claims = jwtUtil.validateToken(accessToken)
val user = CustomUserDetails(
User(
email = claims["email"].toString(),
nickname = claims["nickname"].toString(),
password = claims["password"].toString(),
role = claims["role"].toString()
)
)
val authenticationToken = UsernamePasswordAuthenticationToken(user, user.password, user.authorities)
SecurityContextHolder.getContext().authentication = authenticationToken
filterChain.doFilter(request, response)
} catch (e: Exception) {
logger.error("ERROR_ACCESS_TOKEN")
response.status = HttpStatus.BAD_REQUEST.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
jacksonObjectMapper().writeValue(response.writer, "ERROR_ACCESS_TOKEN")
}
}
}
SecurityConfig 설정을 위한 코드 추가 : addFilterBefore로 JwtCheckFilter 추가
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val objectMapper: ObjectMapper,
private val userDetailsService: UserDetailsService,
private val customAuthenticationSuccessHandler: CustomAuthenticationSuccessHandler,
private val customAuthenticationFailureHandler: CustomAuthenticationFailureHandler
) {
@Bean
fun configure(http: HttpSecurity, jwtUtil: JwtUtil): SecurityFilterChain {
return http
.csrf { it.disable() } // API 서버에서 사용 X
.cors { it.configurationSource(corsConfigurationSource()) } // cors 설정
.addFilterBefore( // 우선 실행되어야 함
JwtCheckFilter(jwtUtil), UsernamePasswordAuthenticationFilter::class.java
)
.addFilterBefore( // JSON 데이터 처리
jsonUsernamePasswordAuthenticationFilter(),
UsernamePasswordAuthenticationFilter::class.java
)
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // 서버 내부에서 세션 생성 X
.formLogin { it.disable() } // 기본 로그인 폼 비활성화(기본 경로 "/login")
.exceptionHandling { it.accessDeniedHandler(CustomAccessDeniedException("ERROR_ACCESSDENIED")) }
.build()
}
}
Refresh Token 발행을 위한 Controller 추가
@RestController
class ApiRefreshController(
private val jwtUtil: JwtUtil
) {
@PostMapping("/api/user/refresh")
fun refresh(
@RequestHeader("Authorization") authHeader: String,
@RequestBody refreshToken: String
): Map<String, Any> {
if (refreshToken == null) {
throw CustomJwtException("NULL_REFRESH")
}
if (authHeader == null || authHeader.length < 7) {
throw CustomJwtException("INVALID_STRING")
}
val accessToken = authHeader.substring(7)
if (!checkExpiredToken(accessToken)) { // AccessToken이 만료되지 않았다면 그대로 반환
return mapOf("accessToken" to accessToken, "refreshToken" to refreshToken)
}
val claims = jwtUtil.validateToken(refreshToken)
val newAccessToken = jwtUtil.generateToken(claims, 10) // 10분
val newRefreshToken =
if (checkTime(claims["exp"] as Int)) jwtUtil.generateToken(claims, 60 * 24) else refreshToken
return mapOf("accessToken" to newAccessToken, "refreshToken" to newRefreshToken)
}
fun checkTime(exp: Int): Boolean { // 1시간 미만 여부
val expDate = Date(exp.toLong() * 1000) // JWT exp를 날짜로 변환
val gap = expDate.time - System.currentTimeMillis() // 현재 시간과 차이 계산(ms)
val leftMin = gap / (1000 * 60)
println("checkTime - expDate: $expDate, gap: $gap, leftMin: $leftMin")
return leftMin < exp
}
fun checkExpiredToken(token: String): Boolean {
try {
jwtUtil.validateToken(token)
} catch (e: CustomJwtException) {
if (e.message == "Expired") {
return true
}
}
return false
}
}
Refresh Token 발급 테스트
'TIL(Today I Learned)' 카테고리의 다른 글
TIL - Redis에 대해 알아보기(+ 명령어) (2) | 2024.05.23 |
---|---|
TIL - Swagger에서 Bearer Token 설정 (0) | 2024.05.22 |
TIL - Java 8 date/time type `java.time.LocalDateTime` not supported (0) | 2024.05.20 |
TIL - Spring Security에서 JSON으로 로그인 처리 (0) | 2024.05.17 |
TIL - Offset-based Pagination & Cursor-based Pagination (0) | 2024.05.16 |