JWT 생성 및 검증 테스트

김준영·2023년 5월 15일
2

Code States

목록 보기
26/33
post-thumbnail
post-custom-banner

JWT 생성


Dependencies

{
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly	'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

jjwt 0.11.5 버전 라이브러리를 사용한다.

JwtTokenizer

encodeBase64SecretKey

public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}

Plain Text인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩 해줍니다.

jjwt가 버전업 되면서 Plain Text 자체를 Secret Key로 사용하는 것을 권장하고 있지 않아 인코딩 시켜줍니다.

generateAccessToken

public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();

    }

generateAccessToken()은 인증된 사용자에게 JWT를 최초로 발급해 주기 위한 JWT 생성 메서드입니다.

Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

Base64 형식 Secret Key 문자열을 이용해 Key(java.security.Key)객체를 얻습니다.

return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
  • setClaims()에는 JWT에 포함시킬 Custom Claims를 추가합니다. Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가합니다.

  • setSubject()에는 JWT에 대한 제목을 추가합니다.

  • setIssuedAt()에는 JWT 발행 일자를 설정하는데 파라미터 타입은 java.util.Date 타입입니다.

  • setExpiration()에는 JWT의 만료일시를 지정합니다. 파라미터 타입은 역시 ]java.util.Date 타입입니다.

  • signWith()에 서명을 위한 Key(java.security.Key) 객체를 설정합니다.

  • compact()를 통해 JWT를 생성하고 직렬화합니다.

generateRefreshToken

public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

generateRefreshToken() 메서드는 Access Token이 만료되었을 경우, Access Token을 새로 생성할 수 있게 해주는 Refresh Token을 생성하는 메서드입니다.

Refresh Token의 경우 Access Token을 새로 발급해 주는 역할을 하는 Token이기 때문에 별도의 Custom Claims는 추가할 필요가 없습니다.

getKeyFromBase64EncodedKey

private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);

        return Keys.hmacShaKeyFor(keyBytes);
    }

getKeyFromBase64EncodedKey() 메서드는 JWT의 서명에 사용할 Secret Key를 생성해 줍니다.

  • Decoder.BASE64.decode() 메서드는 Base64 형식으로 인코딩 된 Secret Key를 디코딩한 후, byte array를 반환합니다.
  • Keys.hmacShaKeyFor() 메서드는 key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성합니다.

jjwt 0.9.x 버전에서는 서명 과정에서 HMAC 알고리즘을 직접 지정해야 했지만 최신 버전에서는 내부적으로 적절한 HMAC 알고리즘을 지정해 줍니다.

전체 코드

package com.example.jwttest.auth;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;

public class JwtTokenizer {

    public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();

    }

    public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);

        return Keys.hmacShaKeyFor(keyBytes);
    }
}

생성 테스트


전체 코드

package com.example.jwttest.auth;

import io.jsonwebtoken.io.Decoders;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.*;

import static org.junit.jupiter.api.Assertions.*;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class JwtTokenizerTest {

    private static JwtTokenizer jwtTokenizer;
    private String secretKey;
    private String base64EncodedSecretKey;

    @BeforeAll
    public void init(){
        jwtTokenizer = new JwtTokenizer();
        secretKey = "jun12312412412312312312412312312421412";
        base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
    }

    @Test
    public void encodeBase64SecretKeyTest(){
        System.out.println(base64EncodedSecretKey);
        assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
    }

    @Test
    public void generateAccessTokenTest(){
        Map<String, Object> claims = new HashMap<>();
        claims.put("memberId", 1);
        claims.put("roles", List.of("USER"));

        String subject = "test access token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, 10);
        Date expiration = calendar.getTime();

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        System.out.println(accessToken);

        assertThat(accessToken, notNullValue());
    }

    @Test
    public void generateRefreshTokenTest(){
        String subject = "test refresh token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.HOUR, 24);
        Date expiration = calendar.getTime();

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        System.out.println(refreshToken);

        assertThat(refreshToken, notNullValue());
    }

}
  • 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩한 후, 인코딩 된 Secret Key를 각 테스트 케이스에서 사용합니다.
  • Plain Text인 Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행되는지 테스트합니다.
  • JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트합니다.
    Jwt는 생성할 때마다 그 값이 바뀌기 때문에 우선 생성된 Access Token이 null이 아닌지 여부만 테스트하고 있습니다.
    생성 과정에서 Exception이 발생하지 않았기 때문에 정상적으로 생성이 되었다고 봐도 무방하며, 더 정확한 테스트는 JWT의 서명 검증에서 확인할 수 있습니다.
  • JwtTokenizer가 Refresh Token을 정상적으로 생성하는지 테스트합니다.
    Custom Claims가 필요하지 않다는 것 외에는 Access Token과 테스트 과정은 동일합니다.

JWT의 검증 기능은 인증된 사용자가 애플리케이션의 리소스에 접근할 때마다 request의 header에 포함된 JWT를 검증할 때 사용됩니다.

JWT 검증 기능 구현


public void verifySignature(String jws, String base64EncodedSecretKey){
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
    }

JwtTokenizer 클래스에 JWT 검증을 위한 verifySignature() 메서드를 추가했습니다.

JWT는 JWT에 포함된 Signature를 검증함으로써 JWT의 위/변조 여부를 확인할 수 있습니다.

jjwt에서는 JWT를 생성할 때 서명에 사용된 Secret Key를 이용해 내부적으로 Signature를 검증한 후, 검즈에 성공하면 JWT를 파싱해서 Claims를 얻을 수 있습니다.

  • setSigningKey() 메서드로 서명에 사용된 Secret Key를 설정합니다.
  • parseClaimsJws() 메서드로 JWT를 파싱해서 Claims를 얻습니다.

verifySignature() 메서드는 Signature를 검증하는 용도이므로 Claims를 리턴할 필요는 없습니다.

파라미터로 사용한 jws는 Signature가 포함된 JWT라는 의미입니다.

JWT 검증 기능 테스트


@DisplayName("does not throw any Exception when jws verify")
    @Test
    public void verifySignatureTest(){
        String accessToken = getAccessToken(Calendar.MINUTE, 10);
        assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
    }

    @DisplayName("throw ExpiredJwtException when jws verify")
    @Test
    public void verifyExpirationTest() throws InterruptedException {
        String accessToken = getAccessToken(Calendar.SECOND, 1);
        assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));

        TimeUnit.MILLISECONDS.sleep(1500);

        assertThrows(ExpiredJwtException.class, () -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
    }

    private String getAccessToken(int timeUnit, int timeAmount) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("memberId", 1);
        claims.put("roles", List.of("USER"));

        String subject = "test access token";
        Calendar calendar = Calendar.getInstance();
        calendar.add(timeUnit, timeAmount);
        Date expiration = calendar.getTime();

        return jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
    }
  • 구현한 JwtTokenizer의 verifySignature() 메서드가 Signature를 잘 검증하는지 테스트합니다.
    • 생성된 JWT를 verifySignature()로 전달해서 Exception이 발생하지 않는다면 Signature에 대한 검증이 잘 수행된 것으로 볼 수 있습니다.
  • JWT 생성 시 지정한 만료일시가 지나면 JWT가 정말 만료되는지를 테스트합니다.
    • 생성되는 JWT의 만료 주기를 아주 짧게 준 후에 첫 번째 Signature 검증을 수행사고, 만료일시가 지나도록 지연시간을 준 뒤, 두 번째 Signature 검증을 수행했을 경우 ExpiredJwtException이 발생하면 JWT가 정상적으로 만료된다고 볼 수 있습니다.

마지막 코멘트


jjwt 라이브러리를 사용하여 간단하게 토큰을 생성하고 검증하는 시간을 가졌다.

간단해보이지만 프로젝트나 현업에서는 더욱 복잡하고 어려울 것이다.

또한 Refresh Token은 테스트를 하진 않았지만 간단하게

  1. 클라이언트가 인증 요청을 하면 서버는 요청 온 아이디/비밀번호를 확인하고 알맞은 사용자이면 Access Token과 Refresh Token을 만들어서 보내준다.
  2. 사용자는 리소스 요청시 헤더에 토큰을 넣어 같이 보낸다.
  3. 서버는 토큰을 검증하고 Access Token의 만료시간을 보고 리소스를 응답한다.
  4. 만약 만료시간이 지나면 서버는 Refresh Token을 사용하여 Access Token을 재발급 해준다.
  5. 만약 Refresh Token 또한 만료시간이 지나면 서버는 사용자에게 다시 로그인을 요청한다.

요런 흐름을 가지고있다.
아직도 공부할게 많이 남았다..

profile
ㅎㅎ
post-custom-banner

0개의 댓글