모르지 않다는 것은 아는것과 다르다.

Spring Security

JWT

채마스 2022. 2. 28. 21:02

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