Spring🌱

JWT 토큰 분석 extractClaim() 함수로 payload 추출

전감자(◔◡◔) 2024. 11. 18. 17:49

spring 에서 로그인 로직을 JWT 토큰을 이용한 방식으로 구현중인데 

토큰 필터 부분에서 유효성 검사 후 JWT 에서 사용자 정보를 추출 하는 로직이 

잘 이해가 되지 않아서 정리해보려 한다. 

 

 

JWT 토큰의 개념부터 상세히 알고 싶다면 해당 글을 참조 

 

쿠키 vs 세션 vs JWT 토큰 방식의 개념에 대해 알아보자

SNS 서비스의 회원가입과 로그인 기능을 구현하는 중  Auth 에 대해서 알게되었다 . 서버에서 사용자를 어떻게 기억하고 인증할까?  서버가 쿠키에 사용자 정보를 저장해두고 이를 세션으로 유

vinyee.tistory.com


@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter { // 이 필터는 모든 HTTP 요청마다 한 번씩 실행됨 (Spring Security의 필터 체인에 등록)

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 필터 내부를 구현하는 메서드 (모든 요청마다 실행됨)

        // 1. 요청 헤더에서 Authorization 헤더를 가져옴
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. Authorization 헤더가 없거나 "Bearer "로 시작하지 않으면, 로그를 남기고 필터 체인의 다음 필터로 넘어감
        if (header == null || !header.startsWith("Bearer ")) {
            log.error("Error occurs while getting header. header is null or invalid");
            filterChain.doFilter(request, response); // 다음 필터로 진행
            return; // 더 이상 처리하지 않음
        }

        try {

            // 3. "Bearer " 이후의 실제 토큰 값을 추출
            final String token = header.split(" ")[1].trim(); // "Bearer " 이후의 문자열을 가져옴

            // 4. 토큰 유효성 검사
            // TODO: check token is valid (토큰이 만료되었거나 유효하지 않은지 확인)



            // 5. 추출한 토큰에서 사용자 이름을 가져옴
            // TODO: get username from token
            String userName = ""; // 토큰에서 사용자 이름 추출

            // 6. 사용자 이름이 유효한지 확인
            // TODO: check the userName is valid

            // 7. 인증된 사용자 정보를 생성하여 SecurityContext에 저장
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    // 실제 사용자 정보 (userDetails)와 권한 리스트를 추가할 수 있음
                    // 현재는 인증 객체를 null로 설정 (사용자 정보와 권한이 없기 때문)
                    null, null, null
            );

            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        } catch (RuntimeException e) {
            // 9. 예외가 발생한 경우 로그를 남기고 필터 체인의 다음 필터로 진행
            log.error("Error occurs while validating. {}", e.toString());
            filterChain.doFilter(request, response);
            return;
        }

        // 10. 정상적인 경우, 필터 체인의 다음 필터로 요청을 전달
        filterChain.doFilter(request, response);
    }
}

 

 

Spring Security 의 Authentication Config 에 들어갈 JWTTokenFilter 의 구현 과정이다. 

 

일단 매 요청 마다 요청에 담겨 있는 해더를 가져오고 , 해더에서 token 문자열만 분리하여 유효성 검사를 진행해야하는데 

 

public class JwtTokenUtils{


    public static boolean isValid(String token){


    }

    // 유효성 검사를 위한 (= expired 됐는지 검사) private 매소드
    private static boolean isExpired(String token){
        return
    }

    // expired 됐는지를 알려면 claim 을 빼와야함
    private static Claims extractClaims(String token , String key){
        return Jwts.parserBuilder().setSigningKey(getKey(key))
                .build().parseClaimsJwt(token).getBody();
    }


    public static String generateToken(String userName, String key, long expiredTimeMs){
        // userName 을 이용하여 JWT 토큰 생성
        Claims claims = Jwts.claims();
        claims.put("userName",userName);

        // 토큰 리턴
        return Jwts.builder()
                .setClaims(claims) // userName을 이용해만든 claim
                .setIssuedAt(new Date(System.currentTimeMillis())) // 토큰 생성 시간
                .setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs)) // 토큰 파기 시간
                .signWith(getKey(key), SignatureAlgorithm.HS256) // 암호화 key와 알고리즘
                .compact();

    }

    private static Key getKey(String key){
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);

    }


}

 

위 코드에서 extractClaim 부분이 이해가 잘 가지 않았다 . 아무래도 JWT 토큰이 어떤 형태로 이루어져있는지 , claim 이란게 

어떤 건지 잘몰라서 그랬던 것 같다. 

 

JWT 토큰 세부 구조 

 

JWT 토큰은 크게 세 부분으로 구성된 문자열이다. 이 문자열은 각각 .로 구분되는데:

<Header>.<Payload>.<Signature> 이런식이다. 
 
Header, Payload, Signature 가 각각 base64 로 인코딩되어 긴 문자열로 나열되어 있는데 사실 원래 형태로 보면 
 
 
{ "alg": "HS256", "typ": "JWT" }. { "userId": "1234567890", "name": "John Doe", "roles": ["USER", "ADMIN"], "exp": 1718993799 }. HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

 

 

=>  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZXMiOlsiVVNFUiIsIkFETUlOIl0sImV4cCI6MTcxODk5Mzc5OX0.lB7K9L4BTVTpMw5sfQZ1EvPnsC2CNpZjrh1S73Fp-R4

 

이런 느낌인 것이다. 

 

  • Header: 토큰 타입(JWT)과 알고리즘 정보(HS256 등)
  • Payload: 토큰에 포함된 정보(Claims)로, 사용자 ID, 만료 시간(exp), 역할 등과 같은 데이터
  • Signature: Header와 Payload를 결합한 뒤, 서버에서 설정한 **비밀 키(Secret Key)**로 서명한 값

 

각 부분을 뜯어보도록 하자 

a. Header

 
{ "alg": "HS256", "typ": "JWT" }
  • alg: 서명 알고리즘 (여기서는 HMAC SHA-256 사용)
  • typ: 토큰 유형 (여기서는 JWT)

Header 부분은 Base64로 인코딩되어 JWT 토큰의 첫 번째 부분(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)으로 나타남


b. Payload (Claims)

{ "userId": "1234567890", "name": "John Doe", "roles": ["USER", "ADMIN"], "exp": 1718993799 }
  • userId: 사용자 ID
  • name: 사용자 이름
  • roles: 사용자 역할 (예: USER, ADMIN)
  • exp: 만료 시간(Unix timestamp)

Payload 부분도 Base64로 인코딩되어 JWT 토큰의 두 번째 부분(eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZXMiOlsiVVNFUiIsIkFETUlOIl0sImV4cCI6MTcxODk5Mzc5OX0)으로 나타남


c. Signature

 
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
  • 서버의 **비밀 키(secret key)**를 사용하여 Header와 Payload를 기반으로 생성된 서명
  • Signature는 JWT 토큰이 변조되지 않았음을 보장하기 위해 사용

Signature 부분은 JWT 토큰의 마지막 부분(lB7K9L4BTVTpMw5sfQZ1EvPnsC2CNpZjrh1S73Fp-R4)

 

Claims를 추출한다는 것의 의미는 무엇일까? 

 

Claims를 추출한다는 것은 JWT 토큰에서 Payload 부분에 담긴 정보를 꺼내오는 것을 의미

 

즉 사용자와 관련된 이 정보를 추출해오는 것이다. 

  • userId: "1234567890"
  • name: "John Doe"
  • roles: ["USER", "ADMIN"]
  • exp: 1718993799 (만료 시간)

 

 

이걸 알고 다시 claim 추출 부분을 다시 보면 

public static Claims extractClaims(String token) {
    return Jwts.parserBuilder()
               .setSigningKey(getKey(SECRET_KEY))
               .build()
               .parseClaimsJws(token)
               .getBody(); // Payload (Claims) 부분을 추출
}

 

 

  1. Jwts.parserBuilder():
    • JWT를 파싱할 수 있는 빌더 객체를 생성
    • 이 빌더를 통해 토큰을 해석하고 검증할 준비를 함.
    • 토큰을 검증하기 위해 서명에 사용된 비밀 키를 설정합니다.
    • getKey() 메서드는 주어진 문자열(SECRET_KEY)을 이용해 Key 객체를 생성합니다.
    • 이 비밀 키는 토큰을 발급할 때 사용한 것과 동일한 값이어야 합니다. 그래야만 해당 토큰이 서버에서 생성된 것임을 검증할 수 있습니다..setSigningKey(getKey(SECRET_KEY)):
       
      • 저는 이부분이 가장 의아했는데요 payload 를 파싱한다면서,,, 단순히 그냥 JWT 에서 paylaod 부분을 가져와서 인코딩되어 있는걸 디코딩하면 되는거 아닌가? 왜 여기서 또 키를 넣어서 검증을 하지? 아직 유효성 검사 하는 부분이 아닌데,,, 라는 생각을 했습니다. 
      • 파싱할 때 key 가 필요한 이유는 바로 요청을 통해 받은 header + payload 로 새 사인을 만들어서 요청을 통해 받은 signature 부분이랑 비교하기 위해서 입니다!
      • 이 새로 만든 사인이랑 signature 랑 일치해야만 유효한 토큰이라고 간주하고 header 와 payload를 디코딩 해서 return 해주는 것이지요! 
  2. .build():
    • 빌더를 사용하여 실제 파서(Parser)를 생성합니다.
    • 이 파서를 이용해 JWT 토큰을 해석하고 검증할 수 있습니다.
  3. .parseClaimsJws(token):
    • 입력된 JWT 토큰(token)을 검증 및 해석합니다.
    • 이 단계에서:
      • 토큰의 서명이 유효한지 확인합니다.
      • 토큰이 변조되지 않았는지 검증합니다.
      • 토큰이 만료되었는지(exp 클레임)를 확인합니다.
    • 유효한 토큰이 아니면 예외(JwtException)를 발생시킵니다.
  4. .getBody():
    • 토큰에서 Payload(Claims) 부분을 가져옵니다.
    • 여기서 Claims에는 사용자가 설정한 정보(userId, name, roles 등)와 만료 시간(exp) 등이 포함됩니다.

Claim 을 가져온 결과 
//extractClaims() 메서드를 사용하면:
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0IiwibmFtZSI6IkphbmUiLCJleHAiOjE3MTk5OTM3OTl9.-zJjSPU9h5Cyyjr5NShnNp9riG7vGQG8jOWzkD8dWco";
Claims claims = extractClaims(token);

System.out.println("User ID: " + claims.get("userId"));  // 결과: User ID: 1234
System.out.println("Name: " + claims.get("name"));       // 결과: Name: Jane
System.out.println("Expiration: " + claims.getExpiration()); // 만료 시간 출력

 

 

이제 claim 을 추출하였으니 이를 바탕으로 토큰이 만료되었는지 , 사용자가 유효한지 알아보는 로직을 짜보겠습니다