Kotlin 에서 JJWT 의 사용

Kyojun Jin·2024년 6월 13일

Spring

목록 보기
8/12

의존성 주입

implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5")

Feb 01, 2024 이후로 업데이트가 없다.

JWT 생성

JWT는 다음 작업으로 만들 수 있다.

  1. Jwts.builder() 메소드를 사용해서 JwtBuilder 인스턴스를 생성한다.
  2. 선택사항으로 header의 매개변수를 설정한다.
  3. 빌더 메소드로 payload의 content나 claims를 설정한다.
  4. 선택사항으로 signWithencryptWith 메소드를 호출해 JWT를 암호화하거나 디지털 서명을 한다.
  5. compact() 메소드로 압축된 JWT string을 만든다.

https://github.com/jwtk/jjwt
https://jwt.io/introduction

를 참고한다.

https://github.com/jwtk/jjwt?tab=readme-ov-file#jwt-header

JJWT는 필요한 헤더를 알아서 생성하기 때문에 신경 쓸 필요가 없다.

Automatic Headers: You do not need to set the alg, enc or zip headers - JJWT will always set them automatically as needed.

추가가 필요하다면 headers().add("key", value)와 같은 식으로 추가하면 된다.

payload

https://github.com/jwtk/jjwt?tab=readme-ov-file#jwt-payload

payload를 추가하는데 contentclaims의 두 가지 방식이 있다. content는 bytearray, claims은 JSON 형식으로 정보를 저장하는 방식이다.

기본적인 정보는 다음 메소드로 빌드가 가능하다.

issuer: sets the iss (Issuer) Claim

subject: sets the sub (Subject) Claim

audience: sets the aud (Audience) Claim

expiration: sets the exp (Expiration Time) Claim

notBefore: sets the nbf (Not Before) Claim

issuedAt: sets the iat (Issued At) Claim

id: sets the jti (JWT ID) Claim

예시:

String jws = Jwts.builder()

    .issuer("me")
    .subject("Bob")
    .audience().add("you").and()
    .expiration(expiration) //a java.util.Date
    .notBefore(notBefore) //a java.util.Date
    .issuedAt(new Date()) // for example, now
    .id(UUID.randomUUID().toString()) //just an example id

    /// ... etc ...

signature

https://github.com/jwtk/jjwt?tab=readme-ov-file#signed-jwts

서명에 필요한 secret key를 생성하는 데에는 두 가지 방법이 있다. (public, private 의 비대칭 키로 서명할 수도 있다.)
JJWT는 SecretKey 라는 객체로 이를 관리한다.

  1. 서버에서 바로 생성
SecretKey key = Jwts.SIG.HS256.key().build(); //or HS384.key() or HS512.key()

HMAC-SHA 알고리즘을 사용하여 서명을 할 때 사용될 secret key 를 생성한다.
이때 HMAC-SHA 알고리즘은 서명 후 생성되는 digest의 크기에 따라 256, 384, 512(bits)로 나뉜다.
256을 사용한다면 키는 최소 32bytes 가 넘어야 한다. (256/4 = 32)

  1. 시크릿 키 문자열을 서버에 저장 후, 이로써 SecretKey 객체를 생성

키 문자열을 따로 지정하거나, 보안성을 위해 openssh 를 사용하여 랜덤 문자열을 얻는다.

openssh rand -base64 256를 통해 서명을 할 키 문자열을 생성할 수 있다.

파일이나 환경변수에서 키 문자열을 읽어오거나 byte array를 읽어서 secretString 따위의 변수에 저장하고,
다음 코드로 SecretKey를 생성한다.

val secretKey: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString))

header, payload, signature 모두를 포함하여 JWT 토큰을 생성하는 과정은 다음과 같다.

Jwts.builder()
	// header를 추가
	.header()
    .add("key1", "value1")
    .add("key2", "value2")
    // 혹은
    .add(headerMap)
    .and()  // 다시 builder 로 돌아온다.
    
    // payload 추가
    .content(bytearray, "Content-Type") // 컨텐트 유형 명시
    // 혹은
    .claim("key1", value1)
    .claim("Key2", value2)
    // 혹은
    .claims(contentMap)
    // 부가정보 추가
    .issuer("me")
    .subject("Bob")
    .audience().add("you").and()
    .expiration(expiration) //a java.util.Date
    .notBefore(notBefore) //a java.util.Date
    .issuedAt(new Date()) // for example, now
    .id(UUID.randomUUID().toString()) //just an example id

	// 서명
    .signWith(secretKey)
    .compact()

expiration과 notBefore가 Date 인스턴스임에 주의한다.

JWT 읽기

try {
	val jwt = Jwts.parser()
    .verifyWith(secretKey)   // private key로 서명했다면 여기선 public key 로 인증한다.
    .build()
    .parseSignedClaims(token)
    
    val header = jwt.header
    val content = jwt.payload
}
catch (e: JwtExeption) {
	logger.error("Error!")
}

JWT Configuration

내가 구현한 방법은 이러하다.
properties에 Jwt에 관한 변수를 적고 이를 Spring에 임포트 한다.
유효 기간에 관한 변수는 Enum으로 관리한다.

JWT 유효 기간

enum class TimeUnit(private val type: String, private val duration: Long) {
    YEAR("year", 365),
    DAY("day", 365),
    HOUR("hour", 24),
    MINUTE("minute", 60),
    SECOND("second", 60),
    MILLISECOND("millisecond", 1000);

    fun getLifetimeInMilli(duration: Long): Long {
        var lifetimeInMilli = duration

        for (i in ordinal + 1 ..< entries.size) {
            lifetimeInMilli *= TimeUnit.entries[i].duration
        }
        
        return lifetimeInMilli
    }
}

위와 같이 이넘 클래스를 정의하였다.

val lifetimeType = TimeUnit.HOUR
lifetimeType.getLifetimeByMilli(12) 

위 작업으로 쉽게 12시간에 대한 Millisecond를 구할 수 있다.
문자열로 정의된 환경변수에 따라, 유효시간은 서버가 열릴 때 (JWT Util 클래스가 로드될 때) 한번 정의되고 만료시간은 토큰이 생성될 때마다 유효시간을 사용해 계산돼야 하므로 Calendar 를 이용하거나 when 문을 사용하기 보다 millisecond를 구하는 것이 편하다고 생각했다.

환경 변수 import

password.yaml 에 아래 사항을 적고 application.yaml에 임포트 한다.

application.yaml

spring:
    config.import: classpath:password.yaml

password.yaml

jwt:
	secret: "서명 알고리즘의 최소 길이를 만족하는 secret key" 
    lifetime:
    	type: HOUR
        duration: 12

gitignore를 사용해서 password.yaml이 어딘가에 업로드 되지 않도록 주의한다.

다음으로, 해당 환경변수를 임포트 해준다.
@Value는 Kotlin의 Spring Boot에선 먹히지 않는 것 같다. 현재 버전에서 아무리 해도 인식되지 않는다.
https://docs.spring.io/spring-framework/reference/languages/kotlin/spring-projects-in.html#injecting-configuration-properties
공식 문서에서도 @ConfigurationProperties를 사용할 것을 권하고 있다.

@Configuration
@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
    var secret: String = "",
    var lifetime: Lifetime = Lifetime()
) {
    data class Lifetime (
        var type: String = "",
        var duration: Long = 0
    )
}

이후 main 클래스에 @ConfigurationPropertiesScan 어노테이션을 달면 된다.
@EnableConfigurationProperties(프로퍼티클래스이름::class)를 해도 된다. Scan을 쓰는 이유는 나중에 프로퍼티 클래스가 많아질 때를 대비해서이다. 성능 차이는 모르겠다.

이후 JWT Util을 구현하여 생성자로 넣어준다. Main에서 어노테이션을 달아주면서 Bean이 등록되었을 것이다.

@Component
class JwtUtil(jwtProperties: JwtProperties) {
    private final val secretString: String = jwtProperties.secret
    private final val secretKey: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString))
    private final var lifetime = 0L

    init {
        val lifetimeType = TimeUnit.valueOf(jwtProperties.lifetime.type)
        lifetime = lifetimeType.getLifetimeByMilli(jwtProperties.lifetime.duration)
    }
    
    fun createToken() {
    	// ...
        val now = Date()
        val currentTime = now.time
        val expiration = Date(currentTime + lifetime)
        // ...
        
        val token = Jwts.builder()
        	.issuedAt(now)
            .expiration(expiration)
            .
    }
}

0개의 댓글