[Java] JWT (JSON Web Token) 개념 및 예제 코드

 

 

✍️ JWT (JSON Web Token) 개념

JWT(JSON Web Token)란 선택적 서명 혹은 선택적 암호화를 사용해 데이터를 만드는 인터넷 표준으로, 헤더, 페이로드, 서명으로 구성된다. 페이로드는 클레임(claim)을 담은 JSON 형태이며 서명은 무결성과 인증을 위해 사용된다.

 

쉽게 말하면 JSON 포맷을 이용해서 사용자 정보를 저장하는 Web Token으로, 클라이언트 서버 구조에서 주로 인증과 데이터 전달에 사용된다.

 

여기서 선택적 서명과 선택적 암호화라는 말이 나오는데 JWT에 서명을 추가하면 무결성이 보장되는 JWS가 되고, 암호화를 추가하면 무결성과 기밀성이 보장되는 JWE가 된다. 비유하자면 JWT를 확장한 클래스가 JWS 혹은 JWE라고 할 수 있다. 하지만 JWT를 이야기할 때 별다른 조건과 설명이 없다면 JWT는 JWS를 의미한다.

 

🍊 JWT 구조

JWT는 헤더(Header), 페이로드(Payload), 서명(Signature) 세 파트로 구성되며 각 부분이 Base64 인코딩되어 표현된다. 각각의 파트는 . 구분자를 통해 식별된다.

 

1. 헤더(Header)

헤더는 typ alg 두 가지 정보를 담고 있다. 

typ은 토큰의 타입으로 JWT에 해당한다.
alg은 해싱 알고리즘을 나타낸다. 해싱 알고리즘으로 보통 SHA, RSA 알고리즘을 사용하며 송신자는 서명을 만들 때, 수신자는 토큰을 검증할 때 헤더에 표기한 해싱 알고리즘을 사용한다.

{ 
   "alg": "HS512",
   "typ": JWT
 }

 

2. 페이로드(Payload)

JWT 토큰에서 사용될 정보가 포함된다. 즉, 서버와 클라이언트가 토큰을 주고받을 때 시스템에서 실제로 사용될 정보를 담고 있다. 여기에 포함되는 정보의 단위를 Claim이라고 부르며 Key-Value 타입으로 표현된다. 토큰에 여러 개의 Claim이 포함될 수 있으며 Claim은 크게 세 분류로 나누어져 있다.

 

1. Registered Claim, 등록된 클레임

2. Public Claim, 공개 클레임

3. Private Claim, 비공개 클레임

 

Registered Claim, 등록된 클레임

등록된 클레임은 토큰 자체를 표현하기 위해 미리 정의된 클레임으로 모두 선택적으로 작성이 가능하며 사용할 것을 권장한다.

iss: 토큰 발급자(issuer)
sub: 토큰 제목(subject)
aud: 토큰 대상자(audience)
exp: 토큰 만료 시간(expiration)
nbf: 토큰 활성 날짜(not before) 
iat: 토큰 발급 시간(issued at)
jti: JWT 토큰 식별자(JWT ID) 중복 방지를 위해 사용하며 일회용 토큰(Access Token) 등에 사용

 

Public Claim, 공개 클레임

공개 클레임은 사용자 정의 클레임으로, 공개용 정보 전달을 위해 사용된다. 충돌 방지를 위해 URI 포맷을 사용한다.

{ 
    "https://kangworld.tistory.com/": true
}

 

Private Claim, 비공개 클레임

비공개 클레임은 사용자 정의 클레임으로, 서버와 클라이언트 간 정보 전달에 사용되는 클레임이다.

{ 
    "userTeamId" : 1,
    "userName" : "kang"
}

 

3. 서명(Signature)

서명(Signature)은 토큰의 무결성을 검증할 때 사용된다. 서명 값을 계산하는 방법으로 1. 헤더(Header)와 페이로드(Playload)를 각각 Base64로 인코딩하고, 2. 인코딩 결과들과 비밀키를 헤더에 명시한 해시 알고리즘으로 해싱 한다. 3. 마지막으로 이 값을 다시 Base64로 인코딩한다. 과정은 아래 그림을 참고.

 

 

서명이 무결성 검증에 사용되는 시나리오는 간단하다. 엘리스가 밥에게 토큰을 보낸다고 가정해 보자.
엘리스가 보낸 토큰의 헤더가 A, 페이로드는 B 일 때 서명 값은 Hash(A + B + 비밀키)가 된다.
밥은 엘리스로부터 받은 토큰이 변조되었는지 판별하기 위해 엘리스가 서명을 만든 과정을 동일하게 진행한다. 엘리스로부터 받은 헤더 값 A, 페이로드 값 B 그리고 비밀키를 이용해서 Hash(A + B + 비밀키) 값을 계산한다. 토큰이 변조되지 않았다면 밥이 계산한 해시값은 엘리스가 보낸 것과 동일할 것이고, 변조되었다면 전혀 다른 해시 값을 가진다.

여기서 자연스럽게 한 가지 사실을 알 수 있는데 JWT는 무결성과 인증을 보장할 뿐, 기밀성은 보장하지 않는다. 세부적인 내용은 HMAC을 찾아보면 이해가 쉬울 것이다.

 

🌱  JWT 예제 코드

JWT 코드 Git 저장소

MVC 패턴에서 JWT 토큰을 발급하는 간단한 시나리오를 코드로 재현했다.

토큰 인증에 관한 코드는 인터셉터와 엮어 별도의 포스팅을 작성할 예정이다.

 

JWT dependency

Gradle
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

 

Auth 인증 요청과 응답에 사용할 Model 클래스

/**
 * 인증 요청에 사용할 클래스
 */
@Getter
@AllArgsConstructor
public class AuthRequest {

    private String id;
    private String password;
}
/**
 * 토큰을 감싸는 클래스
 */
@Getter
@AllArgsConstructor
public class AuthResponse {

    private String token;
}

 

AuthTokenService.class

public interface AuthTokenService {

    /**
     * Token 생성
     */
    String generateToken(AuthRequest authRequest);

    /**
     * HttpServletRequest 헤더에서 Token 추출
     */
    String extractToken(HttpServletRequest httpServletRequest);

    /**
     * Token 유효성 확인
     */
    void validateToken(String token);

    /**
     * Token에서 만료일 추출
     */
    public DateTime getExpireDate(String token);
}

 

토큰의 헤더를 설정하고, Claim으로 Issuer(토큰 발급자)와 Expiration(만기일)을 지정한다.

마지막으로 비밀키를 포함해서 서명을 만들고 토큰을 발급한다.

AuthTokenServiceImpl.class

@Service
public class AuthTokenServiceImpl implements AuthTokenService {

    private static final String HEADER_TOKEN_KEY = "TOKEN";

    private Key key;

    public AuthTokenServiceImpl() {
        String keySource = "jwtKeyString";
        byte[] keyBytes = Decoders.BASE64.decode(keySource);

        key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 토큰 발급
     */
    @Override
    public String generateToken(AuthRequest authRequest) {
        Date expireDate = DateTime.now().plusMinutes(30).toDate();

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setIssuer("admin")
                .setExpiration(expireDate)
                .signWith(key, SignatureAlgorithm.HS512).compact();
    }

    ```
}