JWT Token 구조
토큰 기반 인증에서 가장 활발하게 사용되고 있는 JWT Token의 구조에 대해 알아보겠습니다. 위의 JWT Token 구조를 보면 알 수 있듯이, 크게 Header, PAYLOAD, VERIFY SIGNATURE 로 이루어져 있습니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
- "alg" (Algorithm): 토큰의 서명 알고리즘을 지정합니다. JWT는 일반적으로 HMAC SHA-256 (HS256) 또는 RSA SHA-256 (RS256) 등의 알고리즘을 사용합니다.
- "typ" (Type): 토큰의 유형을 지정합니다. JWT의 경우 "JWT"로 설정됩니다.
이러한 헤더 정보는 JSON 객체로 표현되고 Base64로 인코딩됩니다.
{
"alg": "HS256",
"typ": "JWT"
}
PAYLOAD
JWT의 페이로드는 사용자 정의 데이터를 포함합니다. 이 데이터는 클레임(Claims)의 형태로 구성되며, 클레임은 토큰에 담길 정보를 의미합니다. 클레임은 다음과 같이 두 가지 유형으로 나뉩니다.
- 등록된 클레임(Registered Claims): 토큰의 표준 정보를 의미합니다. 예를 들어 "sub" (Subject), "iss" (Issuer), "exp" (Expiration Time), "iat" (Issued At), "nbf" (Not Before) 등이 있습니다.
- sub (Subject): "sub" 클레임은 토큰이 대상으로 하는 주체(Subject)를 나타냅니다. 주로 사용자를 식별하는 고유한 식별자가 이 부분에 들어갑니다. 예를 들어, 사용자 아이디나 이메일 주소 등이 될 수 있습니다.
- iss (Issuer): iss 클레임은 토큰을 발급한 발급자(Issuer)를 나타냅니다. 일반적으로 토큰을 생성한 서버의 도메인 또는 식별자가 이 부분에 들어갑니다.
- exp (Expiration Time): exp 클레임은 토큰의 만료 시간(Expiration Time)을 나타냅니다. 이 시간 이후에는 토큰이 더 이상 유효하지 않습니다. 시간은 일반적으로 UNIX 타임스탬프 형식으로 표현되며, 이를 초 단위로 나타낸 값이 이 클레임에 포함됩니다.
- iat (Issued At): iat 클레임은 토큰이 발급된 시간(Issued At)을 나타냅니다. 이 시간은 일반적으로 토큰이 생성된 시간을 나타내며, exp 클레임과 마찬가지로 UNIX 타임스탬프 형식으로 표현됩니다.
- nbf (Not Before): nbf 클레임은 토큰의 유효 시작 시간(Not Before)을 나타냅니다. 이 시간 이전에는 토큰이 유효하지 않습니다. "exp" 클레임과 "iat" 클레임과 마찬가지로 UNIX 타임스탬프 형식으로 표현됩니다.
- 비공개 클레임(Private Claims): 사용자 정의 정보를 포함할 수 있는 클레임으로, 서버와 클라이언트 간의 임의적인 정보를 주고받을 수 있습니다.
페이로드(Payload) 역시 JSON 객체로 표현되고 Base64로 인코딩됩니다.
{
"sub": "user123",
"name": "John Doe",
"admin": true
}
VERIFY SIGNATURE
JWT의 서명(SIGNATURE)은 Header와 Payload를 합친 후, 비밀 키로 해시를 생성하여 생성됩니다. SIGNATURE은 토큰이 변조되지 않았음을 검증하기 위해 사용됩니다. 서명은 헤더와 페이로드를 인코딩한 문자열과 비밀 키를 사용하여 HMAC SHA-256 또는 RSA SHA-256 알고리즘 등으로 생성됩니다.
예를 들어, 만약 헤더와 페이로드가 Base64로 인코딩된 후 다음과 같은 문자열이 된다고 가정하면
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
위의 문자열과 비밀 키(SECRET KEY)를 활용하여 서명(SIGNATURE)을 생성합니다. 서명은 토큰의 유효성을 검증하는 데 사용됩니다. 토큰이 변조되면, 서명이 일치하지 않게 되므로 토큰의 무결성을 보장할 수 있습니다.
위의 HEADER, PAYLOAD, VERIFY SIGNATURE 가 합쳐지게 되면 JSON Web Token이 됩니다.
JWT Token 기본적인 library Interface 읽어보기
기본적인 JWT 객체 클래스에 대해 알아봅니다.
Jwt와 Jws는 엄밀히 말하면 다르지만, 일반적으로 통용되는 이야기로 하여 JWT 객체에 대하여 알아보았습니다.
1. JWT.java (Interface)
public interface Jwt<H extends Header, B> {
H getHeader();
B getBody();
}
- JWT Interface입니다.
- 실제 Json Web Token으로 해당 JWT를 주고 받는다고 생각하면 됩니다.
- Header를 Extends 받은 H를 매개변수로 받고, B는 JWT의 Body를 나타냅니다. Payload 혹은 Claims 를 의미한다고 생각하면 되겠습니다.
- H getHeader();
- 이 메서드는 JWT의 헤더를 반환합니다.
- 반환 타입인 H는 Header 인터페이스 또는 이를 구현한 클래스입니다.
- 헤더가 존재하지 않을 경우 null을 반환합니다.
- B getBody()
- 이 메서드는 JWT의 페이로드를 반환합니다.
- 반환 타입인 B는 페이로드를 나타내는 타입으로, 보통 문자열(String) 또는 Claims 객체 등이 될 수 있습니다.
- 페이로드가 존재하지 않을 경우 null을 반환합니다.
- H getHeader();
- 이렇게 Jwt 인터페이스를 정의함으로써, JWT 토큰을 헤더와 Claims 로 나누어서 다룰 수 있게 됩니다.
- 여기서 의문인점은 Jwt에 Header, Body, Signature로 이루어져있는데 Signature는 어디있을까? 라는 의문이 생깁니다. 우리가 사용하는 JWT는 사실 JWS 입니다. JWT는 일종의 추상클래스 Abstract Class 라고 생각하면 되고, JWT에 우리가 Signature를 붙여서 사용하면 JWS라고 생각하면 됩니다.
- 이번 포스팅에서는 JWT 추상클래스에 대해 알아보고, JWS 내용은 생략합니다.
2. Header.java (Interface)
위의 JWT Interface에서 제너릭 타입 매개변수로 Header가 존재하여 확인해보았습니다.
위에서 말했듯 Jwt에는 Header, Payload, Signature로 존재합니다.
public interface Header<T extends Header<T>> extends Map<String,Object> {
public static final String JWT_TYPE = "JWT";
public static final String TYPE = "typ";
public static final String CONTENT_TYPE = "cty";
public static final String COMPRESSION_ALGORITHM = "zip";
@Deprecated
public static final String DEPRECATED_COMPRESSION_ALGORITHM = "calg";
String getType();
T setType(String typ);
String getContentType();
T setContentType(String cty);
String getCompressionAlgorithm();
T setCompressionAlgorithm(String calg);
}
- 우선 Header에서는 Map<String, Object>를 사용하기에 커스텀하기 위해 해당 클래스를 Extends하고 있습니다.
- JWT_TYPE, TYPE, CONTENT_TYPE, COMPRESSION_ALGORITHM 과 같은 Header에 들어가는 정보들이 들어가 있음을 확인할 수 있습니다.
3. Claims.java (Interface)
Registered Claims에 대한 정보를 확인할 수 있습니다.
public interface Claims extends Map<String, Object>, ClaimsMutator<Claims> {
public static final String ISSUER = "iss";
public static final String SUBJECT = "sub";
public static final String AUDIENCE = "aud";
public static final String EXPIRATION = "exp";
public static final String NOT_BEFORE = "nbf";
public static final String ISSUED_AT = "iat";
public static final String ID = "jti";
String getIssuer();
@Override //only for better/targeted JavaDoc
Claims setIssuer(String iss);
String getSubject();
@Override //only for better/targeted JavaDoc
Claims setSubject(String sub);
String getAudience();
@Override //only for better/targeted JavaDoc
Claims setAudience(String aud);
Date getExpiration();
@Override //only for better/targeted JavaDoc
Claims setExpiration(Date exp);
Date getNotBefore();
@Override //only for better/targeted JavaDoc
Claims setNotBefore(Date nbf);
Date getIssuedAt();
@Override //only for better/targeted JavaDoc
Claims setIssuedAt(Date iat);
String getId();
@Override //only for better/targeted JavaDoc
Claims setId(String jti);
<T> T get(String claimName, Class<T> requiredType);
}
- 공식적으로 제공하는 각종 Claims들이 있습니다.
4. ClaimsMutator.java (Interface)
위의 Claims interface 가 extends하고 있는 Interface입니다.
ClaimsMutator라는 이름처럼, ClaimsMutator 인터페이스에서는 JWT (JSON Web Token)의 Claims (클레임)에 대해 수정을 할 수 있는 함수들을 선언합니다.
ClaimsMutator를 extends하고 있는 Class가 있다면, 해당 Class에서 Claims (클레임)을 선언하거나 수정하는 Method를 구현한다는 의미입니다.
public interface ClaimsMutator<T extends ClaimsMutator> {
T setIssuer(String iss);
T setSubject(String sub);
T setAudience(String aud);
T setExpiration(Date exp);
T setNotBefore(Date nbf);
T setIssuedAt(Date iat);
T setId(String jti);
}
- Claims를 Setting 하는 Method들이 있습니다. (Header의 수정은 없습니다.)
- 여기서 <T extends ClaimsMutator> 라는 제네릭 타입 매개변수는 ClaimsMutator의 Extends하고 있는 Class만 매개변수로 들어오도록 하여 해당 Class Type으로 Return 값을 줍니다.
- 위에처럼 사용하는 이유 중 하나로는 빌더 패턴과 같은 상황에서 유용하게 사용할 수 있기 때문입니다. 이러한 제네릭 타입 매개변수를 활용하여 타입 안정성과 유연성을 확보할 수 있습니다.
5. JWTBuilder.java (Interface)
이제 JWT Interface를 확인했으니, Jwt를 만들어주기 위한 JWTBuilder Interface를 확인해보았습니다.
public interface JwtBuilder extends ClaimsMutator<JwtBuilder> {
JwtBuilder setHeader(Header header);
JwtBuilder setHeader(Map<String, Object> header);
JwtBuilder setHeaderParams(Map<String, Object> params);
JwtBuilder setHeaderParam(String name, Object value);
JwtBuilder setPayload(String payload);
JwtBuilder setClaims(Claims claims);
JwtBuilder setClaims(Map<String, Object> claims);
JwtBuilder addClaims(Map<String, Object> claims);
@Override //only for better/targeted JavaDoc
JwtBuilder setIssuer(String iss);
@Override //only for better/targeted JavaDoc
JwtBuilder setSubject(String sub);
@Override //only for better/targeted JavaDoc
JwtBuilder setAudience(String aud);
@Override //only for better/targeted JavaDoc
JwtBuilder setExpiration(Date exp);
@Override //only for better/targeted JavaDoc
JwtBuilder setNotBefore(Date nbf);
@Override //only for better/targeted JavaDoc
JwtBuilder setIssuedAt(Date iat);
@Override //only for better/targeted JavaDoc
JwtBuilder setId(String jti);
JwtBuilder claim(String name, Object value);
JwtBuilder signWith(SignatureAlgorithm alg, byte[] secretKey);
JwtBuilder signWith(SignatureAlgorithm alg, String base64EncodedSecretKey);
JwtBuilder signWith(SignatureAlgorithm alg, Key key);
JwtBuilder compressWith(CompressionCodec codec);
String compact();
}
- JwtBuilder를 만들기 위한 각종 함수들이 있습니다.
이외에도 Compression 관련 Interface와 Exception 등등 중요한 코드들이 존재하지만 생략하겠습니다.
이번 포스팅에서는 JWT의 Header와 Claims에 대한 Interface를 실제로 Implementation 한 코드를 확인해보겠습니다.
JWT Token Interface의 구현체들 읽어보기
1. DefaultJwt.class
JWT를 Implements 하고 있습니다.
public class DefaultJwt<B> implements Jwt<Header,B> {
private final Header header;
private final B body;
public DefaultJwt(Header header, B body) {
this.header = header;
this.body = body;
}
@Override
public Header getHeader() {
return header;
}
@Override
public B getBody() {
return body;
}
@Override
public String toString() {
return "header=" + header + ",body=" + body;
}
}
- 아까 Interface에서 확인한 Jwt<Header,B> 를 implements 하고 있습니다.
- Header와 Body를 매개변수로 받아서 DeafultJwt를 생성하고 있습니다.
2. DefaultHeader.class
Header Interface를 implements 하고 있습니다.
public class DefaultHeader<T extends Header<T>> extends JwtMap implements Header<T> {
public DefaultHeader() {
super();
}
public DefaultHeader(Map<String, Object> map) {
super(map);
}
@Override
public String getType() {
return getString(TYPE);
}
@Override
public T setType(String typ) {
setValue(TYPE, typ);
return (T)this;
}
@Override
public String getContentType() {
return getString(CONTENT_TYPE);
}
@Override
public T setContentType(String cty) {
setValue(CONTENT_TYPE, cty);
return (T)this;
}
@SuppressWarnings("deprecation")
@Override
public String getCompressionAlgorithm() {
String alg = getString(COMPRESSION_ALGORITHM);
if (!Strings.hasText(alg)) {
alg = getString(DEPRECATED_COMPRESSION_ALGORITHM);
}
return alg;
}
@Override
public T setCompressionAlgorithm(String compressionAlgorithm) {
setValue(COMPRESSION_ALGORITHM, compressionAlgorithm);
return (T) this;
}
}
- 코드를 보면 JwtMap을 Extends하고 있는데, DeafultHeader.class에서 Map<String, Object> 형식으로 Header를 다루는 방식을 확장하고 싶다면 할 수 있게 넣어둔 것 같습니다. 여기서는 따로 확장하여 사용하고 있지 않고, 아래의 DefaultClaims에서는 JwtMap을 확장하여 사용하고 있는 모습을 확인할 수 있습니다.
3. DefaultClaims.class
Claims를 구현하고, JwtMap을 확장하고 있습니다.
public class DefaultClaims extends JwtMap implements Claims {
public DefaultClaims() {
super();
}
public DefaultClaims(Map<String, Object> map) {
super(map);
}
//...생략
@Override
public String getIssuer() {
return getString(ISSUER);
}
@Override
public Claims setIssuer(String iss) {
setValue(ISSUER, iss);
return this;
}
//...생략
@Override
public <T> T get(String claimName, Class<T> requiredType) {
Object value = get(claimName);
if (value == null) { return null; }
if (Claims.EXPIRATION.equals(claimName) ||
Claims.ISSUED_AT.equals(claimName) ||
Claims.NOT_BEFORE.equals(claimName)
) {
value = getDate(claimName);
}
return castClaimValue(value, requiredType);
}
private <T> T castClaimValue(Object value, Class<T> requiredType) {
if (requiredType == Date.class && value instanceof Long) {
value = new Date((Long)value);
}
if (value instanceof Integer) {
int intValue = (Integer) value;
if (requiredType == Long.class) {
value = (long) intValue;
} else if (requiredType == Short.class && Short.MIN_VALUE <= intValue && intValue <= Short.MAX_VALUE) {
value = (short) intValue;
} else if (requiredType == Byte.class && Byte.MIN_VALUE <= intValue && intValue <= Byte.MAX_VALUE) {
value = (byte) intValue;
}
}
if (!requiredType.isInstance(value)) {
throw new RequiredTypeException("Expected value to be of type: " + requiredType + ", but was " + value.getClass());
}
return requiredType.cast(value);
}
}
- 여기서는 JwtMap의 get을 Extends하는 모습이 보입니다.
- T get 코드를 읽어보면,
- 클레임 이름(claimName)에 해당하는 값을 반환하는 것입니다. 클레임은 문자열, 숫자, 날짜 등 다양한 데이터 타입으로 저장될 수 있으므로, requiredType에 해당하는 타입으로 값을 변환하여 반환해야 합니다.
- 예를 들어, JWT 클레임의 값이 숫자로 저장되어 있을 때, requiredType이 Long으로 지정된 경우 해당 값을 Long으로 변환하여 반환합니다.
- castClaimValue 메서드의 목적
- 주어진 값을 requiredType에 해당하는 타입으로 변환합니다.
- 코드를 보면, JWT 클레임의 값이 Long으로 저장되어 있는데, requiredType이 Date로 지정된 경우 해당 값을 Date로 변환하여 반환합니다. 또한, 값이 Integer 타입인 경우에는 requiredType에 맞게 Long, Short, Byte 등의 숫자 타입으로 변환하여 반환합니다.
- 이러한 메서드들을 사용하여 DefaultClaims 클래스는 Claims 인터페이스의 메서드를 구현하고, 필요한 타입 변환과 데이터 유효성 검사로 JWT 클레임을 Parsing 하려고 하고 있습니다. 이 과정을 통해 JWT의 클레임 값을 원하는 자료형을 사용하여 얻을 수 있습니다.
마무리
위의 내용은 단순히 Jwt Class와 Jwt 구성요소인 Header, Claims 의 객체 구성에 대해 알아보았습니다.
Jwt의 Header, Claim 내용들을 직접 확인해보면서, Jwt의 구성요소에 대해 조금 더 잘알게 된 것 같습니다.
하지만, Jwt의 내용을 Parsing하는 과정을 진행하는 DeafultJwtParser와 실제로 Jwt의 서명(Signature)의 무결성을 검증하는 Jws에 대하여서는 다루지 않고 있습니다.
해당 내용에 관심있는 분들은 DefaultJwtParser.class 와 DefaultJws.class 를 확인해보시면 좋을 것 같습니다.
또한 추가로 Compression 관련 코드들도 존재하니, 해당 내용들도 확인이 가능합니다.