TIL(Today I Learned)

TIL - JWT Token 생성 및 검증(Access Token, Refresh Token)

Happy._. 2024. 5. 21. 19:01

JWT

https://jwt.io/

  • 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 발급 테스트