Header, Payload, Signature 세 부분으로 구성
- Header: JWT의 타입과 암호화 알고리즘을 포함. JSON 형식으로 인코딩.
- Payload: 클레임 정보 포함(사용자 ID, 권한 등). JSON 형식으로 인코딩.
- Signature: Header와 Payload 조합 후 비밀키를 사용해 생성된 서명값. 토큰의 무결성을 보장 + JWT 조작하지 않았음을 검증.
위 사진은 공식 사이트에 나와있는 예시로, 오른쪽 내용이 인코딩되어 왼쪽이 된다는 것을 나타내고 있다.
헤더부분은 토큰 타입과 사용된 알고리즘이 담겨있으며, Base64 URL 인코딩이 되어있다.
페이로드에는 퍼블릭값이 있고 프라이빗값이 있는데, 기본값 외에도 JSON 양식을 지킨다면 임의로 값을 추가할 수 있다. 페이로드도 마찬가지로 Base64 URL 인코딩된 값이다. 누구라도 JWT에서 이 값을 Base64로 디코딩하면 읽을 수 있기 때문에 중요한 정보보다는 참고할 수 있는 정보를 담아두는 것이 좋다.
마지막 서명 부분이 가장 중요하다. JWT 토큰은 해시 알고리즘을 통해 서명하는데, 먼저 Base 64 URL 인코딩된 헤더와 페이로드 사이에 점을 하나 추가하여 붙인다. 가지고 있는 시크릿키를 사용해 이 문자열을 해시 알고리즘으로 암호화하고, 그 값을 Base64 인코딩한 것이 서명이 된다.
어떠한 값을 어떤 키를 사용해 암호화할 경우 항상 일정한 값이 나온다. 항상 같은 키를 사용하면 항상 같은 결과값을 얻게 된다. 즉, 외부의 조작이 없다면 토큰의 서명은 항상 일정하다.
따라서 JWT는 헤더와 페이로드의 값을 가지고 해시 알고리즘을 돌려 서명이 동일한지를 인증할 수 있다. 서명이 동일하면 토큰은 유효하다. 만약 서명에 변경이 생기면 누군가가 토큰을 건드렸다는 뜻이고, 이 경우에는 토큰은 유효하지 않게 된다.
JWT의 장점은 토큰 기반의 인증 방식이기 때문에 서버 측에서 별도로 세션 저장소를 유지할 필요가 없어 유지보수가 편리해진다. 또, JSON 구조로 되어 있어 다양한 플랫폼 간에 쉽게 전송하고 구현할 수 있으며, 서명 키가 유출되지 않는 한 항상 발행을 증명할 수 있기 때문에 토큰 변조 여부를 쉽게 검증할 수 있다.
반대로 JWT의 크기가 커지면 네트워크 대역폭이 증가하게 된다. 한번 발급된 후에는 내부 정보를 수정할 수 없으므로 만료 시간이 짧으며, 탈취당하면 해당 토큰을 사용한 모든 요청이 인증되므로 보안에 위협이 될 수 있다. 따라서 JWT 토큰을 전송할 때는 HTTPS와 같은 보안 프로토콜을 사용해야 한다.
클라이언트가 서버가 내가 발행한 토큰을 그대로 가져왔는지를 검증한다.
JWT 토큰을 사용하려면 몇가지 의존성을 build.gradledp 추가해주어야 한다. 차례대로 jjwt-api, jjwt-impl, jjwt-jackson인데, 버전은 0.11.5 버전을 사용했다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
라이브러리를 불러오고, JwtService를 만들고 안에 JWT 토큰을 생성하는 메서드와 이 토큰을 검증하는 메서드 하나를 만들어준다.
JWT를 만들려면 키를 생성해야 하는데, 키는 3종류가 있다.
@Service
public class JwtService {
private static String secretKey = "";
public String create(
Map<String, Object> claims,
LocalDateTime expireAt
){
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var _expireAt = Date.from(expireAt.atZone(ZoneId.systemDefault()).toInstant());
return Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setClaims(claims)
.setExpiration(_expireAt)
.compact();
}
JWT를 직접 만들려고 하면 상당히 복잡하지만, 이렇게 이미 만들어진 라이브러리를 사용해 쉽게 토큰을 생성할 수 있다.
다음으로 검증하는 메서드를 만든다.
public void validation(String token){
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
var result = parser.parseClaimsJws(token);
result.getBody().entrySet().forEach(value -> {
log.info("key: {}, value: {}", value.getKey(), value.getValue());
});
}
따로 Controller를 작성하지 않고 테스트 코드를 작성하여 토큰이 제대로 생성되는지 보고 검증해보려 한다.
먼저 토큰이 생성되는지 확인해본다. user_id를 923로 주고, 지금으로부터 10분 후에는 만료되도록 설정한 후 생성 메서드를 호출한다.
@Test
void tokenCreate(){
var claims = new HashMap<String, Object>();
claims.put("user_id", 923);
var expiredAt = LocalDateTime.now().plusMinutes(10);
var jwtToken = service.create(claims, expiredAt);
System.out.println(jwtToken);
}
생성 메서드가 제대로 실행되면 다음과 같이 JWT 토큰이 로그에 찍히는 것을 확인할 수 있다.
이 토큰을 다시 JWT 사이트로 돌아가 복사 붙여넣기 하면 Payload에 아까 입력했던 user_id가 923으로 나오는 것을 확인할 수 있다.
이 키는 외부에서 수정하거나 변형할 수 없기 때문에 토큰에 조금이라도 변화가 생기면 토큰이 invalid해진다.
다음으로 토큰을 검증해본다.
@Test
void tokenValidation(){
var token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5MjMsImV4cCI6MTcwNzg4OTQ2NH0.Elr7nENJLsoezRVTkyJT6AvvS0g0tqCZRQZ5wR-uUus";
service.validation(token);
}
생성했던 토큰을 그대로 복사해서 붙여넣고 실행해보면 테스트가 통과한다.
그러나 만약 만료기간이 10초로 짧은 토큰을 검증한다고 하면, 10초가 지난 후에는 검증에 실패할 것이다.
10초 동안 살아있는 짧은 토큰을 새로 생성하였다.
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5MjMsImV4cCI6MTcwNzg4OTY1NX0.FI9VndEFM4i6Eaibkt2vbYRgTnzONcpvWc3Z3KfBKFI
토큰이 변조되면 이렇게 검증 과정에서 예외가 발생한다.
다시 service로 가서 예외를 처리해준다.
public void validation(String token){
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
try {
var result = parser.parseClaimsJws(token);
result.getBody().entrySet().forEach(value -> {
log.info("key: {}, value: {}", value.getKey(), value.getValue());
});
} catch (Exception e){
if (e instanceof SignatureException){
throw new RuntimeException("JWT Token Not Valid Exception");
} else if (e instanceof ExpiredJwtException){
throw new RuntimeException("JWT Token Expired Exception");
} else {
throw new RuntimeException("JWT Token Validation Exception");
}
}
}
다음으로 30초로 만료기간을 늘린 JWT 토큰을 생성하여 다시 테스트해본다. 30초 내에 실행한 검증은 통과하였고, 30초가 지난 후 실행해보니 제대로 TokenExpiredException을 잡는 것을 확인하였다.