JWT 토큰 개념정리/구현해보기

mylime·2024년 5월 27일
0

JWT

목록 보기
1/3
post-thumbnail

이 게시글은 2024.05.28에 쓴 포스트입니다.


이전에 글쓴이는 사용자의 로그인을 처리하기 위해 JWT를 사용해본적이 있다. 세션방식과 달리 stateless한 특징을 가지고 있어서 서버의 부담이 적어진다고 알고있었는데, 로그아웃이나 토큰이 탈취됐을 때 등 생각해볼 부분이 많았었다.

이번 포스트에서 jwt토큰의 개념을 확실하게 정리하고 간단하게 구현해보려고 한다.


JWT 정의


JSON Web Token(JWT)은 두 당사자 간의 claims를 안전하게 표현하기 위한 개방형 업계 표준 RFC 7519 방법입니다. (jwt.io 사이트)

jwt.io 사이트와 RFC사이트의 정의를 찾아보면 JWT를 다음과 같이 정의한다

  • JSON Web Token의 줄임말로, 두 당사자 간에 전달될 클레임을 표현하는 간편하고 URL-safe한 수단
  • HTTP Authorization 헤더와 URI 쿼리 파라미터와 같은 공간이 제한된 환경에서 사용하기 위해 설계된 간결한 클레임 표현 형식
  • JSON 객체로 클레임 세트를 나타내는 문자열로, JSON Web Signature(JWS) 또는 JSON Web Encryption(JWE)로 인코딩되어 클레임을 디지털 서명하거나 MAC으로 보호하고/하거나 암호화할 수 있음

요약해보면 JWT는 JSON형식으로 클레임이라는 것을 담고있고, 암호화가 되어있는 문자열이라고 생각할 수 있다.

클레임이 무엇인지는 아래에서 자세히 설명하겠다


+) ✅ URL-Safe의 의미

JWT토큰은 문자열 형식으로 헤더or바디에 넣을 수도 있지만 url에 파라미터형식으로도 보낼 수 있다. 그러므로 url로 보낼 때 정상적으로 전송될 수 있는 문자로만 구성되어야한다.

JWT토큰의 각 부분은 Base64로 인코딩된다고 알려져있다. base64란 컴퓨터가 8비트의 이진데이터를 ASCII 코드로 바꾸는 인코딩 방식이다. 하지만 base64형태의 문자열을 웹으로 전송하는 경우 +(62)와 /(63) 글자가 포함되어 있어 정상적으로 전송되지 않는다. 이 문제를 해결하기 위해 Base64url인코딩이 등장하였다. Base64url은 +(62)와 /(63)를 -(62)와 _ (63)으로 변경하여 보낸다.
JWT는 url-safe를 보장하기 위해 Base64url인코딩을 사용한다.


+) JWT 발음방법

부트캠프 강사님과 내 친구들 모두 "제이더블유티"라고 발음하는데, RFC문서에는 다음과 같이 적혀있다.

The suggested pronunciation of JWT is the same as the English word "jot".

...나는 그냥 부르던대로 부르려고 한다.



JWT 구조


JWT 토큰은 세 부분으로 구성되며, 모두 Base64url로 인코딩된 문자열로 표시된다. 이 세 부분은 마침표를 기준으로 연결된다.

  • 토큰의 만료 날짜, 서명에 사용되는 알고리즘 및 추가 메타데이터가 포함된 헤더
  • JSON 페이로드
  • 헤더와 페이로드에 서명하여 생성된 서명

jwt.io 사이트에서 JWT토큰의 내부 값을 쉽게 확인할 수 있다.


🙄 이렇게 쉽게 내부값을 볼 수 있으면 위험한 거 아닌가?

사실 JWT토큰은 JSON 객체를 암호화하여 서명한 base64표현에 지나지 않는다. 토큰만 있다면 누구나 내부에 들어있는 정보를 볼 수 있기 때문에 중요한 정보를 토큰에 포함한다면 위험할 수 있다.


💌 claim이란?

주체에 대해 주장된 정보의 조각입니다. 클레임은 클레임 이름과 클레임 값으로 구성된 이름/값 쌍으로 표현됩니다. (RFC 7519)

claim은 jwt 토큰의 페이로드에 포함되는 정보라고 생각할 수 있다. 유저의 아이디나 이메일 등 식별자가 될 수도 있고, 토큰이 발행된 시간 등 다른 값이 될 수도 있겠다.

클레임의 이름은 항상 문자열이고, 클레임의 값은 JSON 값이 될 수 있다. JWT에서 클레임 세트는 JWS 및/또는 JWE 구조로 인코딩된 JSON 객체로 표현된다.

클레임의 예시는 다음과 같음

  • iss: JWT를 발행한 주체
  • sub: JWT의 주체, 고유한 값이여야함
  • exp: 토큰의 만료시간
  • iat: 토큰 발행 시간


+) 생소한 용어정리

  • JWT Claims Set
    : JSON 객체로 클레임 세트를 나타내는 문자열. JWS 또는 JWE로 인코딩되어 클레임을 디지털 서명하거나 MAC으로 보호하고/하거나 암호화할 수 있다
  • Nested JWT
    : 중첩 서명 및/또는 암호화가 사용되는 JWT. Nested JWT에서는 JWT가 외부 JWS 또는 JWE 구조의 페이로드 또는 평문 값으로 사용됨
  • Collision-Resistant Name
    : 다른 이름과 충돌할 가능성이 매우 적도록 이름을 할당할 수 있는 네임스페이스의 이름. Collision-Resistant 네임스페이스의 예로는 도메인 이름, ITU-T X.660 및 X.670 권고 시리즈에 정의된 객체 식별자(OID), 그리고 유니버설 고유 식별자(UUID) [RFC4122]가 있다.
  • JWS(JSON Web Signature)
    : JSON 데이터 구조를 사용하는 서명 표준. JSON으로 전자 서명을 하여 URL-safe 문자열로 표현한 것 (RFC7515)
  • JWE(JSON Web Encryption)
    : JSON 데이터 구조를 사용하는 암호화 방법. JSON을 암호화하여 URL-safe 문자열로 표현한 것 (RFC7516)

JWS와 JWE


💥 JWT 내부의 JSON 객체는 JWS 구조의 페이로드로 사용되거나 JWE 구조의 평문으로 사용된다

JWT의 클레임은 암호화되거나 서명으로 무결성이 보장된다. 디지털 서명을 하는 방식은 JWS 방식이고, 암호화하는 방식은 JWE 방식이다.
JWT는 인터페이스 역할을 해주는 추상적인 개념이고, 실제 구현은 JWS와 JWE로 나누어진다.


디지털 서명의 경우 claim의 내용이 노출되지만, 서명을 이용하여 원본이 맞는지 무결성을 파악할 수 있다.
반면, 암호화 방식은 claim 자체를 암호화시켜 내용을 파악할 수 없다. 클라이언트가 claim의 데이터를 사용하려면 디지털 서명 방식을 사용해야한다. 따라서 대부분 JWT라고 하면 JWS를 가리킨다.

(jwt.io 사이트도 claim의 내용을 그대로 볼 수 있기 때문에 JWS를 구현했다고 볼 수 있다)

JWT는 항상 JWS Compact Serialization 또는 JWE Compact Serialization을 사용하여 표현되며, 이는 클레임을 디지털 서명하거나 메시지 인증 코드 (MAC)로 무결성을 보호하고, 암호화할 수 있게 해준다



🧐 Bearer의 의미?


토큰을 보내는 방법 중 Authorization 필드에 토큰을 담아 보내는 방법이 자주 사용된다.
Authorization: <type> <credentials>

이 경우 위와같이 type을 명시해주는데, Bearer는 OAuth와 JWT에 해당하는 토큰 type을 의미한다. 다양한 종류의 토큰을 처리하기 위해 type을 구분한다.


<type>필드에는 다음과 같은 타입이 들어올 수 있다.

  • Basic: 사용자 아이디와 암호를 Base64로 인코딩한 값을 토큰으로 사용한다. (RFC 7617)
  • Bearer: OAuth Access Token Type 등록. JWT 혹은 OAuth에 대한 토큰을 사용한다. (RFC 6750)
  • Digest: 서버에서 난수 데이터 문자열을 클라이언트에 보낸다. 클라이언트는 사용자 정보와 nonce를 포함하는 해시값을 사용하여 응답한다 (RFC 7616)
  • HOBA: 전자 서명 기반 인증 (RFC 7486)
  • Mutual: 암호를 이용한 클라이언트-서버 상호 인증 (draft-ietf-httpauth-mutual)
  • AWS4-HMAC-SHA256: AWS 전자 서명 기반 인증



토큰을 보내는 방법(Client → server)


헤더 Authorization필드에 Bearer를 붙여 주고받는 방법 외에도 다른 방법이 존재한다! RFC문서에서는 클라이언트가 서버에 요청 시 토큰을 전달하는 방법은 3가지가 있다고 하였다(RFC 6750 섹션2)

사실 마지막 방법은 문서에만 기록되어 있는 방법이고, 두 번째 방법도 특별한 경우가 아니면 사용해서는 안된다고 말한다. 사실상 첫 번째 방법인 Bearer를 사용하는 방법이 권장되는 것 같다.


1. Request Header의 Authorization 필드 사용

"Authorization" 요청 헤더에 액세스 토큰을 보낼 때 HTTP/1.1 [RFC2617]에 의해 정의된 필드에서 클라이언트는 "Bearer"를 사용합니다. 액세스 토큰을 전송하기 위한 인증 체계입니다. (RFC 6750)

Bearer를 붙이는 방법은 이 방법에 해당하며, 가장 일반적인 방법이다.

예시)

GET /resource HTTP/1.1
     Host: server.example.com
     Authorization: Bearer mF_9.B5f-4.1JqM

2. Form-Encoded Body 파라미터

HTTP request entity-body에 액세스 토큰을 보낼 때, 클라이언트는 "access_token" 파라미터를 사용하여 request-body에 엑세스토큰을 추가합니다.

요청 body에 토큰을 실어보내는 방법이다. body 내에서 파라미터를 사용하여 accessToken임을 알려준다.

RFC문서에서는 클라이언트는 다음 조건이 모두 충족되지 않는 한 이 방법을 사용해서는 안 된다고 한다!

  • Http request Entity-header는 "Content-Type" 헤더를 포함하고 있어야하고, 이 필드의 값은 "application/x-www-form-urlencoded"여야함.
  • Entity-body는 "application/x-www-form-urlencoded" 인코딩 요구사항을 따른다. (HTML 4.01 [W3C.REC-html401-19991224]에 정의된 content-type)
  • HTTP requset entity-body는 single-part여야한다.
  • Entity-body에 인코딩될 콘텐츠 전체는 ASCII [USASCII] 문자로 구성되어야함.
  • Http request method는 request-body의 정의된 의미가 있는 것이다. 특히 이는 "GET"메서드는 절대 사용되지 않음을 의미함.

Entity-body에는 엑세스 토큰 외에도 다른 request-specific 파라미터가 포함되어 있을 수 있다. 이 경우 "access_token" 파라미터는 "&"문자(ASCII
code 38)를 사용하여 request-specific 파라미터로부터 적절하게 분리되어야 한다.

예시)

POST /resource HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded

access_token=mF_9.B5f-4.1JqM

"application/x-www-form-urlencoded" 메서드는 참여하는 브라우저가 "Authorization" 요청 헤더 필드에 접근할 수 없는 경우를 제외하고는 사용되어서는 안된다.


3. URI Query 파라미터(권장 x)

액세스 토큰을 HTTP 요청 URI에 보낼 때, 클라이언트는 "Uniform Resource Identifier (URI): Generic Syntax" [RFC3986]에 정의된 대로 "access_token" 파라미터를 사용하여 요청 URI 쿼리 구성 요소에 액세스 토큰을 추가하는 방법

쿼리 파라미터에 토큰을 실어보내는 방법이다.
HTTP 요청 URI 쿼리에는 다른 request-specific 파라미터가 포함될 수 있으며, 이 경우 "access_token" 매개변수는 request-specific 파라미터와 "&" 문자(ASCII 코드 38)를 사용하여 올바르게 구분되어야 한다.

예시)

GET /resource?access_token=mF_9.B5f-4.1JqM HTTP/1.1
Host: server.example.com

url 전문)
https://server.example.com/resource?access_token=mF_9.B5f-4.1JqM&p=q


URI 쿼리 파라미터 방법을 사용하는 클라이언트는 "no-store" 옵션이 포함된 Cache-Control 헤더도 필수로 전송해야 한다! 이러한 요청에 대한 서버 성공(2XX 상태) 응답은 "private" 옵션이 포함된 Cache-Control 헤더를 필수로 포함해야한다.

😥 한계점

  • 액세스 토큰이 포함된 URL이 로그에 기록될 가능성이 높은 것을 포함해 URI 방법과 관련된 보안 취약성(섹션 5 참조) 이 존재함
  • 그러므로 "Authorization" 요청 헤더 필드나 HTTP 요청 엔터티 본문에 액세스 토큰을 전달할 수 없는 경우를 제외하고는 사용해서는 안 된다. (리소스 서버가 필수적으로 지원해야하는 건 아님)




😊 직접 만들어보자!


build.gradle

//jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

jwt와 관련된 의존성을 추가해준다


application.yml

jwt:
	secretKey: 7D4A614E645267156B58703273357638792F423F44B8472B4B6250655360566D
    #1분
	expiredMs: 60000
	#리프레쉬 토큰 만료시간(선택) 
	expiredRefreshMs: 1000000
@Value("${jwt.secretKey}")
private String secretKey;

@Value("${jwt.expiredMs}")
private Long expiredJwtMs;

yml에 만료시간과 secretKey를 넣어준다. 불러올 때는 @Value로 주입받으면 쉽게 받을 수 있다.


+) 이 예제에서 jwt토큰은 HS256으로 암호화가 될 건데, secretKey가 너무 짧을 경우에는 다음과 같은 오류가 날 수 있으니 충분히 긴 문자열을 넣어줘야한다!

The signing key's size is 40 bits which is not secure enough for the HS256 algorithm.
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS256 MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).
Consider using the io.jsonwebtoken.security.Keys class's 'secretKeyFor(SignatureAlgorithm.HS256)' method to create a key guaranteed to be secure enough for HS256.
See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.


JwtUtil 만들기

  1. JWT 토큰 만드는 메서드
public static String createJwt(String secretKey, Long expiredMs, String memberEmail) {
	Claims claims = Jwts.claims();
	claims.put("email", memberEmail);

	String token = Jwts.builder()
		.setClaims(claims)
		.setIssuedAt(new Date(System.currentTimeMillis()))
		.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
		.signWith(SignatureAlgorithm.HS256, secretKey)
		.compact();

	return token;
}
  • claim에는 유저를구분할 수 있는 email을 하나 넣어준다. (key값 email, value는 실제 이메일)
  • iat 클레임에 들어갈 발행시간과
  • exp 클레임에 들어갈 만료시간을 정해준다.
  • 서명은 HS256 알고리즘과 secretKey를 사용하여 만들어준다

만들어진 토큰을 까보면 다음과 같이 잘 저장된 걸 볼 수 있다. 까보는 사이트: jwt.io

  1. 유효기간 만료 체크 메서드
public static boolean isExpired(String token, String secretKey) {
	//token = token.substring(7); //Bearer 제외(선택사항)

	return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
                .getBody().getExpiration().before(new Date());
}
  • 토큰의 expiration을 빼와서 현재 시간보다 이전인지 확인한다
  • 만약 유효기간이 지났으면 401을 반환하도록 예외처리 해주면 되겠다



마치며


아직도 알아보지 못한 내용들이 많다. 시간이 되면 이 포스트에 추가사항을 넣어보려고 한다.
이전에는 생각없이 사용만 했는데 찾다보니 아직 많이 부족하다는 게 느껴진다.



더 알아보기 좋은 글

https://velog.io/@dae-hwa/JWTJSON-Web-Token-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0
https://datatracker.ietf.org/doc/html/rfc7516#section-3.3
https://jwt.io/introduction

참고자료

https://jwt.io/
https://datatracker.ietf.org/doc/html/rfc7519
https://growth-coder.tistory.com/166
http://www.opennaru.com/opennaru-blog/jwt-json-web-token/
https://velog.io/@dae-hwa/JWTJSON-Web-Token-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0
https://velog.io/@cada/%ED%86%A0%EA%B7%BC-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D%EC%97%90%EC%84%9C-bearer%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C
https://velog.io/@nnoshel/base64%EC%99%80-base64-url-safe

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

0개의 댓글