Spring + Java JWT 적용하기

Nine-JH·2023년 11월 10일
3

Mission : JWT를 사용해보세요

팀 프로젝트의 요구사항 중 JWT를 사용하라는 조건이 있어서 간단하게 사용해보고자 합니다.


라이브러리 선택 JJWT


먼저 라이브러리를 선정해야겠죠. Java 진영에서는 여러가지 라이브러리가 존재합니다.

  • JJWT
  • Java JWT
  • Spring Security JWT Liberary

Spring Security JWT Liberary 의 경우 JWT 기술이 굳이 스프링에 종속되어야 하나? 라는 의문점과 또 릴리즈가 2020년이었기에 깔끔하게 포기했습니다.
이후 JJWTJava JWT를 고민하던 와중 JJWT의 깃허브를 들어가봤는데요

문서화가 정말 잘되어있더군요. 레퍼런스도 많았고요. 그래서 JJWT를 선택하게 되었습니다. (README를 잘쓰는게 정말 중요하구나를 다시금 깨달았습니다.)



의존성 추가

ext {
    JJWT_VERSION = "0.12.3"
}

implementation "io.jsonwebtoken:jjwt-api:${JJWT_VERSION}"
runtimeOnly "io.jsonwebtoken:jjwt-gson:${JJWT_VERSION}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${JJWT_VERSION}"

그 다음은 의존성을 추가해야 합니다. JJWT의 경우 인터페이스인 api와 low-level 모듈인 gson, impl을 나눈것을 볼 수 있었는데, 사용자 입장에서는 api와 구현부 각각의 모듈을 선택할 수 있다는 것이 클린 아키텍처를 목표로 삼고 있구나를 확인할 수 있었습니다.
이런 아키텍처의 장점중 하나는 런타임에 의존성 주입이 가능하다는 것입니다. 그렇기 때문에 runtimeOnly를 사용한다면
굳이 클래스 참조 없이 가볍게 사용이 가능합니다.



간단한 Provider Class 만들기

이후에는 간단한 JWT Provider을 만들어보겠습니다.

@Component
public class JwtProvider {

    @Value("${spring.application.name}")
    private String issuer;

    @Value("${service.jwt.access-expiration}")
    private Long accessExpiration;

    private final SecretKey secretKey;

    public JwtProvider(@Value("${service.jwt.secret-key}") String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

    public String createAccessToken(JwtPayload jwtPayload) {

        return Jwts.builder()
            .claim("email", jwtPayload)
            .issuer(issuer)
            .issuedAt(jwtPayload.issuedAt())
            .expiration(new Date(jwtPayload.issuedAt().getTime() + accessExpiration))
            .signWith(secretKey, Jwts.SIG.HS512)
            .compact();
    }

    public JwtPayload verifyToken(String jwtToken) {

        Jws<Claims> claimsJws = Jwts.parser().verifyWith(secretKey).build()
            .parseSignedClaims(jwtToken);

        return new JwtPayload(claimsJws.getPayload().get(jwtPropertiesProvider.getEmailKey(), String.class), claimsJws.getPayload().getIssuedAt());
    }
}
  • @Component : 유틸로 쓰는 방법도 있지만, JWT 생성시 필요한 필드 정보들을 동적으로 주입받기 위해 빈으로 등록하게 되었습니다.
  • claim : 토큰 payload 들어갈 key-value 쌍입니다.
  • expiration : 왜 시간을 new Date() 를 사용하지 않고 필드에서 건네받냐 에 대한 의문점이 있을 수 있는데, 테스트의 용이성을 위해서 외부 객체 파라미터로 받도록 변경했습니다. 이렇게 되면 테스트가 훨씬 쉬워지더라고요.

테스트 외부 주입

public String newDate(JwtPayload jwtPayload) {

    return Jwts.builder()
        ...
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + accessExpiration))
        .compact();
    }
    
public String getFromPayload(JwtPayload jwtPayload) {

    return Jwts.builder()
        ...
        .issuedAt(jwtPayload.issuedAt())
        .expiration(new Date(jwtPayload.issuedAt().getTime() + accessExpiration))
        .compact();
}

가령 다음과 같은 두 메서드가 있다고 해보겠습니다.
newDate() 의 경우에서는 내부적으로 시간을 생성해서 사용하고 있기 때문에 테스트 때 외부에서 이를 조작할 방법이 없습니다.
이와 반대로 getFromPayload()의 경우에는 시간을 주입받기 때문에 테스트 때 언제든지 시간을 조작할 수 있습니다! 테스트의 난이도가 확 내려가게 되는 것이죠.


Secret-Key

JJWT 가 버전업이 되면서 SecretKey 에 대한 설정이 조금 바뀌었습니다.

Creating Safe Keys
If you don't want to think about bit length requirements or just want to make your life easier, JJWT has provided convenient builder classes that can generate sufficiently secure keys for any given JWT signature algorithm you might want to use.

JJWT maintainer 측에서는 개발자들이 아무런 고민 없이 문자열 값으로 JWT의 키를 넣는것을 상당히 경계하고 있습니다. 이런 경우에는 보안 취약점이 될 수 있기 때문에 무작위 키 값을 만드는 빌더 클레스를 제공해 주고 있습니다.

SecretKey key = Jwts.SIG.HS256.key().build();

그런데 이런 경우 해당 Secret Key를 가지던 서버가 Shutdown 되면 이미 발급된 JWT에 대해 검증을 제대로 할 수 있나? 에 대한 의문이 들기 시작했습니다.
업로드중..

추가적으로 분산 서버를 생성할 때에도 여러 고려사항이 있겠죠.(sticky-server 등등..)
JWT Key 문제점 2

어떻게 보면 Stateless Token을 지향한 JWT가 Stateful 이 되어버린 것 같았죠.


그래서 키 값을 넣는 방식을 그대로 사용할겁니다!
    private final SecretKey secretKey;

    public JwtProvider(@Value("${service.jwt.secret-key}") String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

문자열을 그대로 넣는것을 권장하지 않기 때문에 JWT 에서 제공하는 암호화를 사용해야 합니다.

Note
You cannot sign JWTs with PublicKeys as this is always insecure. JJWT will reject any specified PublicKey for signing with an InvalidKeyException.

If you want to sign a JWS using HMAC-SHA algorithms, and you have a secret key String or encoded byte array, you will need to convert it into a SecretKey instance to use as the signWith method argument.

It is almost always incorrect to call any variant of secretString.getBytes in any cryptographic context.
Safe cryptographic keys are never represented as direct (unencoded) strings. If you have a password that should be represented as a Key for HMAC-SHA algorithms, it is strongly recommended to use a key derivation algorithm to derive a cryptographically-strong Key from the password, and never use the password directly.



JWT 검증

    public JwtPayload verifyToken(String jwtToken) {

        Jws<Claims> claimsJws = Jwts.parser().verifyWith(secretKey).build()
            .parseSignedClaims(jwtToken);
        Claims payload = claimsJws.getPayload();

        return new JwtPayload(payload.get(USER_KEY, String.class), payload.getIssuedAt());
    }

검증 자체는 parseSignedClaims() 내부적으로 수행합니다.

 Jws<Claims> parseSignedClaims(CharSequence jws) throws JwtException, IllegalArgumentException;

여기서는 JwtException 으로 추상화 되어있지만, Impl 모듈의 io.jsonwebtoken.impl.DefaultJwtParser 클래스를 보면 상당히 구체적으로 나와있습니다. (너무 길어서 사진으로는 첨부를 하지 않겠습니다.)

그래서 만약 JWT 예외 케이스별로 처리를 하고 싶다면 try-catch 문을 사용하시면 됩니다.

try {
    Jws<Claims> claimsJws = Jwts.parser().verifyWith(secretKey).build()
        .parseSignedClaims(jwtToken);
    Claims payload = claimsJws.getPayload();

    return new JwtPayload(payload.get(USER_KEY, String.class), payload.getIssuedAt());
} catch (SignatureException e) {
    // 비밀키 일치 X 처리
} catch (ExpiredJwtException e) {
    // 만료 exception 처리
}


테스트 코드 작성

마지막으로 테스트코드를 작성해봅시다.

발급 테스트

    @DisplayName("JWT Access Token 발급은")
    @Nested
    class Context_createAccessToken {

        @DisplayName("토큰 발급에 성공한다.")
        @Test
        void _willSuccess() {

            // given
            Date issueDate = new Date(System.currentTimeMillis());
            JwtPayload targetPayload = new JwtPayload("test@email.com", issueDate);

            // when
            String accessToken = jwtProvider.createAccessToken(targetPayload);

            // then
            Assertions.assertThat(accessToken).isNotNull();

            Jws<Claims> claimsJws = Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(accessToken);

            Assertions.assertThat(claimsJws.getPayload().getIssuer()).isEqualTo(applicationName);
            Assertions.assertThat(claimsJws.getPayload().getIssuedAt()).isEqualTo(roundOffMillis(issueDate));
            Assertions.assertThat(claimsJws.getPayload().getExpiration()).isEqualTo(new Date(issueDate.getTime() + accessExpiration));
        }

      
    /**
     * Jwts.issuedAt 의 경우 밀리초 자리수를 버리기 떄문에 해당 메서드를 사용해야 합니다.
     * @param date
     * @return
     */
    private Date roundOffMillis(Date date) {
        return new Date(date.getTime() / 1000 * 1000);
    }
}

검증 테스트

@DisplayName("JWT 검증은")
    @Nested
    class Context_verifyToken {

        @DisplayName("만료 되지 않고, Secret Key가 일치하면 성공한다.")
        @Test
        void notExpired_equalsSecretKey_willSuccess() {

            // given
            Date issueDate = new Date(System.currentTimeMillis());
            JwtPayload targetPayload = new JwtPayload("test@email.com", issueDate);

            String accessToken = Jwts.builder()
                .claim("user-key", targetPayload.email())
                .issuer(applicationName)
                .issuedAt(issueDate)
                .expiration(new Date(issueDate.getTime() + accessExpiration))
                .signWith(secretKey, SIG.HS512)
                .compact();

            // when
            JwtPayload jwtPayload = jwtProvider.verifyToken(accessToken);

            // then
            Assertions.assertThat(jwtPayload.email()).isEqualTo(targetPayload.email());
        }

        @DisplayName("SecretKey가 일치하지 않으면 실패한다.")
        @Test
        void notEqualsSecretKey_willFail() {

            // given
            Date issueDate = new Date(System.currentTimeMillis());
            JwtPayload targetPayload = new JwtPayload("test@email.com", issueDate);

            String accessToken = Jwts.builder()
                .claim("user-key", targetPayload.email())
                .issuer(applicationName)
                .issuedAt(issueDate)
                .expiration(new Date(issueDate.getTime() + accessExpiration))
                .signWith(Jwts.SIG.HS256.key().build())
                .compact();

            // when
            Assertions.assertThatThrownBy(() -> jwtProvider.verifyToken(accessToken))
                .isInstanceOf(SignatureException.class);
        }

        @DisplayName("만료가 되면 실패한다.")
        @Test
        void tokenExpired_willFail() {

            // given
            Date issueDate = new Date(System.currentTimeMillis());
            JwtPayload targetPayload = new JwtPayload("test@email.com", issueDate);

            String accessToken = Jwts.builder()
                .claim("user-key", targetPayload.email())
                .issuer(applicationName)
                .issuedAt(issueDate)
                .expiration(new Date(issueDate.getTime() - 10000))
                .signWith(secretKey, SIG.HS512)
                .compact();

            // when
            Assertions.assertThatThrownBy(() -> jwtProvider.verifyToken(accessToken))
                .isInstanceOf(ExpiredJwtException.class);
        }
    }

1개의 댓글

comment-user-thumbnail
2024년 2월 2일

해당 Secret Key를 가지던 서버가 Shutdown 되면 이미 발급된 JWT에 대해 검증을 제대로 할 수 있나?

이 부분은 미처 생각을 못 했네요. 감사합니다.

답글 달기