본문 바로가기
인증과 인가

JWT를 직접 구현하면서 토큰 인증 방식에 대해 알아보자!

by NewCodes 2024. 10. 13.

 

안녕하세요!

NewCodes입니다!

 

이번에는 JWT에 대해 다뤄보겠습니다!

 

JWT는 토큰 인증 방식 중에 하나인데요. 

 

 

이 글을 읽으시기 전에

사전 지식으로 다음 글을 추천드려요!

 

인증과, 인가 더 이상 헷갈리지 말자

세션 인증 방식, 이 글 하나로 끝내자!

 

인증과 인가, 세션에 대해 잘 알고 계시다면

이번 글도 수월하게 읽으실 수 있을 거예요~

 

 

최대한 이해를 돕기 위해 

직접 만든 그림도 있답니다~ 😁

 

그러면 바로 시작해볼게요!

 


🪙 '토큰'이 뭐지?

우선 토큰이 뭘 의미하는 걸까요? 

 

https://www.newsprime.co.kr/news/article/?no=283985

 

지하철 탈 때 이러한 토큰 보신 적 있나요? (요새는 거의 카드를 쓰긴 하죠 ㅎㅎ)

 

이 토큰을 통해 우리는 지하철을 탈 권리가 있음을 증명할 수 있습니다. 

 

이외에도 토큰이라는 단어는 정말 여러 가지 맥락에서 쓰일 수 있습니다.

 

인증 과정 맥락에서 쓰이는 토큰의 의미는 '인증 정보'를 담고 있는 작은 데이터라고 보시면 됩니다. 

 

이 토큰은 사용자에게 웹을 사용할 수 있는 권리를 주는 것과도 같아요.

 

사용자는 각자의 토큰을 통해 회원임을 증명할 수 있고, 웹을 정상적으로 이용할 수 있게 됩니다.

 

 


🧐 JWT는 무슨 토큰일까? 

JWT가 토큰임을 알아봤는데요. 그러면 이제 JWT가 어떤 토큰인지 아는 게 중요하겠죠!

 

https://jwt.io/

 

JWT는 2015년 5월에 RFC 7519에서 정의되었습니다. 이후로 인기있는 인증 메커니즘 중의 하나로 자리 잡았습니다. 참고로 RFC는 Request for Comments의 약자로, 주로 인터넷 표준과 프로토콜을 정의하는 문서입니다. 

 

JWT는 사용자 인증과 권한 부여에 사용되는 
JSON 형식의 토큰입니다. 

 

 

JWT는 JSON Web Tokens의 약자입니다. 이름에서 보시다시피 JWT는 정보를 담을 때 JSON 형식을 사용합니다. 또한, 에서 인증/인가를 구현할 때 주로 사용되죠. 

 

JWT의 정체성이자 가장 큰 장점은 'Stateless(무상태성)'입니다. 

 

세션과 비교를 하면 금방 와닿으실 텐데요. 세션은 서버에서 유저 정보를 sessionID와 매핑해서 저장하고 있어야 했습니다. 하지만, JWT를 사용하게 되면 서버에서 별도로 저장할 정보가 없습니다. (stateless한 HTTP의 정신과도 잘 어울리죠.)

 

또한 기본적으로 로그인 이후의 전체적인 플로우세션 방식과 유사합니다. 로그인을 했을 때 토큰을 발급하고, 이후 요청에서 토큰을 함께 보내는 방식입니다. 

 

 

JWT는 어떻게 사용자를 식별할 수 있는 걸까요?

어떻게 인증된 사용자임을 검증할 수 있는 걸까요?

 

지금부터 이에 대해 자세히 알아보시죠!

 

 


🛠️ JWT 직접 구현하기

개구리를 해부하지 말고, 직접 만들어라.
- 니콜라스 네그로폰테 박사

 

어떤 걸 잘 이해하기 위해서는, 이를 해부해서 내부를 들여다보는 것도 좋은데요. 그것보다 더 좋은 것이 직접 만들어보는 것입니다. 직접 만들어보면 볼 때는 다른 시각으로 접근할 수 있고, 미처 몰랐던 걸 알게 될 때가 많아요.

 

이를 이번에도 적용하여 JWT를 직접 만들어보겠습니다. 그리고 JWT가 어떤 토큰인지도 정리해보겠습니다. 

 

 


Step 1.  토큰에 사용자 식별 정보 담기 

JWT는 stateless하다고 했었죠. 서버에서는 로그인한 사용자의 정보를 별도로 저장하지 않습니다. 

 

처음에는 서버가 빈 깡통의 토큰을 사용자에게 주었다고 가정해볼게요! 
그리고 토큰에 어떤 정보가 필요한지 차근차근 채워나가볼게요!

 

빈 깡통의 토큰과 함께 요청

 

 

이때, 서버에서는 당연히 누구의 토큰인지를 알 수가 없습니다. 

 

사용자를 식별하기 위해서는 토큰 내에 사용자 정보를 담을 수밖에 없습니다.

 

드디어 누가 보낸 요청인지 알아낸 서버

 

 

JWT에서는 이러한 사용자 정보payload에 담습니다. 참고로 payload는 JWT에서만 쓰이는 용어가 아닙니다. payload는 '전송되는 데이터' 자체를 의미해요. 보통 전송되는 데이터에서 중요한 정보, 의미 있는 정보를 payload라고 부르곤 합니다. 

 

아래에는 제가 직접 작성한 JWT 발급 함수 중의 일부분(payload)을 가져왔어요!

 

const payload = base64UrlEncode(JSON.stringify({
  id: user.getId(), 
  nickname: user.getNickname(),
  role: user.getRole(),
  iat: Date.now(),
  exp: Date.now() + TOKEN_EXPIRE,
  iss: DOMAIN,
  sub: ACCESS_TOKEN,
}));

 

이처럼 payload에 userId와 같이 unique한 사용자 정보를 담을 수 있어요. 그러면 서버에서는 토큰을 열어보고 누가 보낸 요청인지 알아낼 수 있답니다! 또한 JWT에서는 payload를 위와 같이 JSON 형식으로 담는답니다! 

 

payload 관련해 정리하고 다음으로 넘어가겠습니다. 

 

  1. 서버에서는 요청을 받으면 누가 보낸 건지를 알아내기 위해, 토큰을 발급할 때 사용자 정보를 미리 담아두어야 합니다.
  2. 그리고 JWT에서는 이를 payload라고 합니다. 
  3. payload는 암호화된 게 아니라 Base64URL로 인코딩만 된 것이기에 사용자가 직접 열어볼 수 있습니다. 

 

<Base64URL 인코딩이란?>
또한 JSON 문자열 그대로를 전달하는 게 아니라, Base64URL 인코딩을 해서 전달합니다. 이는 Base64 인코딩의 변형으로, 주로 URL이나 파일 이름에서 안전하게 사용할 수 있게 설계된 인코딩 방식입니다. 

아래 코드를 보시면 해당 규칙처럼 문자를 대체한 걸 볼 수 있는데요.

'+'를 '-'로 대체
'/'를 '_'로 대체
'='를 ''로 대체

이렇게 대체하는 이유는 '+', '/', '='와 같은 문자열이 URL이나 파일 이름에서 특별한 의미를 가질 수 있어 문제가 될 수 있기 때문입니다. 
function base64UrlEncode (data) {
  const base64 = Buffer.from(data).toString('base64');
  const base64Url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  return base64Url;
};

 

 


Step 2. 믿을 수 있는 토큰인지 확인하기

그런데 위처럼 토큰에 사용자 식별 정보만 담아서 토큰을 주고받으면 문제점이 있어요! 

 

예를 들어 사용자가 발급받은 토큰에서 role을 user에서 admin으로 임의로 바꾼다고 해봅시다.

(role은 인가를 뜻합니다. user는 일반 사용자를, admin은 관리자를 뜻해요!)

 

admin으로 바꾸어 보낸 악의적인 요청

 

 

이러면 서버에서는 admin 권한이 있는 사용자라고 판단하게 됩니다.

 

홀라당 속아 넘어간 서버

 

즉, 사용자는 admin인 척 악의적인 요청을 보낼 수 있게 되죠. 

 

만약 사용자가 payload를 조작하면 서버에서는 이를 알아차릴 수가 없습니다. 아주 심각한 문제이죠. 이 문제를 해결하기 위해서는 payload가 위변조되지는 않았는지 확인하기 위한 장치가 필요합니다.

 

JWT는 signature을 통해 이를 해결해요. 토큰에 사인을 하는 겁니다.

 

예를 들어, 계약서에 사인을 하는 것과도 유사해요.

 

NewCoder가 서명한 계약서

 

계약서에 사인을 하게 되면 법적인 효력이 생기고, 유효한 계약서가 되는 것이죠. JWT도 비슷합니다. 

 

 

 

사인을 통해 유효한 계약임을 나타내는 것처럼, 토큰에 사인을 해서 유효한 토큰임을 나타내는 거죠! 그런데 위처럼 단순히 NewCodes라고 이름만 사인해두면, 누구나 쉽게 따라하겠죠? 🤣

 

 

JWT에서는 사인을 어떻게 하는지 구체적인 코드를 살펴보시죠!

 

function signToken (header, payload) {
  const hmac = crypto.createHmac('sha256', TOKEN_SECRET_KEY);
  const signature = hmac.update(header + '.' + payload).digest('hex');
  return signature;
};

 

이는 token을 sign하는 함수입니다. 여기서는 HMAC-SHA256이라는 알고리즘을 사용해요. 이 부분은 살짝 생소하실 수 있으니 천천히 읽어보시는 걸 권해요.

 

 

HMAC

 

  • HMAC-SHA256: HMACSHA256이 결합된 암호화 알고리즘입니다.
    • HMAC: Hash-based Message Authentication Code비밀키해시함수를 통해 메시지인증코드를 만들어냅니다.
    • SHA256: Secure Hash Algorithm 256-bit로 256 비트 길이의 해시 값을 생성하는 해시 함수입니다. 

 

HMAC 작동 과정에 대해 예를 들어보겠습니다.

 

HMAC 작동 과정 예시

 

그림을 자세히 살펴보시는 걸 추천드려요! 어느 정도 감이 오시나요? 

 

HMAC에서는 Secret Key가 핵심입니다!! Secret Key는 안전하게 서버만 알고 있는 키로써, Message가 위변조되지 않았는지를 검사하는 역할을 합니다. 

 

아까 예시를 통해서 살펴보면요.

admin으로 바꾸어 보낸 악의적인 요청

 

이처럼 사용자가 토큰을 변조하는 경우가 생겨도 서버에서는 이제 이를 알아차릴 수 있습니다. 

 

이제는 방어할 줄 아는 서버

 

 

아직 토큰을 어떻게 검증하는지 감이 안 오실 수도 있어요. 코드를 통해 살펴봐요! 'newSignature'에 주목해주세요!

 

function verifyToken (token, now = Date.now()) {
  const [header, payload, signature] = token.split('.');
  const newSignature = signToken(header, payload);

  if (signature !== newSignature) {
    throw new AuthenticationError('유효한 토큰이 아닙니다.');
  }

  const decodedPayload = JSON.parse(base64UrlDecode(payload));
  if (decodedPayload.exp < now) {
    throw new AuthenticationError('토큰이 만료되었습니다.');
  }

  return decodedPayload;
};

function signToken (header, payload) {
  const hmac = crypto.createHmac('sha256', CONFIG.TOKEN_SECRET_KEY);
  const signature = hmac.update(header + '.' + payload).digest('hex');
  return signature;
};

 

서버에서는 토큰이 들어오면 해당 토큰을 바탕으로 다시 sign을 해봅니다. 그러고 반환된 signature 값이 토큰에 있는 signature 값과 다르다면 변조되었다는 걸 알 수 있어요!!

 

 

사용자 입장에서는

Message(Payload)를 변경한다고 한들,

Secret Key를 모르기 때문에 

해당 토큰에 적합한 Signature를 작성할 수가 없어요. 

 

 


Step 3. 토큰 자체에 대한 정보를 담기

위에서 토큰을 sign하기 위해 SHA256이라는 해시 알고리즘을 사용했어요! 물론 해당 알고리즘뿐만 아니라 여러 알고리즘이 사용될 수 있습니다. 

 

그러면 서버에서는 토큰을 받았을 때 검증하려면 어떤 알고리즘으로 만들어진 토큰인지를 우선 알아야겠죠? 그래서 JWT에서는 토큰 자체에 대한 메타데이터header라는 곳에 담습니다. 

 

const header = base64UrlEncode(JSON.stringify({
  alg: 'HS256',
  typ: 'JWT',
}));

 

 

위 코드에서 보시다시피 header에는 주로 알고리즘토큰 유형 두 가지 정보를 담아요. 이는 표준화, 확장성 등을 위해 관용적으로 고정된 듯합니다. 참고로 JWT 공식 문서를 보면 header를 이렇게 설명하고 있어요. 

 

The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HMAC SHA256 or RSA.

 

 

마지막으로 JWT는 header, payload, signature를 'header.payload.signature' 형식으로 포맷합니다. 그래서 서버 입장에서는 쉽게 파싱할 수도 있어요!

 

https://cloud.google.com/apigee/docs/api-platform/security/oauth/using-jwt-oauth?hl=ko

 

 


⭐️ 정리하기

JWT를 발급하고 검증하는 코드를 첨부할게요!

참고로 제 코드에서는 HS256 알고리즘만 사용함을 가정하고 작성한 겁니다. 

import crypto from 'crypto';
import AuthenticationError from '../../error/customError/AuthenticationError.js';

function issueToken (user) {
  const header = base64UrlEncode(JSON.stringify({
    alg: 'HS256',
    typ: 'JWT',
  }));
  const payload = base64UrlEncode(JSON.stringify({
    id: user.getId(),
    nickname: user.getNickname(),
    role: user.getRole(),
    iat: Date.now(),
    exp: Date.now() + TOKEN_EXPIRE,
    iss: DOMAIN,
    sub: ACCESS_TOKEN,
  }));
  const signature = signToken(header, payload);

  return `${header}.${payload}.${signature}`;
};

function verifyToken (token, now = Date.now()) {
  const [header, payload, signature] = token.split('.');
  const newSignature = signToken(header, payload);

  if (signature !== newSignature) {
    throw new AuthenticationError('유효한 토큰이 아닙니다.');
  }

  const decodedPayload = JSON.parse(base64UrlDecode(payload));
  if (decodedPayload.exp < now) {
    throw new AuthenticationError('토큰이 만료되었습니다.');
  }

  return decodedPayload;
};

function signToken (header, payload) {
  const hmac = crypto.createHmac('sha256', TOKEN_SECRET_KEY);
  const signature = hmac.update(header + '.' + payload).digest('hex');
  return signature;
};

function base64UrlEncode (data) {
  const base64 = Buffer.from(data).toString('base64');
  const base64Url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  return base64Url;
};

function base64UrlDecode (base64Url) {
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((base64Url.length + 3) % 4);
  const decodedData = Buffer.from(base64, 'base64').toString('utf-8');
  return decodedData;
};

 

 

아래는 제가 작성한 코드를 통해 발급해본 사진입니다. 

 

 

 

그리고 JWT를 디코딩해보면 아래처럼 나옵니다. 

 

 

 

해당 글에서 다룬 내용을 요약하고 마무리하겠습니다!

 

  1. 토큰 인증 방식 중에 가장 많이 사용되는 건 JWT이다. 
  2. JWT는 JSON Web Token의 약자로, 사용자 인증과 권한 부여에 사용되는 토큰이다. 
  3. JWT의 정체성은 stateless이다. 기본적으로 서버에서 별도로 상태를 저장할 필요가 없다. 
  4. JWT는 header, payload, signature로 이루어져 있다.

 

다음 글에서는

브라우저에서 JWT를 저장하는 곳,

JWT가 주로 사용되는 곳,

refresh token의 필요성

에 대해 알아보겠습니다!

 

 

읽어주셔서 감사합니다!! 😊

 


📚 레퍼런스