[Spring] Security JWT 생성 및 검증 테스트
JWT(JSON Web Token)는 데이터를 안전하고 간결하게 전송하기 위해 고안된 인터넷 표준 인증 방식으로써 토큰 인증 방식에서 가장 범용적으로 사용되며 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩된 토큰 정보를 Secret Key로 서명한 메시지를 Web Token으로써 인증 과정에 사용한다.
build.gradle (의존 라이브러리 추가)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// JWT 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
JWT를 위한 대표적인 라이브러리는 jjwt와 java JWT가 있는데 JAVA 진영에서 가장 많이 사용되는 jjwt를 사용하겠다.
JWT 생성 기능 구현
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.Date;
import java.util.Map;
public class JwtTokenizer {
// Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩 해준다.
// Plain Text 자체를 Secret Key로 사용하는 것을 권장하지 않는다.
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
// generateAccessToken()은 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성 메서드
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); // Base64형식 Secret Key 문자열을 이용해 Key객체를 얻음
return Jwts.builder()
.setClaims(claims) // JWT에 포함 시킬 Custom Claims를 추가함 (Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가)
.setSubject(subject) // JWT에 대한 제목을 추가
.setIssuedAt(Calendar.getInstance().getTime()) // JWT 발행 일자를 설정, 파라미터 타입은 java.util.Date 타입
.setExpiration(expiration) // JWT의 만료일시를 지정
.signWith(key) // 서명을 위한 Key 객체를 설정
.compact(); // JWT를 생성하고 직렬화함
}
// Access Token이 만료되었을 경우, 새로 생성할 수 있게 해주는 Refresh Token을 생성하는 메서드
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();
}
...
...
// JWT의 서명에 사용할 Secret Key를 생성해줌
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // Base64 형식으로 인코딩된 Secret Key를 디코딩한 후, byte array를 반환
Key key = Keys.hmacShaKeyFor(keyBytes); // key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key 객체를 생성
return key;
}
}
jjwt 0.9.x 버전에서는 서명 과정에서 HMAC 알고리즘을 직접 지정해야 했지만 최신 버전에서는 내부적으로 적절한 HMAC 알고리즘을 지정해 준다.
JWT 생성 기능 테스트
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.util.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
// TestInstane는 테스트 인스턴스의 라이프 사이클을 설정할 때 사용
// PER_METHOD: test 함수 당 인스턴스가 생성
// PER_CLASS: test 클래스 당 인스턴스가 생성
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JwtTokenizerTest {
private static JwtTokenizer jwtTokenizer;
private String secretKey;
private String base64EncodedSecretKey;
// 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩한 후, 인코딩된 Secret Key를 각 테스트 케이스에서 사용
@BeforeAll
public void init() {
jwtTokenizer = new JwtTokenizer();
secretKey = "kevin1234123412341234123412341234"; // encoded "a2V2aW4xMjM0MTIzNDEyMzQxMjM0MTIzNDEyMzQxMjM0"
base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
}
// Plain Text인 Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행되는지 테스트
@Test
public void encodeBase64SecretKeyTest() {
System.out.println(base64EncodedSecretKey);
assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
}
// JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트
@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());
}
// Jwt Tokenizer가 Refresh Token을 정상적으로 생성하는지 테스트
@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());
}
}
JWT 검증 기능 구현
JWT에 포함된 Signature를 검증함으로써 JWT의 위/변조 여부를 확인
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
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 void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key) // 서명에 사용된 Secret Key를 설정
.build()
.parseClaimsJws(jws); // JWT를 파싱해서 Claims를 얻음
}
...
...
}
JWT 검증 기능 테스트
import io.jsonwebtoken.ExpiredJwtException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JwtTokenizerTest {
private static JwtTokenizer jwtTokenizer;
private String secretKey;
private String base64EncodedSecretKey;
...
...
// JwtTokenizer의 verifySignature() 메서드가 Signature를 잘 검증하는지 테스트
@DisplayName("does not throw any Exception when jws verify")
@Test
public void verifySignatureTest() {
String accessToken = getAccessToken(Calendar.MINUTE, 10);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
// JWT 생성 시 지정한 만료일시가 지나면 JWT가 정말 만료되는지 테스트
@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();
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
}
댓글남기기