세션 기반 | 토큰 기반 | |
---|---|---|
서버 관리 여부 | o | x |
네트워크 트래픽 | 적음 | 많음 |
보안성 | 유리 | 불리(서버에서 관리x) |
확장성 | 불리(세션 불일치 발생 가능) | 유리 |
적합한 방식 | SSR | CSR |
JSON Web Token의 약자로 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용하는 토큰 인증 방식이다. JWT는 보통 액세스 토큰(Access Token) 과 리프레시 토큰(Refresh Token) 두 가지 중류가 사용된다.
헤더, 페이로드, 서명 세 가지 정보를 base64로 인코딩한 값을 콤마(.)를 사이에 두고 이어붙인 형태로 생성된다.
클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보낸다.
아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 암호화 된 토큰을 생성한다. 이 때, Access Token과 Refresh Token을 모두 생성한다.
토큰을 클라이언트에게 전송하면, 클라이언트는 토큰을 저장한다. 저장하는 위치는 Local Storage, Session Storage, Cookie 등이 될 수 있다.
클라이언트가 HTTP Header(Authorization Header) 또는 쿠키에 토큰을 담아 request를 전송한다.
서버는 토큰을 검증하여 발급한 토큰이 맞을 경우, 클라이언트의 요청을 처리한 후 응답을 보내준다.
의존 라이브러리
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 생성
mport 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 형식의 문자열로 인코딩
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
// 인증된 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
//Base64 형식 Secret Key 문자열을 이용해 Key 객체를 얻음
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims) // 인증된 사용자의 정보
.setSubject(subject) // JWT 제목
.setIssuedAt(Calendar.getInstance().getTime()) // 발행일자
.setExpiration(expiration) // 만료일시
.signWith(key) // 서명을 위한 Key 객체 생성
.compact(); // JWT 생성 후 직렬화
}
// Refresh 토큰 생성
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);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
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;
@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);
}
// 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());
}
// 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 검증
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;
// 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;
}
}