JWT 발급하기

Sei Kim·2024년 3월 4일
1

Backend In Action

목록 보기
6/9
post-thumbnail

들어가며


그 동안 프로젝트를 진행하면 주로 자바를 활용하여 JWT를 발급하였습니다. 이번에는 코틀린으로 JWT 토큰을 발급해 볼 것 이며 jjwt도 0.12.x로 업그레이드 되면서 수정된 문법을 익혀볼 것 입니다.

1. 실습 환경


전체 코드는 GitHub에서 확인하실 수 있습니다.

2. 라이브러리 추가


// build.gradle.kts

val jjwtVersion: String = "0.12.5"

dependencies {
	// ...

    implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion")
}

오늘자 기준(24.3.3)으로 최신 버젼은 0.12.5 입니다. 위와 같이 build.gradle.kts에 해당 내용을 추가합니다.

3. Jwt Properties


JWT 관련 환경 변수들을 가져와야 합니다. yml에서 자동완성 기능을 사용하기 위해 ConfigurationProperties을 활용합니다.

3.1. JwtProperties.kt


@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
    val secret: String,
    val accessTokenExpireTime: Long,
    val refreshTokenExpireTime: Long,
)

위와 같이 yml에서 사용하고 값을 주입을 받습니다.
기본 값이 없으므로 따로 지정하지 않는다면 컴파일 단계에서 실패하게 됩니다.

3.2. application.yml


jwt:
  secret: 7JWI64WV7Z...	// 긴 값
  access-token-expire-time: 30
  refresh-token-expire-time: 60

JWT 암호화 알고리즘으로 HS512를 사용할 것 이기 때문에 매우 긴 값이 필요합니다.
위와 같이 yml파일에 값을 넣어주도록 합니다.

현재 시간 단위는 "초(second)"입니다.

4. TokenProvider.kt


코드의 전체 부분입니다.

import com.seikim.kotlinjwt.auth.exception.AuthErrorCode
import com.seikim.kotlinjwt.auth.exception.AuthException
import com.seikim.kotlinjwt.auth.properties.JwtProperties
import com.seikim.kotlinjwt.member.domain.Member
import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import org.springframework.stereotype.Component
import java.util.*
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec

@Component
class TokenProvider(
    private val jwtProperties: JwtProperties
) {
    private val key: SecretKey by lazy {
        val keyBytes = Decoders.BASE64.decode(jwtProperties.secret)
        SecretKeySpec(
            keyBytes,
            Jwts.SIG.HS512.key().build().algorithm
        )
    }

    companion object {
        private const val BEARER_TYPE: String = "Bearer"
    }

    fun generateAccessToken(member: Member): String {
        val now: Long = Date().time

        val accessToken: String = generateToken(
            member,
            now,
            expireTime = jwtProperties.accessTokenExpireTime
        )

        return accessToken
    }

    fun generateRefreshToken(member: Member): String {
        val now: Long = Date().time

        val refreshToken: String = generateToken(
            member,
            now,
            expireTime = jwtProperties.refreshTokenExpireTime
        )

        return refreshToken
    }

    private fun generateToken(member: Member, now: Long, expireTime: Long): String {
        val token: String = Jwts.builder()
            .subject(member.email)
            .claim("memberId", member.id)
            .signWith(key)
            .expiration(Date(now + (1000L * 60 * expireTime)))
            .compact()
        return "$BEARER_TYPE $token"
    }

    fun parseToken(token: String): Int {
        validateBearer(token)
        val jws = token.replace(BEARER_TYPE, "")
            .trim()
        try {
            val claimsJws: Jws<Claims> = Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(jws)
            return claimsJws.payload["memberId"] as Int
        } catch (e: ExpiredJwtException) {
            throw AuthException(AuthErrorCode.EXPIRED_JWT)
        } catch (e: JwtException) {
            throw AuthException(AuthErrorCode.INVALID_JWT)
        }
    }

    private fun validateBearer(token: String) {
        if (token.contains(BEARER_TYPE)) {
            return
        }
        throw AuthException(AuthErrorCode.INVALID_BEARER_TYPE)
    }
}

4.1. key: SecretKey


private val key: SecretKey by lazy {
    val keyBytes = Decoders.BASE64.decode(jwtProperties.secret)
    SecretKeySpec(
        keyBytes,
        Jwts.SIG.HS512.key().build().algorithm
    )
}

JWT 0.12.x 로 넘어오면서 바뀐 부분 중 하나입니다. 이전에는 미리 정의하지 않고 알고리즘을 이후에 넣어줬지만 현재는 키를 정의할 때 미리 알고리즘을 넣어줍니다.

lazy를 사용하여 객체가 생성된 후 값이 주입되도록 작성하였습니다. 이렇게하여 yml에 정의해둔 값을 가져올 수 있습니다.

4.2. generateToken


private fun generateToken(member: Member, now: Long, expireTime: Long): String {
    val token: String = Jwts.builder()
        .subject(member.email)
        .claim("memberId", member.id)
        .signWith(key)
        .expiration(Date(now + (1000L * 60 * expireTime)))
        .compact()
    return "$BEARER_TYPE $token"
}

현재 spring security를 사용하지 않아 권한과 관련된 부분은 없습니다.

JWT 토큰을 생성하는 부분입니다. 토큰을 생성할 때 필요한 값과 현재 시간, 만료 시간을 넘겨 받아 토큰을 생성합니다.
만약 원하는 값을 더 넣으려면 claim와 같이 key-value 형식으로 넣을 수 있습니다.

4.3. parseToken


fun parseToken(token: String): Int {
    validateBearer(token)
    val jws = token.replace(BEARER_TYPE, "")
        .trim()
    try {
        val claimsJws: Jws<Claims> = Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(jws)
        return claimsJws.payload["memberId"] as Int
    } catch (e: ExpiredJwtException) {
        throw AuthException(AuthErrorCode.EXPIRED_JWT)
    } catch (e: JwtException) {
        throw AuthException(AuthErrorCode.INVALID_JWT)
    }
}

private fun validateBearer(token: String) {
    if (token.contains(BEARER_TYPE)) {
        return
    }
    throw AuthException(AuthErrorCode.INVALID_BEARER_TYPE)
}

토큰을 검증하고 원하는 값을 가져오는 부분입니다.
Bearer이 재대로 적용되어 있는지 검증하며, 만료 시간이 지났는지, 잘못된 토큰인지 파악합니다.

정리


사용자의 인증 정보를 받아 정상적인 인증이라면 로그아웃을 진행하는 컨트롤러를 실행시킨 결과 정상적으로 응답하였습니다.
JWT를 자바가 아닌 코틀린으로 발급하면서 별 다른 어려움은 없었습니다.

문법이 0.12.x 로 넘어오면서 익숙하지 않아 헷갈린 부분이 있지만 공식 문서를 찾아보며 해결을 하였습니다.

물론 아직 개인적인 아쉬움은 존재합니다.

Ref


  1. jjwt - GitHub
  2. gemini - google

0개의 댓글