{
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 버전 라이브러리를 사용한다.
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로 사용하는 것을 권장하고 있지 않아 인코딩 시켜줍니다.
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를 생성하고 직렬화합니다.
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는 추가할 필요가 없습니다.
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
getKeyFromBase64EncodedKey() 메서드는 JWT의 서명에 사용할 Secret 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());
}
}
JWT의 검증 기능은 인증된 사용자가 애플리케이션의 리소스에 접근할 때마다 request의 header에 포함된 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를 얻을 수 있습니다.
verifySignature() 메서드는 Signature를 검증하는 용도이므로 Claims를 리턴할 필요는 없습니다.
파라미터로 사용한 jws는 Signature가 포함된 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);
}
jjwt 라이브러리를 사용하여 간단하게 토큰을 생성하고 검증하는 시간을 가졌다.
간단해보이지만 프로젝트나 현업에서는 더욱 복잡하고 어려울 것이다.
또한 Refresh Token은 테스트를 하진 않았지만 간단하게
- 클라이언트가 인증 요청을 하면 서버는 요청 온 아이디/비밀번호를 확인하고 알맞은 사용자이면 Access Token과 Refresh Token을 만들어서 보내준다.
- 사용자는 리소스 요청시 헤더에 토큰을 넣어 같이 보낸다.
- 서버는 토큰을 검증하고 Access Token의 만료시간을 보고 리소스를 응답한다.
- 만약 만료시간이 지나면 서버는 Refresh Token을 사용하여 Access Token을 재발급 해준다.
- 만약 Refresh Token 또한 만료시간이 지나면 서버는 사용자에게 다시 로그인을 요청한다.
요런 흐름을 가지고있다.
아직도 공부할게 많이 남았다..