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
- https://mangkyu.tistory.com/56
- 이도원님의 MSA 강의
- 안성훈님의 스프링 시큐리티
'Spring Security' 카테고리의 다른 글
스프링시큐리티 테스트 (0) | 2022.03.24 |
---|---|
PasswordEncoder (0) | 2022.03.24 |
ProxyFactory 를 이용한 동적 Method 인가 처리 (0) | 2022.02.28 |
Method 시큐리티 프로세스 커스텀 (0) | 2022.02.28 |
URL 시큐리티 프로세스 커스텀 (0) | 2022.02.28 |