JWT 란?
JWT는 Json Web Token 의 약자이다.
Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token이다.
JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다.
JWT는 아래와 같이 Header, Payload, Signature 으로 구성되어 있고, 3 부분을 . 구분자로 구분하며, Json 형태인 각 부분은 Base64로 인코딩 되어 표현된다.
Base64는 암호화된 문자열이 아니고, 같은 문자열에 대해 항상 같은 인코딩 문자열을 반환한다.
세션 vs JWT
세션 방식
먼저 세션 방식으로 인증을 처리하는 과정은 아래와 같다.
위와 같이 먼저 클라이언트 측에서 로그인을 시도한다.
회원 정보가 일치하면 서버측은 세션을 발급한다.
이제 클라이언트는 발급된 세션을 매 요청시 쿠키에 담아서 서버측에 전송한다.
서버측에서는 쿠키를 까보고 세션정보가 일치하면, 요청한 값을 보내준다.
JSESSIONID는 유의미한 값이 아니라 서버에서 세션(사용자) 정보를 찾는 Key로만 활용한다. -> 따라서 탈취되었다고 해서 개인정보가 탈취된건 아니다. (장점이라고 할 수 있다.) -> 세션하이제킹 공격을 당할수 있기 때문에 절대적으로 안전하다는 뜻은 아니다.
세션기반 인증의 문제점
서버에 세션(사용자) 정보를 저장할 공간이 필요하다. -> 서비스를 이용하는 사용자가 많다면 저장할 공간도 더 많이 필요하다.
분산 서버에서는 세션을 공유하는데 어려움이 있다.
세션은 자바 코드딴에서 발급된 것이다. -> 그렇기 때문에 java script 기반으로 구성된 React 와 같은 기술에서 세션이 공유되지 안된다.
세션과 쿠키는 모바일 애플리케이션에서 유효하게 사용할 수 없음. -> 공유가 되지 않기 때문이다.
렌더링된 HTML 페이지가 반환되지만, 모바일 애플리케이션에서는 JSON(or XML) 과 같은 포멧이 필요하다.
JWT 방식
JWT 방식으로 인증을 처리하는 과정은 아래와 같다.
위와 같이 먼저 클라이언트 측에서 로그인을 시도한다.
회원 정보가 일치하면 JWT 를 발급한다.
이제 클라이언트는 JWT 를 매 요청시 헤더에 담아서 같이 보내준다.
서버는 JWT 를 까보고 정보가 맞다면, 요청한 값을 보내준다.
JWT 방식의 장점
세션 관리를 할 필요가 없어 별도의 저장소가 필요 하지않다.
Stateless 하다.
CDN 와 같은 캐시서버를 인증처리에 사용할 수 있다.
No-Cookie-Session -> No CSRF
지속적으로 토큰을 저장할 수 있다.
서버분산 & 클러스터 환경과 같은 확장성에 좋다.
JWT 방식의 단점
한번 제공된 토큰은 회수가 어렵다. -> 토큰은 세션을 저장하지 않기 때문에 한번 제공된 토큰을 회수할수 없습니다. -> 그래서 보통 토큰의 유효기간을 짧게 한다.
토큰에는 유저의정보가 있기 때문에 상대적으로 안정성이 우려된다. -> 따라서 민감정보((ex 패스워드, 개인정보)를 토큰에 포함시키면 안된다.
JWT 구조
Header
HEADER는 JWT를 검증하는데 필요한 정보를 가진 객체다.
Signature에 사용한 암호화 알고리즘이 무엇인지, Key의 ID가 무엇인지 정보를 담고 있다.
{
"alg": "HS256",
"kid": "key1"
}
alg 알고리즘 방식을 지정하는데, 헤더(Header)를 암호화 하는 것이 아니고, Signature를 해싱하기 위한 알고리즘을 지정하는 것이다. ex) HS256(SHA256) 또는 RSA
kid 는 아래에서 언급할 Key Rolling 과 관련있다.
PayLoad
실질적으로 인증에 필요한 데이터를 저장한다.
데이터의 각각 필드들을 Claim이라고 한다.
대부분의 경우에 Claim에 username을 포함한다. -> 인증할 때 payload에 있는 username을 가져와서 유저 정보를 조회할 때 사용해야하기 때문이다.
클레임은 총 3가지로 나누어지며, Json(Key/Value) 형태로 다수의 정보를 넣을 수 있다.
등록된 클레임(Registered Claim)
등록된 클레임은 토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터다.
모두 선택적으로 작성이 가능하며 사용할 것을 권장한다.
JWT를 간결하게 하기 위해 key는 모두 길이 3의 String이다.
subject로는 unique한 값을 사용하는데, 사용자 이메일을 주로 사용한다.
길이가 3인 key 는 아래와 같은 것들이 있다.
iss: 토큰 발급자(issuer)
sub: 토큰 제목(subject)
aud: 토큰 대상자(audience)
exp: 토큰 만료 시간(expiration)
nbf: 토큰 활성 날짜(not before)
iat: 토큰 발급 시간(issued at)
jti: JWT 토큰 식별자(JWT ID)
공개 클레임(Public Claim)
공개 클레임은 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다.
충돌 방지를 위해 URI 포맷을 이용한다.
예시는 아래와 같다.
{
"https://hyunwook.dev": true
}
비공개 클레임(Private Claim)
비공개 클레임은 사용자 정의 클레임이다.
서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다.
예시는 아래와 같다.
{
"token_type": access
}
Signature
Header와 Payload는 암호화하지 않았고 json -> utf8 -> base64로 변환한 데이터다. -> Header와 Payload 생성 메커니즘은 너무 쉽고 누구나 만들 수 있는 데이터다. -> 따라서 저 두개의 데이터만 있다면 토큰에 대한 진위여부 판단은 전혀 이루어질수 없다. -> 그래서 Signature 가 필요하다.
서명(Signature)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.
서명(Signature)은 위에서 만든 헤더(Header)와 페이로드(Payload)의 값을 각각 BASE64로 인코딩하고, 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 BASE64로 인코딩하여 생성한다.
https://jwt.io/ 를 통해서 아래와 같이 토큰 정보를 확인할 수 있다.
Key Rolling
JWT의 토큰 생성 메커니즘을 보다보면 Secret Key가 노출되면 사실상 모든 데이터가 유출될 수 있다는 걸 알수 있다.
이런 문제를 방지하기 위해서 Secret Key를 여러개 두고 Key 노출에 대비할 수 있다.
Secret Key를 여러개를 사용하고 수시로 추가하고 삭제해줘서 변경한다면 SecretKey 중에 1개가 노출되어도 다른 Secret Key와 데이터는 안전한 상태가 된다.
Key Rolling에서는 여러개의 Secret Key가 존재한다.
Secret Key 1개에 Unique한 ID (kid 혹은 key id라고 부름) 를 연결시켜 둔다.
JWT 토큰을 만들 때 헤더에 kid를 포함하여 제공하고 서버에서 토큰을 해석할 때 kid로 Secret Key를 찾아서 Signature를 검증한다.
JWT 구현
설정
아래와 같이 디펜던시를 추가한다.
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
}
아래와 같이 yml 파일에 토큰의 유효시간을 설정해 둔다.
token:
expiration_time: 86400000
secret: user_token
60 * 60 * 24 * 1000 = 86400000 으로 만료시간을 하루로 설정했다.
secret 에는 임의의값을 사용하면된다. -> 이 값이 토큰이 생성될때 사용된다.
JWT 생성
public static String createToken(User user) {
Claims claims = Jwts.claims().setSubject(user.getUsername()); // subject
Date now = new Date(); // 현재 시간
Pair<String, Key> key = JwtKey.getRandomKey();
// JWT Token 생성
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(System.currentTimeMillis() + Long.parseLong(env.getProperty("token.expiration_time")) // 토큰 만료 시간 설정
.setHeaderParam(JwsHeader.KEY_ID, key.getFirst()) // kid
.signWith(SignatureAlgorithm.HS512, env.getProperty("token.secret")) // signature
.compact();
}
yml 에 설정해둔 변수를 env 로 가져와서 만료시간을 설정한다.
해더에 값을 세팅한다. -> key.getFirst() 가 kid 이다.
signWith 에서 암호화할 알고리즘을 지정하고, 키 조합을 할 수 있는 값도 설정한다. -> yml 에 정의된 secret 값을 조합하는데 사용했다.
토큰에서 정보 추출
public static String getUsername(String token) {
// jwtToken에서 username을 찾습니다.
return Jwts.parserBuilder()
.setSigningKeyResolver(SigningKeyResolver.instance)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject(); // username
}
위와 같이 토큰에서 정보를 추출할 수 있다.
SigningKeyResolver 는 kid 로 Key 를 가져오는데 사용된다.
parseClaimsJws는 찾아온 키로 signature 를 검증한다. -> 인증이 실패하면 원인에 따라 적절한 예외를 던진다.
JWT Key
public class JwtKey {
/**
* Kid-Key List 외부로 절대 유출되어서는 안됩니다.
*/
private static final Map<String, String> SECRET_KEY_SET = Map.of(
"key1", "SpringSecurityJWTPracticeProjectIsSoGoodAndThisProjectIsSoFunSpringSecurityJWTPracticeProjectIsSoGoodAndThisProjectIsSoFun",
"key2", "GoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurity",
"key3", "HelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurity"
);
private static final String[] KID_SET = SECRET_KEY_SET.keySet().toArray(new String[0]);
private static Random randomIndex = new Random();
/**
* SECRET_KEY_SET 에서 랜덤한 KEY 가져오기
*
* @return kid와 key Pair
*/
public static Pair<String, Key> getRandomKey() {
String kid = KID_SET[randomIndex.nextInt(KID_SET.length)];
String secretKey = SECRET_KEY_SET.get(kid);
return Pair.of(kid, Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)));
}
/**
* kid로 Key찾기
*
* @param kid kid
* @return Key
*/
public static Key getKey(String kid) {
String key = SECRET_KEY_SET.getOrDefault(kid, null);
if (key == null)
return null;
return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
}
}
Key Rolling 을 사용하기 우해서 Map 형태로 3개의 키를 정의한다.
getRandomKey 메소드에서 랜덤으로 키를 지정해 준다.
getKey 메소드에서 Key 를 찾아서 넘겨주는데, 이때 hmacShaKeyFor는 길이에 따라서 적절한 암호화 방식을 알아서 선택한다.
인증 필터 구현
jwt 를 검증하는 필터를 구현해보자.
코드는 아래와 같다.
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationFilter.Config> {
Environmrnt env;
public AuthorizationHeaderFilter(Environment env){
super(Config.class);
this.env = env;
}
public static class Config {
}
@Override
public GetwayFilter apply(Config config){
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if(!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)){
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
if(!isJwtValid(jwt)){
return onError(exchange, "JWT token is not vaild", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange);
}
}
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
private boolean isJwtValid(String jwt){
boolean returnValue = true;
String subject = null;
try{
subject = Jwts.parse().setSigningKey(env.getProperty("token.secret"))
.parseClaimsJws(jwt).getBody()
.getSubject();
} catch (Exception ex){
returnValue = false;
}
if (subject == null || subject.isEmpty()){
returnValue = false;
}
return returnValue;
}
}
isJwtValid 메소드에서 Jwt 가 유효한지 검증한다.
token.secret 으로 jwt를 파싱한다.
onError 메소드의 반환값으로 Mono 라는 단위를 사용했는데, 이는 Spring WebFlux 라는 개념에서 쓰이는 단위이다. -> 단일값일때 사용되며 단일값이 아닐때에는 Flux 를 사용한다.
REFERENCES