JWT 복습
JWT(JSON Web Token)
JWT는 데이터를 안전하고 간결하게 전송하기 위해 고안된 인터넷 표준 인증 방식으로써 토큰 인증 방식에서 가장 범용적으로 사용되며 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용합니다.
JWT 공식 사이트 : https://jwt.io/
JWT의 종류
JWT는 보통 다음과 같이 두 가지 종류의 토큰을 사용자의 자격 증명에 이용합니다.
- 액세스 토큰(Access Token)
- 리프레시 토큰(Refresh Token)
JWT 구조
- Header
이 JSON 객체를 base64 방식으로 인코딩하면 JWT의 첫 번째 부분이 완성됩니다. Header는 이것이 어떤 종류의 토큰인지(지금의 경우엔 JWT), 어떤 알고리즘으로 Sign할지 정의합니다. JSON Web Token이라는 이름에 걸맞게 JSON 포맷 형태로 정의한다는 것을 기억하세요.{ "alg": "HS256", "typ": "JWT" }
- Payload는 어떤 정보에 접근 가능한지에 대한 권한을 담을 수도 있고, 사용자의 이름 등 필요한 데이터를 담을 수 있습니다.
첫 번째 부분과 마찬가지로, 위 JSON 객체를 base64로 인코딩하면 JWT의 두 번째 블록이 완성됩니다. Payload는 다음으로 설명할 Signature를 통해 유효성이 검증될 정보이긴 하지만, 민감한 정보는 담지 않는 것이 좋습니다. Payload에는 서버에서 활용할 수 있는 사용자의 정보가 담겨 있습니다.{ "sub": "someInformation", "name": "phillip", "iat": 151623391 }
- Signature는 이렇게 암호화된 메시지는 토큰의 위변조 유무를 검증하는 데 사용됩니다. 예를 들어, 만약 HMAC SHA256 알고리즘(암호화 방법 중 하나)을 사용한다면 Signature는 아래와 같은 방식으로 생성됩니다. base64로 인코딩 된 첫 번째, 그리고 두 번째 부분이 완성되었다면, Signature에서는 원하는 비밀 키(Secret Key)와 Header에서 지정한 알고리즘을 사용하여 Header와 Payload에 대해서 단방향 암호화를 수행합니다.
HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
JWT를 통한 인증의 장점
- 상태를 유지하지 않고(Stateless), 확장에 용이한(Scalable) 애플리케이션을 구현하기 용이합니다.
- 서버는 클라이언트에 대한 정보를 저장할 필요 없습니다. (토큰이 정상적으로 검증되는지만 판단합니다)
- 클라이언트는 request를 전송할 때마다 토큰을 헤더에 포함시키면 됩니다.
- 여러 대의 서버를 이용한 서비스라면 하나의 토큰으로 여러 서버에서 인증이 가능하기 때문에 JWT를 사용하는 것이 효과적입니다.
- 클라이언트가 request를 전송할 때마다 자격 증명 정보를 전송할 필요가 없습니다.
- HTTP Basic 같은 인증 방식은 request를 전송할 때마다 자격 증명 정보를 포함해야 하지만 JWT의 경우 토큰이 만료되기 전까지는 한 번의 인증만 수행하면 됩니다.
- 인증을 담당하는 시스템을 다른 플랫폼으로 분리하는 것이 용이합니다.
- 사용자의 자격 증명 정보를 직접 관리하지 않고, Github, Google 등의 다른 플랫폼의 자격 증명 정보로 인증하는 것이 가능합니다.
- 토큰 생성용 서버를 만들거나, 다른 회사에서 토큰 관련 작업을 맡기는 것 등 다양한 활용이 가능합니다.
- 권한 부여에 용이하다
- 토큰의 Payload(내용물) 안에 해당 사용자의 권한 정보를 포함하는 것이 용이합니다.
JWT를 통한 인증의 단점
- Payload는 디코딩이 용이합니다.
- Payload는 base64로 인코딩 되기 때문에 토큰을 탈취하여 Payload를 디코딩하면 토큰 생성 시 저장한 데이터를 확인할 수 있습니다. 따라서 Payload에는 민감한 정보를 포함하지 않아야 합니다.
- 토큰의 길이가 길어지면 네트워크에 부하를 줄 수 있습니다.
- 토큰에 저장하는 정보의 양이 많아질수록 토큰의 길이는 길어집니다.
- 따라서 request를 전송할 때마다 길이가 긴 토큰을 함께 전송하면 네트워크에 부하를 줄 수 있습니다.
- 토큰은 자동으로 삭제되지 않습니다.
- 즉 한 번 생성된 토큰은 자동으로 삭제되지 않기 때문에 토큰 만료 시간을 반드시 추가해야 합니다.
- 또한 토큰이 탈취된 경우 토큰의 기한이 만료될 때까지 토큰 탈취자가 해당 토큰을 정상적으로 이용할 수 있으므로 만료 시간을 너무 길게 설정하지 않아야 합니다.
JWT 생성 및 검증 테스트를 위한 프로젝트 설정
JWT 생성 및 검증 테스트를 위해서 먼저 다음과 같이 프로젝트 설정을 진행하면 됩니다.
설정은 어렵지 않습니다. 여러분들이 이미 모두 다 알고 있는 방식입니다.
- Spring Boot 기반의 템플릿 프로젝트 생성 및 의존 라이브러리 추가
JWT 생성 및 검증 테스트 자체는 Spring Framework와 상관없습니다.
다만 우리가 이후 학습에서 Spring MVC 기반의 애플리케이션에 Spring Security를 적용하고 인증 프로세스에 JWT를 이용할 것이므로 아래의 spring-starter를 포함한 Spring Boot 기반의 템플릿 프로젝트를 생성하겠습니다.
(Spring Boot 기반의 템플릿 프로젝트 생성은 Spring Boot Initializr를 이용하면 됩니다.)
dependencies {
// (1)
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'
// (2)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
코드 4-55는 JWT 생성 및 검증 테스트를 위해 템플릿 프로젝트에 필요한 의존 라이브러리입니다.
(1)은 이후 학습에서 필요한 Spring Framework와 관계된 라이브러리이며, 지금은 사용되지 않습니다.
(2)는 여러분들이 JWT 생성 및 검증 테스트를 수행하기 위해 필요한 JWT 라이브러리입니다.
JWT를 위한 대표적인 라이브러리에는 jjwt와 Java JWT가 있는데 이번 챕터에서는 Java 진영에서 가장 많이 사용되는 jjwt를 사용하겠습니다.
JWT 생성
✅ JWT 생성 기능 구현
먼저 JWT(JSON Web Token)를 생성하고 검증하는 역할을 수행하는 클래스를 정의하고, JWT 생성 메서드를 추가해 보겠습니다.
package com.springboot.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.Date;
import java.util.Map;
public class JwtTokenizer {
// (1)
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
// (2)
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); // (2-1)
return Jwts.builder()
.setClaims(claims) // (2-2)
.setSubject(subject) // (2-3)
.setIssuedAt(Calendar.getInstance().getTime()) // (2-4)
.setExpiration(expiration) // (2-5)
.signWith(key) // (2-6)
.compact(); // (2-7)
}
// (3)
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();
}
...
...
// (4)
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // (4-1)
Key key = Keys.hmacShaKeyFor(keyBytes); // (4-2)
return key;
}
}
코드 4-56은 JWT 생성을 위한 JwtTokenizer 클래스의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- (1)의 encodeBase64SecretKey() 메서드는 Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩해줍니다.
- jjwt가 버전업 되면서 Plain Text 자체를 Secret Key로 사용하는 것은 암호학(cryptographic)적인 작업에 사용되는 Key가 항상 바이너리(byte array)라는 사실과 맞지 않는 것을 감안하여 Plain Text 자체를 Secret Key로 사용하는 것을 권장하지 않고 있습니다.
- (2)의 generateAccessToken()은 인증된 사용자에게 JWT를 최초로 발급해 주기 위한 JWT 생성 메서드입니다.
- (2-1)에서는 Base64 형식 Secret Key 문자열을 이용해 Key(java.security.Key) 객체를 얻습니다.
- (2-2)의 setClaims()에는 JWT에 포함시킬 Custom Claims를 추가합니다. Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가합니다.
- (2-3)의 setSubject()에는 JWT에 대한 제목을 추가합니다.
- (2-4)의 setIssuedAt()에는 JWT 발행 일자를 설정하는데 파라미터 타입은 java.util.Date 타입입니다.
- (2-5)의 setExpiration() 에는 JWT의 만료일시를 지정합니다. 파라미터 타입은 역시 java.util.Date 타입입니다.
- (2-6)의 signWith()에 서명을 위한 Key(java.security.Key) 객체를 설정합니다.
- (2-7)의 compact()를 통해 JWT를 생성하고 직렬화합니다.
- (3)의 generateRefreshToken() 메서드는 Access Token이 만료되었을 경우, Access Token을 새로 생성할 수 있게 해주는 Refresh Token을 생성하는 메서드입니다.
- Refresh Token의 경우 Access Token을 새로 발급해 주는 역할을 하는 Token이기 때문에 별도의 Custom Claims는 추가할 필요가 없습니다.
- (4)의 getKeyFromBase64EncodedKey() 메서드는 JWT의 서명에 사용할 Secret Key를 생성해 줍니다.
- (4-1)의 Decoders.BASE64.decode() 메서드는 Base64 형식으로 인코딩 된 Secret Key를 디코딩한 후, byte array를 반환합니다.
- (4-2)의 Keys.hmacShaKeyFor() 메서드는 key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성합니다.
- 💡 jjwt 0.9.x 버전에서는 서명 과정에서 HMAC 알고리즘을 직접 지정해야 했지만 최신 버전에서는 내부적으로 적절한 HMAC 알고리즘을 지정해 준다는 사실을 기억하기 바랍니다.
JWT를 생성하는 기능은 모두 구현했습니다.
이제 JwtTokenizer가 JWT를 정상적으로 잘 생성해 주는지 테스트해 보도록 합시다.
✅ JWT 생성 기능 테스트
package com.springboot.auth;
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;
// (1)
@BeforeAll
public void init() {
jwtTokenizer = new JwtTokenizer();
secretKey = "kevin1234123412341234123412341234"; // encoded "a2V2aW4xMjM0MTIzNDEyMzQxMjM0MTIzNDEyMzQxMjM0"
base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
}
// (2)
@Test
public void encodeBase64SecretKeyTest() {
System.out.println(base64EncodedSecretKey);
assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
}
// (3)
@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());
}
// (4)
@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());
}
}
코드 4-57은 JwtTokenizer 클래스가 JWT를 정상적으로 잘 생성하는지를 테스트하기 위한 테스트 케이스입니다.
우리가 단위 테스트를 학습하는 시간은 아니기 때문에 테스트 케이스 코드 자체를 구체적으로 설명하기보다는 테스트 시나리오만 간단하게 설명하도록 하겠습니다.
- 먼저 (1)에서 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩한 후, 인코딩 된 Secret Key를 각 테스트 케이스에서 사용합니다.
- (2)에서는 Plain Text인 Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행이 되는지 테스트하고 있습니다.
- Base64 형식으로 인코딩 된 Secret Key를 디코딩한 값이 원본 Plain Text Secret Key가 일치하는지를 테스트하면 됩니다.
- (3)에서는 JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트합니다.생성 과정에서 Exception이 발생하지 않았기 때문에 정상적으로 생성이 되었다고 봐도 무방하며, 더 정확한 테스트는 JWT의 서명 검증에서 확인할 수 있습니다.
- JWT는 생성할 때마다 그 값이 바뀌기 때문에 우선 생성된 Access Token이 null이 아닌지 여부만 테스트하고 있습니다.
- (4)에서는 JwtTokenizer가 Refresh Token을 정상적으로 생성하는지 테스트합니다. Custom Claims가 필요하지 않다는 것 외에는 Access Token과 테스트 과정은 동일합니다.
코드 4-57의 테스트 케이스가 모두 통과된다면 이제 JWT를 검증하는 기능만 구현하면 됩니다.
JWT의 검증 기능은 인증된 사용자가 애플리케이션의 리소스에 접근할 때마다 request의 header에 포함된 JWT를 검증할 때 사용된다는 사실을 기억하기 바랍니다.
JWT 검증
✅ JWT 검증 기능 구현
package com.springboot.auth;
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) // (1)
.build()
.parseClaimsJws(jws); // (2)
}
...
...
}
코드 4-58에서는 JwtTokenizer 클래스에 JWT 검증을 위한 verifySignature() 메서드가 추가되었습니다.
코드의 설명은 다음과 같습니다.
JWT는 JWT에 포함된 Signature를 검증함으로써 JWT의 위/변조 여부를 확인할 수 있습니다.
jjwt에서는 JWT를 생성할 때 서명에 사용된 Secret Key를 이용해 내부적으로 Signature를 검증한 후, 검증에 성공하면 JWT를 파싱 해서 Claims를 얻을 수 있습니다.
- (1)의 setSigningKey() 메서드로 서명에 사용된 Secret Key를 설정합니다.
- (2)의 parseClaimsJws() 메서드로 JWT를 파싱해서 Claims를 얻습니다.💡파라미터로 사용한 jws는 Signature가 포함된 JWT라는 의미이니 참고 바랍니다.
- verifySignature() 메서드는 Signature를 검증하는 용도이므로 Claims를 리턴할 필요는 없습니다.
이제 JWT 검증 기능이 잘 동작하는지 테스트해 보도록 하겠습니다.
✅ JWT 검증 기능 테스트
package com.springboot.auth;
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;
...
...
// (1)
@DisplayName("does not throw any Exception when jws verify")
@Test
public void verifySignatureTest() {
String accessToken = getAccessToken(Calendar.MINUTE, 10);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
// (2)
@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;
}
}
코드 4-59에서는 두 가지 측면에서 JWT에 대한 검증 테스트를 수행합니다.
- (1)에서는 우리가 구현한 JwtTokenizer의 verifySignature() 메서드가 Signature를 잘 검증하는지 테스트합니다.
- 테스트 자체는 간단합니다. 생성된 JWT를 verifySignature()로 전달해서 Exception이 발생하지 않는다면 Signature에 대한 검증이 잘 수행된 것으로 볼 수 있습니다.
- (2)에서는 JWT 생성 시 지정한 만료일시가 지나면 JWT가 정말 만료되는지를 테스트합니다.
- 생성되는 JWT의 만료 주기를 아주 짧게 준 후에 첫 번째 Signature 검증을 수행하고, 만료일시가 지나도록 지연시간을 준 뒤, 두 번째 Signature 검증을 수행했을 경우 ExpiredJwtException이 발생하면 JWT가 정상적으로 만료된다고 볼 수 있습니다.