ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kotlin으로 Jwt 개발하기
    Kotlin 2023. 5. 14. 00:01
    728x90

    개요

    이전에 Java를 통해 jjwt0.9.1 라이브러리로 jwt를 구현했던 내용이 담겨있으며, jwt에 대한 기본적인 개념들이 정리되어 있는 글입니다.

    https://junuuu.tistory.com/307

     

    JWT란? JWT 원리, 사용법

    JWT란? JWT란 Json Web Token의 약자로써 Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token입니다. Claim이란 사용자 정보나 데이터 속성 등을 의미합니다. 즉, Claim 토큰이라 하면

    junuuu.tistory.com

     

    하지만 Kotlin을 기준으로 작성해보고자 했을 때 다음과 같은 에러가 발생했습니다.

    java.lang.UnsupportedOperationException
    	at java.base/java.util.AbstractMap.put(AbstractMap.java:209)
    	at io.jsonwebtoken.impl.JwtMap.setDate(JwtMap.java:86)
    	at io.jsonwebtoken.impl.DefaultClaims.setIssuedAt(DefaultClaims.java:96)
    	at io.jsonwebtoken.impl.DefaultJwtBuilder.setIssuedAt(DefaultJwtBuilder.java:215)

    바로 Date객체가 불변이어서 put 메서드를 지원하지 않았기 때문입니다.

     

    이에 따라 11 버전을 사용하여 해결하였습니다.

     

    의존성 추가

      //jwt
      implementation("io.jsonwebtoken:jjwt-api:0.11.2")
      runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
      runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")

     

    JWTUtil 작성

    import com.fasterxml.jackson.databind.ObjectMapper
    import com.fasterxml.jackson.databind.SerializationFeature
    import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
    import java.util.*
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SignatureAlgorithm
    import io.jsonwebtoken.security.Keys
    import java.nio.charset.StandardCharsets
    import java.time.LocalDateTime
    import java.time.ZoneId
    
    object JWTUtil {
    
      //1000 밀리세컨트= 1초, 60초 = 1분, 60분 = 1시간, 24시간 = 하루
      private const val ONE_DAY = 1000L * 60L * 60L * 24L
      const val EXPIRATION_TIME = ONE_DAY
      private const val SECRET_KEY = "your-secret-key"
      private val signingKey = Keys.hmacShaKeyFor(SECRET_KEY.toByteArray(StandardCharsets.UTF_8))
        ?: throw IllegalStateException("Token을 발급하기 위한 Key가 적절하게 생성되지 않음")
    
      fun generateToken(
        userId: String,
        expirationInMillisecond: Long = EXPIRATION_TIME
      ): String {
        val now = Date()
        val expiration = Date(now.time + expirationInMillisecond)
        val claims = generateClaims(now, expiration)
        return Jwts.builder()
          .setClaims(claims)
          .setSubject(userId)
          .setIssuedAt(now)
          .setExpiration(expiration)
          .signWith(signingKey, SignatureAlgorithm.HS256)
          .compact()
      }
    
      /**
       * LocalDateTime을 직렬화 하기 위해서는 JavaTimeModule이 등록되어 있어야 합니다.
       * SerializationFeature.WRITE_DATES_AS_TIMESTAMPS 설정을 통해 직렬화를 보기 쉽게 만듭니다.
       * */
      private fun generateClaims(now: Date, expiration: Date): Map<String, String> {
        val nowLocalDateTime = LocalDateTime.ofInstant(now.toInstant(), ZoneId.systemDefault())
        val expirationLocalDateTime = LocalDateTime.ofInstant(expiration.toInstant(), ZoneId.systemDefault())
    
        val mapper = ObjectMapper()
        mapper.registerModule(JavaTimeModule())
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    
        return mapOf(
          "issuedAt" to mapper.writeValueAsString(nowLocalDateTime),
          "expiredAt" to mapper.writeValueAsString(expirationLocalDateTime),
        )
      }
    
      fun getUserIdFromToken(token: String): String {
        return Jwts.parserBuilder()
          .setSigningKey(signingKey)
          .build()
          .parseClaimsJws(token)
          .body
          .subject
      }
    }

    싱글톤을 위해 Object로 작성하였습니다.

    토큰을 생성하고 토큰으로부터 정보를 가져오는 메서드 2개를 작성했습니다.

    userId를 받아 subject정보로 구성하며 HS256 알고리즘을 통해 시크릿키와 함께 사이닝 합니다.

    Claims에는 토큰의 발행일과 만료일을 넣어줍니다.

    만료기간은 기본값으로 하루로 잡았으며 만료 테스트를 위해 변수로도 넣어주었습니다.

     

    테스트 작성

    import io.jsonwebtoken.ExpiredJwtException
    import io.jsonwebtoken.MalformedJwtException
    import org.junit.jupiter.api.Assertions.*
    import org.junit.jupiter.api.Test
    
    internal class JWTUtilTest{
    
     @Test
      fun `Jwt Token 발행과 검증시 subject에 userId가 포함되어 있어야 한다`(){
        //given
        val userId = "myUserId"
        val expireTime = JWTUtil.EXPIRATION_TIME
    
        //when
        val result = JWTUtil.generateToken(userId, expireTime)
        val userIdFromToken = JWTUtil.getUserIdFromToken(result)
    
        //then
        assertNotNull(result)
        assertEquals(userId, userIdFromToken)
      }
    
      @Test
      fun `유효하지 않은 토큰 Jwt Token 검증에 실패하여 MalformedJwtException을 반환한다`(){
        //given
        val randomToken = "a.b.c"
    
        //when & then
        assertThrows(MalformedJwtException::class.java){
          JWTUtil.getUserIdFromToken(randomToken)
        }
      }
    
      @Test
      fun `만료기간이 지난 토큰은 검증시 ExpiredJwtException을 반환한다`(){
        //given
        val userId = "myUserId"
        val expireTime = 1L
        val result = JWTUtil.generateToken(userId, expireTime)
    
        //when
        Thread.sleep(10L)
    
        //then
        assertThrows(ExpiredJwtException::class.java){
          JWTUtil.getUserIdFromToken(result)
        }
      }
    }

    토큰이 제대로 만들어졌는지, claim에는 정보가 잘 들어있는지 검증에 실패하는 경우는 무엇인지에 대해 테스트합니다.

     

    이슈 발생 subject가 Null Error

    분명히 위에서 setSubject를 수행했습니다.

    하지만 반영되지 않아 null으로 나오는 에러가 발생했습니다.

     

    원인은 다음과 같습니다.

    https://github.com/jwtk/jjwt/issues/179

     

    calling setClaims() after setSubject() will cause NullPointerException · Issue #179 · jwtk/jjwt

    Executing the below program will throw NullPointerException at line 38 Code package foo.bar; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; i...

    github.com

    jjwt에 대한 github issue를 찾게 되었습니다.

    이미 close 된 이슈이며 setClaims가 먼저 호출되고 setSubject가 그 뒤에 호출되도록 수정되어야 합니다.

    사유는 setClaims는 전체 payload부분을 완전히 대체해서 들어가기 때문입니다.

    .setClaims(claims)
    .setSubject(userId)

     

    'Kotlin' 카테고리의 다른 글

    상태패턴과 전략패턴의 차이는 무엇일까?  (1) 2023.10.17
    Kotlin Value Class란?  (0) 2023.09.01
    Mapstrcut 변환시 함수 호출하기  (0) 2023.05.22

    댓글

Designed by Tistory.