[Spring] JWT (JSON Web Token)
스프링 복습 및 정리 18P
JWT란?
웹에서 정보를 안전하게 전송하기 위한 토큰 기반의 인증 방식 중 하나이다.
토큰 인증 방식에서 가장 범용적으로 사용되며, JSON 포맷의 토큰 정보를 인코딩 후,
인코딩 된 토큰 정보를 Secret Key로 서명(Sign)한 메시지를 Web Token으로써 인증 과정에 사용한다.
JWT의 종류
Access Token (액세스 토큰)
Refresh Token (리프레시 토큰)
Access Token은 보호된 정보들에 접근할 수 있는 권한 부여에 사용된다.
클라이언트가 처음 인증을 받게 될 때(로그인), Access Token과 Refresh Token 두 가지를 다 받지만,
실제로 권한을 얻는 데 사용하는 토큰은 Access Token이다.
권한을 부여받는 데엔 Access Token만 가지고 있으면 된다.
하지만 Access Token을 악의적인 유저가 얻어냈다면 자신이 이 사용자인 것처럼 서버에 여러 가지 요청을 보낼 수 있다.
그렇기 때문에 Access Token에는 비교적 짧은 유효 기간을 주어 탈취되더라도 오랫동안 사용할 수 없도록 하는 것이 좋다.
Access Token의 유효기간이 만료됐다면 Refresh Token을 사용하여 새로운 Access Token을 발급받는다.
이때, 사용자는 다시 로그인 인증을 할 필요가 없다.
유효기간이 긴 Refresh Token 마저 악의적인 유저가 얻어낸다면 큰 문제가 된다.
Refresh Token을 이용해 Access Token을 다시 발급받으면 사용자에게 피해를 입힐 수 있기 때문이다.
그렇기 때문에 사용자의 편의보다 정보를 지키는 것이 더 중요한 웹 애플리케이션은 Refresh Token을 사용하지 않는 곳이 많다.
JWT 구조
Header (헤더)
어떤 종류의 토큰인지, 어떤 알고리즘으로 Sign할지 정의한다.
토큰의 유형과 해시 알고리즘 과 같은 메타데이터를 포함한다.
이 부분은 Base64로 인코딩되어 있다.
{
"alg" : "HS256",
"typ" : "JWT"
}
Payload (내용)
실제로 전송하고자 하는 데이터가 이 부분에 담겨있다.
사용자의 클레임(claims)이라고도 불리는 이 정보는 여러가지 종류가 있을 수 있다.
예를 들어 어떤 정보에 접근 가능한지, 사용자 ID, 권한, 토큰 만료 시간 등이 있다.
Payload는 Signature를 통해 유효성이 검증될 정보이긴 하지만, 민감한 정보는 담지 않는 것이 좋다.
이 부분은 Base64로 인코딩되어 있다.
{
"sub" : "someInformation",
"name" : "demuu",
"iat" : 951823142
}
Signature (서명)
이는 토큰이 유효한지 검증하기 위한 부분으로,
헤더와 내용을 합친 후 지정된 알고리즘을 이용해 서명된 값이다.
서명은 비밀 키를 사용하여 생성되며, 이를 알지 못하는 이상 토큰을 변조하는 것이 어렵다.
Signature에서는 원하는 Secret Key와 Header에서 지정한 알고리즘을 사용하여 Header와 Payload에 대해서 단방향 암호화를 수행한다.
예를 들어, 만약 HMAC SHA256 알고리즘을 사용한다면 Signature는 아래와 같은 방식으로 생성된다.
HMACSHA265(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
인증 절차
- 클라이언트가 서버에 ID, Password를 담아 로그인 요청을 보낸다.
- ID, Password가 일치하는지 확인하고, 클라이언트에게 보낼 암호화된 토큰을 생성한다.
- Access Token과 Refresh Token을 모두 생성한다.
- 토큰을 클라이언트에게 전송하면, 클라이언트는 토큰을 저장한다.
- 저장하는 위치는 Local Storage, Session Storage, Cookie 등이 될 수 있다.
- 클라이언트가 HTTP Header(Authorization Header) 또는 쿠키에 토큰을 담아 request를 전송한다.
- Bearer authentication을 이용한다.
- 서버는 토큰을 검증하여 토큰이 인증될 경우, 클라이언트의 요청을 처리한 후 응답을 보내준다.
JWT 장점
상태를 유지하지 않고(Stateless), 확장에 용이한(Scalable) 애플리케이션을 구현하기 용이하다.
- 서버는 클라이언트에 대한 정보를 저장할 필요 없다. (토큰이 정상적으로 검증되는지만 판단한다)
- 클라이언트는 request를 전송할 때마다 토큰을 헤더에 포함시키면 된다.
- 여러 대의 서버를 이용한 서비스라면 하나의 토큰으로 여러 서버에서 인증이 가능하기 때문에 JWT를 사용하는 것이 효과적이다.
- 세션 방식이라면 모든 서버가 해당 사용자의 세션 정보를 공유하고 있어야 한다.
클라이언트가 request를 전송할 때마다 자격 증명 정보를 전송할 필요가 없다.
- HTTP Basic 같은 인증 방식은 Request를 전송할 때마다 자격 증명 정보를 포함해야 하지만 JWT의 경우 토큰이 만료되기 전까지는 한 번의 인증만 수행하면 된다.
인증을 담당하는 시스템을 다른 플랫폼으로 분리하는 것이 용이하다.
- 사용자의 자격 증명 정보를 직접 관리하지 않고, Github, Google 등의 다른 플랫폼의 자격 증명 정보로 인증하는 것이 가능하다.
- 토큰 생성용 서버를 만들거나, 다른 회사에서 토큰 관련 작업을 맡기는 것 등 다양한 활용이 가능하다.
권한 부여에 용이하다
- 토큰의 Payload 안에 해당 사용자의 권한 정보를 포함하는 것이 용이하다.
JWT 단점
Payload는 디코딩이 용이하다.
- Payload는 base64로 인코딩 되기 때문에 토큰을 탈취하여 Payload를 디코딩하면
토큰 생성 시 저장한 데이터를 확인할 수 있다. 따라서 민감한 정보를 포함하지 않아야 한다.
토큰의 길이가 길어지면 네트워크에 부하를 줄 수 있다.
토큰은 자동으로 삭제되지 않는다.
- 토큰은 자동으로 삭제되지 않기 때문에 토큰 만료 시간을 반드시 추가해야 한다.
JWT 구현
build.gradle
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'
}
JWT를 위한 대표적인 라이브러리에는 jjwt와 Java JWT가 있는데 가장 많이 사용되는 jjwt를 사용하겠다.
jjwt 사용법을 구글링 하면 0.9 버전에 대한 내용이 대부분이지만, 여기서는 0.11.5 버전을 사용한다.
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 {
// 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;
}
}
1의 encodeBase64SecretKey()
메서드는 Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩해준다.
2의 generateAccessToken()
은 인증된 사용자에게 JWT를 최초로 발급해 주기 위한 JWT 생성 메서드이다.
2-1에서는 Base64 형식 Secret Key 문자열을 이용해 Key(java.security.Key
) 객체를 얻는다.
2-2의 setClaims()
에는 JWT에 포함시킬 Custom Claims를 추가한다.
CustomClaims에는 주로 인증된 사용자와 관련된 정보를 추가한다.
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 생성 기능 테스트
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());
}
}
위 코드는 JwtTokenizer 클래스가 JWT를 정상적으로 잘 생성하는지를 테스트하기 위한 테스트 케이스이다.
1에서 테스트에 사용할 Secret Key를 Base64 형식으로 인코딩한 후, 인코딩된 Secret Key를 각 테스트 케이스에서 사용한다.
2에서는 Plain Text인 Secret Key가 Base64 형식으로 인코딩이 정상적으로 수행이 되는지 테스트하고 있다.
3에서는 JwtTokenizer가 Access Token을 정상적으로 생성하는지 테스트한다.
JWT는 생성할 때마다 그 값이 바뀌기 때문에 우선 생성된 Access Token이 null이 아닌지 여부만 테스트하고 있다.
생성 과정에서 Exception이 발생하지 않았기 때문에 정상적으로 생성이 되었다고 봐도 무방하며,
더 정확한 테스트는 JWT의 서명 검증에서 확인할 수 있다.
4에서는 JwtTokenizer가 Refresh Token을 정상적으로 생성하는지 테스트한다.
Custom Claims가 필요하지 않다는 것 외에는 Access Token과 테스트 과정은 동일하다.
JWT의 검증 기능은 인증된 사용자가 애플리케이션의 리소스에 접근할 때마다 request의 header에 포함된 JWT를 검증할 때 사용된다.
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) // 1
.build()
.parseClaimsJws(jws); // 2
}
...
...
}
위 코드에서는 JwtTokenizer 클래스에 JWT 검증을 위한 verifySignature() 메서드가 추가되었다.
JWT는 JWT에 포함된 Signature를 검증함으로써 JWT의 위/변조 여부를 확인할 수 있다.
jjwt에서는 JWT를 생성할 때 서명에 사용된 Secret Key를 이용해 내부적으로 Signature를 검증한 후,
검증에 성공하면 JWT를 파싱해서 Claims를 얻을 수 있다.
1의 setSigningKey()
메서드로 서명에 사용된 Secret Key를 설정한다.
2의 parseClaimsJws()
메서드로 JWT를 파싱해서 Claims를 얻는다.
verifySignature()
메서드는 Signature를 검증하는 용도이므로 Claims를 리턴할 필요는 없다.
파라미터로 사용한 jws는 Signature가 포함된 JWT라는 의미이다.
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;
...
...
// 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;
}
}
1에서는 구현한 JwtTokenizer의 verifySignature() 메서드가 Signature를 잘 검증하는지 테스트한다.
생성된 JWT를 verifySignature()로 전달해서 Exception이 발생하지 않는다면 Signature에 대한 검증이 잘 수행된 것이다.
2에서는 JWT 생성 시 지정한 만료일시가 지나면 JWT가 정말 만료되는지를 테스트한다.
생성되는 JWT의 만료 주기를 아주 짧게 준 후에 첫 번째 Signature 검증을 수행하고,
만료일시가 지나도록 지연시간을 준 뒤,
두 번째 Signature 검증을 수행했을 경우 ExpiredJwtException이 발생하면 JWT가 정상적으로 만료된다고 볼 수 있다.
Spring Boot 핵심 정리 모음
1P - POJO, IoC, DI, AOP, PSA >
3P - 컴포넌트 스캔과 의존성 자동 주입, @Autowired >
5P - Spring MVC / MVC의 동작 방식과 구성 요소 >
7P - HTTP Header, Rest Client >
9P - Service / Entity 클래스, Mapper / MapStruct >
10P - 예외 처리 (@ExceptionHandler, @RestControllerAdvice) >
11.1P - DDD, 도메인 엔티티 및 테이블 설계 >
11.2P - 데이터 액세스 계층 구현 (도메인 엔티티 클래스 정의) >
11.3P - 데이터 액세스 계층 구현 (서비스, 리포지토리 구현) >
12P - JPA(Java Persistence API) >
12.3P - Spring Data JPA 데이터 액세스 계층 구현 >
14P - Spring MVC Testing 단위 테스트 >
14.3P - 슬라이스 테스트 (API, 데이터 액세스 계층) >
14.5P - TDD (Test Driven Development) >
15P - API 문서화 (Documentation) >
댓글남기기