JWT에 RSA 암호화 방식 적용하기

MOON HEE·2023년 4월 25일
0

트러블 슈팅

목록 보기
25/25

1. JWT(JSON Web Token)

JWT는 정보를 안전하게 전달하기 위한 자가수용적인 방식의 토큰이다.

<<자가수용적(self-contained)>>이란?
토큰의 페이로드에 필요한 모든 정보를 담고 있는 성질을 의미한다. 즉 토큰 자체로 토큰의 유효성을 완전하게 검증할 수 있다.

그러면 토큰 자체에 모든 정보가 담겨 있지 않은 방식은 무엇이 있나?
👉 세션 기반 인증방식이 있다.
세션 기반 인증방식은 서버 측에서 세션 상태를 유지해야만 클라이언트가 인증 작업을 처리할 수 있다.


1) JWT ⊂ Token 기반 인증 시스템

JWT는 Token 기반 인증 시스템에 속한다.
Token 기반 인증 시스템에서는 클라이언트가 서버에 접속하면, 서버에서 해당 클라이언트에게 인증되었다는 의미로 Token을 부여한다.

Token은 유일하다. Token을 발급받은 클라이언트는 또 다시 서버에 요청을 보낼 때 요청 헤더에 토큰을 심어서 보낸다.

로그인 시 토큰을 발급받는다고 했을 때, 해당 사용자가 게시판에 글을 쓰거나 작성한 글을 삭제할 때 서버는 요청을 보낸 클라이언트의 신원을 확인해야 할 것이다. 즉 토큰은 사용자를 식별하는 신분증 같은 것이다.

마지막으로 서버에서는 클라이언트가 준 Token을 가지고 있는 Token과 같은 지 체크한다.


2) 일단 JWT 생성해보기

JWT의 구조를 살펴보기 전에 냅다 한번 만들어보면 어떤 값이 암호화되는 것인지 쉽게 이해할 수 있다. 우선 jsonwebtoken 라이브러리를 활용해 jwt를 생성할 것이므로 패키지를 다운 받자.

$ npm i jsonwebtoken

암호화를 위해 Node.js의 기본 모듈인 Crypto를 사용할 수도 있다. Crypto는 저수준 API를 제공하기 때문에 라이브러리를 사용하는 것이 더 간단해서 jsonwebtoken이 많이 쓰인다.

📑 Crypto 문서 바로가기
📑 jsonwebtoken nodejs 문서 바로가기
🚀 Crypto로 구현한 블로그 추천!


공개키(=메시지 봉인)와 비밀키(=자물쇠 열쇠) 발급받기

RSA는 비대칭 키를 사용하는 양방향 알고리즘에 속한다.
비대칭 암호화 방식에는 키가 2개 필요하다. (공개키와 비밀키)
공개키와 비밀키에 대해 대략적으로 이해하기에 적합한 예시가 위키백과에 있었다!

A에게 B가 메시지를 전하고자 할 때, B는 A의 열린 자물쇠를 들고 와 그의 메시지를 봉인(공개키 암호화 과정에 해당)하고, 그런 다음 A에게 전해 주면, 자물쇠의 열쇠(개인키에 해당)를 가지고 있는 A가 그 메시지를 열어보는(개인키 복호화 과정에 해당) 식이 된다. 중간에 그 메시지를 가로채는 사람은 그 열쇠를 가지고 있지 않으므로 메시지를 열람할 수 없다.

전체 암호화 알고리즘에서 RSA의 위치는 이렇다. 이 도식에 있는 양방향 알고리즘 중에 RSA만 소인수분해의 어려움을 이용한다(나머지는 이산대수를 활용한다).

jwt 라이브러리에서는 기본 암호화 방식으로 해시 알고리즘을 사용한다. RSA는 해시보다 안전하지만 key 길이가 길면 그만큼 컴퓨터 자원을 많이 사용하기 때문에 적합한 암호화 방식을 선택하는 것은 개발자의 선택사항으로 남아 있다.


공부는 이쯤하고 키를 발급받으러 가자! 👇
https://travistidwell.com/jsencrypt/demo/

1. 위 사이트에서 Key Size를 2048bit로 설정하고 Generate New Keys를 클릭한다.

2. 해당 키를 .env 파일에 저장한다.

저장 시 직렬로 저장해주자! 멀티라인으로 저장할 수도 있지만 직렬로 저장하는 것을 추천하는 이유는 배포환경에서의 환경변수와 개발환경에서의 환경변수를 동일하게 맞춰주기 위해서다.

배포할 플랫폼에 환경변수를 입력해야 하는데, 위와 같이 한줄로 입력해야 한다(위 플랫폼은 클라우드타입이다). 동일하게 동작하도록 하기 위해 똑같이 만들어 주는 것이 좋다.

// 직렬로 바꿔주는 방법
const privateKey = `생
					성
					된
					코
					드
					붙
					여
					넣
					기`;

console.log(privateKey.replace(/\n/g, '\\n'));
// -----BEGIN PUBLIC KEY-----\nMIG...\nBF...\ntn...=\n-----END PUBLIC KEY-----

JWT 생성 메서드 만들기

이제 사용자 데이터 중 _id의 값을 사용하여 토큰을 발급해보자.

아직 DB와 서버를 세팅하지 않았다면 인프런의 '따라하며 배우는 노드, 리액트 시리즈'를 참고해서 빠르게 세팅할 수 있다!

👉 강의 관련 링크

jwt.sign 메서드 사용 방법

jwt.sign(페이로드, 비밀키[, 옵션, callback])
  • 페이로드에 객체나 문자 타입이 들어갈 수 있다.
  • 콜백함수가 포함되면 비동기적으로 동작한다.
import jwt from 'jsonwebtoken';

const privateKey: string = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');

const publicKey: string = process.env.PUBLIC_KEY.replace(/\\n/g, '\n');

...

jwt.sign(
    { id: _id.toHexString(), iat: Date.now(), issuer: 'green-maps' },
    privateKey,
    {
      algorithm: 'RS256',
      expiresIn: 365 * 24 * 60 * 60, // 초 단위,
    },
    async function (err, token) {
      if (err) {
        return cb(new Error('암호화 에러'));
      }

      user.token = token;

      await user
        .save()
        .then((user: any) => cb(null, user))
        .catch((err: any) => cb(err));
    }
  );
  • iat는 issue at의 약자로 토큰을 발행한 시점이다.
  • expiresIn은 토큰을 만료할 시점이다. 숫자형으로 작성하면 '초' 단위이고 문자열로 작성하면 "3d"와 같은 형식으로도 작성할 수 있다.

로그인 시 토큰 생성 확인

로그인 시 토큰을 쿠키에 저장하는 코드이다.

route.post('/login', async (req: Request, res: Response) => {
  try {
    const { userId, password, keepLogin } = req.body;
    const user = await User.findOne({ userId: userId });

    if (!user) return res.json({ success: false, errMessage: '사용자가 존재하지 않습니다.' });

    user.comparePassword(password, (err: Error | null, same: boolean | null) => {
      if (!same) {
        return res.json({ success: false, errorMessage: '비밀번호가 일치하지 않습니다' });
      } else {
        user.generateToken((err?: Error | null, user?: any) => {
          if (err) return res.status(400).send(err);
          else {
              res.cookie('auth', user.token, {
                maxAge: 7 * 24 * 60 * 60 * 1000,
                httpOnly: true, // 서버에 의해서만 접근
                secure: true, // https
                sameSite: 'strict', // cors 비허용
                domain: 'localhost',
              })
                .status(200)
                .json({ success: true, user: user });
        });
      }
    });
  } catch (err) {
    if (err instanceof Error) {
      res.json({ success: false, errorMessage: err.message });
    }
  }
});

그동안 프론트 엔드에서 컨트롤하기 쉬운 로컬 스토리지에 보관하고 Authorization 헤더에 담아서 요청하는 방식을 사용해 왔었다. 이번에는 NodeJS 코드를 작성하기 때문에 쿠키를 처음 사용해봤다.

CSRF 보다 XSS를 예방하는 게 우선인 것 같아 로컬 스토리지 보다는 쿠키에 보관하는 것이 더 안전하고, 서버 자원에 의존하지 않도록 세션을 이용하지 않았다. 직접 사용해보니 클라이언트 코드 작성 시 요청 헤더에 토큰을 담지 않아도 된다는 점이 개발을 편하게 해줬다!

  • 장점: XSS 공격으로부터 안전하다.
    • 쿠키의 httpOnly 옵션을 사용하면 서버 코드에서만 쿠키에 접근할 수 있다.
  • 단점: CSRF 공격에 취약하다.
    • 공격자가 Request URL만 알면 사용자가 링크를 클릭하도록 유도해서 Request를 위조하기 쉽다.

🤨 잘 생성이 된건가..?? 확인하기 위해 jsonwebtoken 디버그 사이트를 이용한다.

생성된 토큰을 Encoded에 붙여넣고, DecodedVERIFY SIGNATURE에 공개키와 비밀키를 넣어준다. 그러면 왼쪽 하단에서 유효한 서명인지 확인할 수 있다.

유효한 토큰이 잘 생성됐다! 이제 이 데이터를 토대로 JWT의 구조를 파악해보자.


3) JWT 구조

결과 Decoded에서 확인할 수 있듯이 JWT는 점으로 구분되는 헤더-페이로드-서명확인의 구조로 이루어져 있다.

이 중 헤더와 페이로드는 Base64로 인코딩된 JSON 형식의 데이터이다. Base64는 주로 사진, 동영상과 같은 이진 데이터를 텍스트 형식으로 변환하는데 사용하는 인코딩 방식이다. 인증 메커니즘에서 사용될 때는 인증 정보를 안전하게 전송하기 위해 사용된다.

출처: 위키피디아

토큰의 유형("typ": "JWT")과 사용하는 알고리즘에 대한 정보("alg": "RS256")가 담겨 있다. JSON 형식으로 작성된다.

Payload

JWT의 페이로드는 토큰에 포함되는 정보를 담고 있다. 페이로드도 헤더와 마찬가지로 JSON 형식으로 작성된다. 페이로드에 있는 키-값 쌍을 클레임(claim)이라고 한다.

클레임은 토큰에 대한 메타데이터나 사용자 정의 데이터를 포함한다.

  • 등록된 클레임 (Registered Claims): 토큰의 일반적인 정보를 나타내는 클레임으로, 발급자(iss), 만료 시간(exp), 발급 시간(iat) 등이 있다.
  • 공개 클레임 (Public Claims): 사용자 정의 클레임으로, 토큰을 사용하는 어플리케이션들끼리 사전에 협의하여 사용할 수 있는 클레임이다. 예를 들어, 사용자의 이름, 이메일, 역할 등을 포함할 수 있다.
  • 비공개 클레임 (Private Claims): 토큰을 생성한 당사자들끼리 사전에 협의하여 사용할 수 있는 클레임이다.

Signature

헤더와 페이로드를 조합한 후 지정된 알고리즘을 사용하여 서명한 부분이다. 서명은 토큰의 무결성과 변조 여부를 확인하는 데 사용된다.


4) auth 미들웨어로 등록하기

import { Response, NextFunction } from 'express';
import User from '../models/User.js';

const auth = (req: Request, res: Response, next: NextFunction) => {
    const token = req.cookies.auth;

    User.findByToken(token, (err: Error | null, user?: any) => {
        if (err) {
            return res.json({ isAuthenticated: false, errorMessage: err.message });
        } else if (!user) return res.json({ isAuthenticated: false, errorMessage: '🚨 2해당 유저가 없습니다.' });

        req.token = token;
        req.user = user;

        next();
    });
};

export default auth;

5) 미들웨어 사용

북마크를 추가하는 api에 auth 미들웨어를 사용하는 코드이다.

route.post('/bookmark', auth, async (req: Request, res: Response) => {
    try {
      const user = await User.findOneAndUpdate(
        { token: req.token },
        {
          $push: {
            bookmarkList: {
              _id: req.body._id,
            },
          },
        },
        { new: true }
      );

      res.status(200).json({ success: true, user: user });
    } catch (err) {
      console.error(err);
    }
});

6) 로그인 요청 후 응답 메시지

Set-Cookie에 쿠키에 대한 정보가 담겨 있다. 로그인 후에 토큰을 생성하고 클라이언트에 쿠키를 전송하는 헤더이다.

7) 북마크 요청 메시지

쿠키 정보가 필요한 요청의 경우 요청 메시지에 클라이언트의 쿠키가 담겨서 서버로 전송된다.

profile
자고 일어나면 해결되는게 더 많은 듯 그럼 잘까

0개의 댓글