공부하기/ETC

JWT 찐하게 이해하기 👀 | JWT 구성과 인증 원리

다섯자두 2025. 3. 13. 01:47

많이 듣고 많이 사용했던 JWT

싹 다 정리해보고 이전 과제때부터 마주했던 JwtUtil 클래스도 좀 살펴보려고 한다.

 

JWT와 Session을 통한 인증

JWT와 Session 모두 웹 서비스에서 사용자의 인증 상태를 유지하는 방법이다.

익숙한 말로는 로그인을 유지하는 방법이라고도 할 수 있겠다.

 

HTTP의 stateless한 특성으로 인해 사용자의 인증 정보를 유지하는 기술이 필요하고, 이를 위해 Session 혹은 JWT를 이용할 수 있다.

► Session 인증 방식

세션 인증 방식을 순서 매기자면 다음과 같다.

  1. 사용자 최초 로그인 시, 서버는 세션을 생성하여 인증 정보를 세션 저장소(별도 서버)에 저장한다.
  2. 서버는 세션 생성 시 발급한 sessionId를 클라이언트에 Cookie에 포함하여 응답한다.
  3. 클라이언트는 이후 Cookie에 sessionId를 포함하여 요청을 보낸다.
  4. 서버는 sessionId로 세션을 확인하여 사용자를 인증하고, 세션 저장소에서 사용자 인증 정보(ex. userId, role)를 가져와 사용한다.

즉, 로그인 후 유지할 상태 데이터가 서버 측에 저장되는 방식이다.

JWT와 Session 인증 방식의 큰 차이점은 바로 이 ``데이터의 저장 위치``이다.

► JWT 인증 방식

인증 방식을 순서 매기자면 다음과 같다.

  1. 사용자 최초 로그인 시, 서버는 인증 정보를 포함하여 JWT를 발급한다.
  2. 서버는 클라이언트에 응답 시 발급한 JWT를 전달한다.
  3. 클라이언트는 이후 ``Authorization`` 헤더에 JWT를 포함하여 요청을 보낸다.
  4. 서버는 JWT 서명을 검증하여 사용자를 인증하고, JWT에 포함된 사용자 인증 정보((ex. userId, role)를 사용한다.

즉, 서버가 상태 데이터를 저장하지 않는다.

토큰 자체에 유지할 데이터를 함께 포함시킨 후 클라이언트 측에서 토큰을 유지하도록 하는 방식이다.

서버는 토큰의 유효성을 검증하는 알고리즘만 동작시키면 될뿐, 별도로 상태를 저장할 저장소를 유지하지 않아도 된다.


✔️ Session를 통한 로그인 인증 방식
- 인증 : sessionId로 세션 조회, 유효한 세션이 있으면 인증 성공
- 인증 정보 사용 : sessionId로 조회한 세션에 저장된 데이터 사용

✔️ JWT를 통한 로그인 인증 방식
- 인증 : JWT 서명을 검증하여 유효한 정보인지 확인
- 인증 정보 사용 : JWT에 포함된 데이터 사용

 

JWT의 구성

이해한 내용 짧게만 정리해보자

자세한 내용은 여기 참고
  • Header, Payload, Signature로 구성되어 있다.
  • 세 파트가 dot(.)으로 이어져 있는 구조이다.

► Header 

{
  "alg": "HS256",
  "typ": "JWT"
}
  • JWT인만큼 JSON 형태로, 주로 위 코드에서 보이는 두 필드로 구성된다.
  • `alg` : JWT의 세 번째 구성요소인 Signature를 만들 때 사용되는 암호화 알고리즘을 명시한다.
  • `typ` : 해당 토큰의 타입을 명시하는 것으로 JWT이니까 JWT라고 명시한다.

해당 JSON 내용이 Base64Uri 인코딩 되어 JWT의 첫 번째 파트를 형성한다.

{"alg": "HS256", "typ": "JWT"} → Base64Uri 인코딩 → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

► Payload

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
  • 역시 JSON 형태로, 사용자 정보와 추가 데이터를 담는 부분이다.
  • 여기 담기는 데이터를 Claim이라 부른다.
    • 데이터를 누가 정의했는지, 어디에서 사용하는 지에 따라 3가지 종류로 나뉜다.
    • 개발자가 임의로 넣는 로그인 유지 데이터는 Public Claim에 해당한다. (ex. userRole, nickname 등등)

해당 JSON 내용이 Base64Uri 인코딩 되어 JWT의 두 번째 파트를 형성한다.

► Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
  • 앞서 나온 Header,Payload를 secret key를 사용하여 서명 알고리즘을 돌린 값이다.
  • 위 코드는 서명 알고리즘으로 HMAC + SHA256을 사용한 예이고, 이 외에도 HMAC + SHA-384, RSA + SHA-256 등등 다양하게 사용하는 것 같다.

이 Signature, 서명값을 검증해서 JWT가 유효한지를 판별한다. 


✔️ JWT의 구성
- ``Header.Payload.Signature``

ex) ``eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.MzXXc7P0HPPzPLlXY21kGLBKTdYzq3bdhz76hn8fBYk``

1. Header : Base64 URL 인코딩됨
2. Payload : Base64 URL 인코딩됨
3. Signature : 암호화 알고리즘으로 서명됨

 

그래서 뭘 어떻게 검증한다는건데?

로그인 정보가 들어가는 Payload 파트는 Base64 URL로 인코딩이 되어 token에 그대로 들어간다.

즉, 디코딩 시 기존 JSON 형태의 데이터를 바로 확인할 수 있다. 따라서 절대로 민감한 정보를 포함해서는 안 된다.

 

이처럼 JWT는 로그인 정보를 암호화하지 않는다.

다만 서명을 통해 Payload가 변조되지 않았음을 확인한다.

서명
- 데이터의 무결성 보장 및 송신자 인증
- 데이터가 변경되지 않았음을 증명
암호화
- 데이터의 기밀성 보장
- 데이터를 제 3자가 읽을 수 없게 함
👀 JWT(JSON Web Token)는 JWS(JWT + 서명) 또는 JWE(JWT + 암호화)가 될 수 있다.
JWE는 Payload를 암호화하여 보호하는 것으로, 서명이 없다.
그렇지만 일반적으로 JWT는 JWS를 의미한다.

 

아래에 이해한 내용을 간단히 정리해봤다.

► JWT 생성

  • ``Header + Payload``와 비밀키를 사용하여 해싱 함수를 돌리고, 결과값을 얻는다. (=> Signatrue 값)
  • 이 Signature 값으로 JWT를 구성한 뒤 클라이언트에 전달한다.

► JWT 검증

  • 전달된 JWT의 ``Header + Payload``와 비밀키를 사용하여 해싱 함수를 돌리고, 결과값을 얻는다.
  • 해당 값과 전달된 JWT의 Signature 값이 동일한지 확인한다. 
    • 동일하지 않다면 Header 혹은 Payload의 값이 변조된 것임
  • 이후 Payload에 만료 기간 정보 등을 넣어놨다면 비교해보고 invalid 처리를 하는 등의 작업을 수행한다.

 

JwtUtil 뜯어보기

그렇다면 그동안 사용했던 JwtUtil을 한 번 뜯어보자

► 코드 보기

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String email, String nickname, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .claim("nickname", nickname)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        throw new ServerException("Not Found Token");
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}
  • 서명에 사용하는 알고리즘으로는 HS256(HMAC SHA-256)를 사용한다.
  • `createToken`
    • JWT를 생성한다. 
    • claim으로 여러 데이터를 넣고 있다. userId를 포함하여 email, userRole, nickname, expiration 등등
    • `signWith`으로 JWT Signature을 만들 때 사용할 서명 알고리즘과 비밀키를 전달한다.
  • `extractClaims`
    • 클라이언트로부터 전달된 JWT를 검증하고 Claim을 추출한다.
    • `setSigningKey`로 서명 검증에 사용할 비밀키를 전달한다.
      • ``Q1.`` 왜 서명 알고리즘은 전달하지 않을까? 
      • ``A1.`` Header에 명시되어 있기 때문 
    • `parseClaimsJws`
      • JWS (JWT + Signature)
      • JWT 서명을 검증하고 Payload를 해석한다.
      • 검증 실패 시 여러 Exception 발생
    • `getBody`
      • Payload(Claims)를 추출한다.

 

► 서명 알고리즘 HS256(HMAC SHA-256)

궁금한걸 어찌하리 !! 짧게 알아보자

 

메세지를 주고 받을 때 메세지 인증 방법으로 사용할 수 있는 것으로 MDC, MAC이 있다.

🔽 MDC(Modification Detection Code), 변경 감지 코드

해당 메세지가 변경되지 않았음을 보장하기 위해 사용하는 것이다.

전달할 메세지 `M`이 있다면 해싱 함수에 넣어 얻은 결과값인 `H(M)`을 MDC로 사용한다.

  • 송신측에서 별도의 안전한 채널로 `H(M)`을 전달한다.
  • 수신측에서는 전달받은 메세지로 `H(M)` 값을 얻고, 전달받은 MDC와 비교하여 변경 여부를 확인한다.

해싱 함수는 input 값이 달라지면 아예 다른 값을 얻으므로 이를 통해 메세지가 변경되었는지를 감지하는 것이다.

🔽 MAC(Message Authentication Code), 메시지 인증 코드

MDC를 통해서는 해당 메세지가 수정되지 않았음은 확인할 수 있지만, 그 송신자가 인증된 자인지는 확인할 수가 없다.

메세지 변경 감지에 송신자 인증까지 수행하기 위해 사용하는 것이 MAC이다. 

인증을 위해 송신자와 수신자만 아는 비밀키를 생성한다. 이 비밀키를 가진 사람인가? 를 확인해서 인증을 한다고 보면 된다.

전달할 메세지 `M`이 있다면 특정 비밀키 `K`와 함께 넣어 얻은 결과값인 `H(K|M)`을 MAC으로 사용한다.

  • 송신측에서 `M`과 함께 `H(K|M)`을 전달한다.
  • 수신측에서는 전달받은 `M`과 자신이 가진 `K`로 `H(K|M)` 값을 얻고, 전달받은 MAC과 비교하여 변경 여부를 확인한다.
MDC와 MAC 모두 해시 함수를 기반으로 만들어지는 것이 일반적이지만, 반드시 해시 함수를 사용해야 하는 것은 아니다.

 

HMAC은 MAC이긴 MAC인데 Hashed MAC이다.

메세지를 b 비트 블록으로 분할하고 키를 b 비트로 패딩하고 어떤 상수와 XOR 연산을 한 후에 해싱함수를 적용해서 중간 HMAC을 만들고 어쩌구저쩌구 복잡하다. 

암호학 공부를 하는 중은 아니므로 자세히 알 필요는 없을 것 같고, 해시 함수를 여러 번 적용해서 이를 기반으로 만들어진 MAC이라고 보면 될 듯하다.

 

어쨌거나 HMAC SHA-256에서 HMAC은 메시지 인증 코드를 생성하는 방식을, SHA-256은 해당 코드 생성시 사용하는 해싱 알고리즘을 뜻한다.

😤 속이 다 시원하다