JWT

Seung jun Cha·2023년 1월 21일
0

1. 개념

  • 토큰 기반 인증방식으로 사용자가 로그인에 성공했을 때 발급된다. 서버가 여러 대인 경우, 같은 사용자가 서로 다른 도메인 데이터를 요청할 경우 유용하다. 세션과 토큰 인증을 함께 사용할 수 있지만 굳이 그럴 필요가 없다.

  • Access Token은 Header와 Payload의 값을 각각 Base64로 인코딩한 후, 인코딩 된 값을 Secret Key를 이용해 헤더에서 정의한 알고리즘으로 암호화하고 다시 Base64로 인코딩하여 생성한다.

  • 일반적으로 토큰은 요청 헤더의 Authorization 필드에 담아져 보내집니다. 이때 Bearer 키워드는 HTTP 인증 헤더(Authentication Header)에 명시되어 있습니다. 즉 jwt 토큰 앞 Bearer은 약속된 규약으로 전송할 때 자동으로 붙는다는 것이다.
  • 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성되어있다. JWT를 사용하는 인증 필터를 구현하고 UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업을 수행하는 방식으로 구성한다.

  1. header : 토큰의 타입과 암호화 방식이 적혀있다.
{
    "typ": "JWT",
    "alg": "HS256"
}
  1. payload
    토큰에 담을 한 조각의 정보를 클레임이라고 한다. 클레임은 name/value의 한 쌍으로 이루어져 있고 클레임의 종류는 다음과 같이 3가지의 종류가 있다.
  • 등록된 클레임 : 서비스에 필요한 정보가 아니라 토큰에 대한 정보들을 담기위해 이름이 이미 정해진 클레임
    iss(발급자), sub(제목), aud(대상자), exp(만료시간), nbf(토큰의 활성날짜), iat(발급된시간), jti(JWT 고유식별자, 일회용 토큰에 사용)
  • 공개 클레임 : 공개 클레임은 사용자 정의 클레임으로. 공개용 정보 전달을 위해 사용됩니다. 충돌 방지를 위해 URI로 이름을 짓는다
    {
     "https://shinsunyoung.com/jwt_claims/is_admin": true, 
     }
  • 비공개 클레임 : 서버와 클라이언트 간의 정보 공유를 위한 클레임
  1. signature : 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용하며, 헤더(header)의 인코딩 값과 정보(payload)의 인코딩값을 합친 후에 주어진 비밀키를 통해 해쉬값을 생성한다.
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
  • 클라이언트가 보호된 경로 또는 리소스에 접근하려고 할 때마다, 클라이언트는 일반적으로 Bearer 스키마를 사용하여 Authorization 헤더 에서 JWT를 보내야 한다.
    서버는 Authorization헤더에 유효한 JWT가 있는지 확인한다. 서버의 보호된 경로는 Authorization헤더에 유효한 JWT가 있는지 확인하고 JWT가 있는 경우 사용자는 보호된 리소스에 액세스할 수 있습니다.

2. 과정


사용자가 로그인을 한다.

  1. 서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.

  2. JWT 토큰의 유효기간을 설정한다.

  3. 암호화할 Secret key 를 이용해 Access Token 을 발급한다.

  4. 사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.

  5. 서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.

  6. 검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.

3. 설정

  1. jwt를 사용한다면 세션은 사용하지 않으므로
    SessionCreationPolicy.STATELESS로 설정해준다.
    현재 스프링 시큐리티에서 세션을 관리하지 않겠다는 뜻이다.

CSRF 토큰도 사용하지 않으므로 이것도 비활성화 한다.

csrf.disable
  1. properties에 토큰을 발급하는데에 필요한 사람, 시크릿 키, 헤더 값 등을 설정하고 @Value로 값을 가져와서 사용한다
jwt.issuer=example@gmail.com
jwt.secretKey=secretKey
jwt.tokenPrefix=Bearer

  @Value("${jwt.issuer}")
  private String issuer;
  @Value("${jwt.secretKey}")
  private String secretKey;
  @Value("${jwt.tokenPrefix}")
  private String tokenPrefix;
  • 이러면 Authorization이 key가 되고 accessToken이 value가 된다. request.getHeader("Authorization")로 토큰을 가지고 온다.
jwt:
  secret: QUIWEYWEIUSDJKAFHLSZVCNASJDKCNZXCQIWE4982WIRJKLFVJJIWR894513ASD4A6S5D4AD54A5SD13ZC1X2AD4W56D5Q6WZ3ZXC1
  access:
    expiration: 80000
    header: Authorization

  refresh:
    expiration: 1200000
    header: Authorization-refresh

이후 필요한 것은 다음과 같다.

  • 토큰 생성 메서드
  • 토큰의 헤더를 검증하는 메서드,
  • 토큰의 값만 가지고 올 수 있게 대가리를 떼는 메서드,
  • 헤더를 분리하고 Token 값을 claims로 바꿔주는 메서드
  • 모든 과정을 통과하면 유저 객체를 만들어준다.
  1. 다음은 토큰을 생성하는 메서드이다.
public String makeJwtToken(User user) {  //토큰 생성
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + Duration.ofMinutes(30).toMillis()))
                .claim("email", user.getEmail())
                .claim("role", user.getRole())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }
  1. 토큰의 머리를 떼는 메서드이다.
    HTTP 인증 토큰은 클라이언트가 서버에 요청을 보낼 때, 해당 요청에 대한 인증 정보를 담은 헤더값으로 사용됩니다. 일반적으로 HTTP 인증 토큰은 "Authorization"이라는 이름의 헤더에 담겨 전송됩니다. 예를 들면 "Authorization: Bearer {access_token}"과 같은 형태로 사용됩니다. 즉 Authorization 헤더에 들어가는 값의 prefix이다

redis에서도 prefix를 사용하는데 이는 동일한 Redis 서버를 사용하는 다른 어플리케이션에서 발생하는 키(Key) 중복을 막기 위해서이다.
Redis에서 엑세스 토큰의 Prefix를 "access_token:" + memberId으로 설정하면, Redis에 저장되는 실제 키(Key)는 "access_token:{memberId}:{access_token}"의 형태가 된다.

  private String extractToken(String authorizationHeader) { 
  //토큰 (Bearer) 떼고 토큰값만 가져오는 메서드
        return authorizationHeader.substring(
        jwtProperties.getTokenPrefix().length());
    }
  1. 떼어낸 토큰의 머리가 유효한지 검증하는 메서드이다.
   private void validationAuthorizationHeader(String header) { 
   //헤더값이 유효한지 검증하는 메서드

        if (header == null || !header.startsWith(jwtProperties.getTokenPrefix())) {
            logger.error("토큰이 없습니다.(1)");
        }
    }
  1. 머리를 뗀 토큰을 파싱하는 메서드이다.
private Claims parsingToken(String token) { 
//Token 값을 claims로 바꿔주는 메서드
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)  <- 토큰 몸통
                .getBody();
    }
  1. JwtTokenProvider에서 Token 값을 추출하여 UserDto 객체를 만들때 사용하는 메서드 (토큰의 페이로드에서 클레임을 가져옴)
 public UserDto getUserDtoOf(String authorizationHeader) {  
 // 토큰에서 유효값을 추출하기위한 메서드
 
        validationAuthorizationHeader(authorizationHeader); 
        //토큰이 Bearer로 시작하는지 형식이 맞는지 확인
        
        String token ="";
        Claims claims=null;

        try {
            token = extractToken(authorizationHeader); 
            // header에서 토큰 추출 (Bearer뗌)
            
            claims = parsingToken(token);
            return new UserDto(claims);
        }catch (Exception e){
            logger.error("토큰이 없습니다.(2)");
        }
        return null;
    }
  1. 이제 jwt토큰의 유효성을 검사하기 위한 필터를 생성한다.
    스프링 시큐리티가 인증을 확인하는 단위는 매 Request 요청이 일어날 때이다. 매 요청마다 확인하기 위해서는 Controller 로직을 수행하기 이전에 로직인 Filter를 이용하면 좋다. Filter는 Request 요청마다 한번씩 호출하는 OncePerRequestFilter을 상속합니다.
    즉, 시큐리티 인증 과정에 request 요청이 올 때마다 accessToken을 검증하기위해 요청당 단일 실행을 보장하는 필터를 집어넣는 것이다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {  
// 요청을 할떄마다 한번 거쳐가는 필터


   @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws IOException, ServletException {
        //filter에서 header를 가져옴
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        try {
            //token 값에서 유효값 (email, role)을 추출하여 userDTO를 만들어서 값을 세팅
            UserDto user = jwtTokenProvider.getUserDtoOf(authorizationHeader);
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
                    user,
                    "",
                    user.getAuthorities()));

            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException exception) {
            logger.error("ExpiredJwtException : expired token");
        } catch (Exception exception) {
            logger.error("Exception : no token");
            filterChain.doFilter(request, response);
        }
    }

엑세스 토큰의 유효시간이 경과했더라도 리프레쉬 토큰이 유효하다면 새로운 엑세스 토큰을 발급받을 수 있다

case1: access token과 refresh token 모두가 만료된 경우 -> 에러 발생
case2: access token은 만료됐지만, refresh token은 유효한 경우(사용자가 보낸 Refresh Token과 DB에 저장되어있는 Refresh Token을 비교한다.) -> access token 재발급, 토큰 업데이트
case3: access token은 유효하지만, refresh token은 만료된 경우 -> refresh token 재발급, 저장, 토큰 업데이트
case4: accesss token과 refresh token 모두가 유효한 경우
case5: 로그아웃시 accesss token과 refresh token 모두 삭제

로그인에 성공했다면 successHandler로 JWT토큰을 발급하는 authenticationsuccesshandler를 만든다

4. 서명

  • 대부분의 서명 알고리즘이 바이트 배열의 비밀키를 입력받기 때문에, JWT 토큰에 서명하기 위해서는 문자열 형태의 비밀 키이진 형태의 바이트 배열로 변환해야 합니다.

4-1 서명 알고리즘

  1. HS256, HS384, HS512: HMAC 기반의 서명 알고리즘으로 비밀 키를 사용합니다. 상대적으로 빠르고 간단하지만 비밀 키가 노출될 경우 보안에 취약할 수 있습니다.

  2. RS256, RS384, RS512: RSA 기반의 서명 알고리즘으로 공개 키와 개인 키 쌍을 사용합니다. 보안성이 높지만 연산량이 많습니다.

  3. ES256, ES384, ES512: ECDSA 기반의 서명 알고리즘으로 타원 곡선 암호화를 사용합니다. RSA보다 짧은 키 길이로 높은 보안을 제공합니다.

  4. Ed25519: 최신의 효율적이고 안전한 서명 알고리즘입니다. 높은 성능과 보안성을 제공합니다.

5. 블랙리스트

유저가 로그아웃시 access token의 남은 유효기간만큼 Redis에 유효기간을 설정하여 블랙리스트로 등록해놓는다. 토큰 값 자체를 key로 두고, value로 logout이라는 값을 줬습니다. 이때 블랙리스트로 등록하는 액세스 토큰에 유효시간을 요청시 받은 엑세스토큰의 남은 유효시간만큼 줍니다. 이렇게 되면 로그아웃된 엑세스 토큰으로 요청이 들어왔을 때, 해당 토큰의 유효성이 남아있는 동안은 Redis에 해당 토큰 값이 key로 블랙리스트 등록되어 있을 것이기 때문에 로그인을 할 수 없습니다.

  • Cannot call sendError() after the response has been committed
    이 에러는 이미 response가 전송되어 클라이언트 측에서 응답을 받았지만, 다시 sendError()를 호출하여 응답을 보내려는 시도가 있기 때문에 발생합니다. 즉, 이미 응답을 보낸 후에 다시 응답을 보내려는 것이기 때문에 오류가 발생합니다.

위 코드에서는 블랙리스트에 있는 경우 403 오류 응답을 보내는 것입니다. 그러나, 만약 다른 필터에서 이미 응답을 보냈다면, response가 이미 커밋되어 응답을 보낸 상태이므로, 다시 sendError()를 호출하면 이러한 오류가 발생합니다.

0개의 댓글