노알못 Node.js 정리: JWT 인증을 활용한 Express 서버 만들기

Table of Content

이 글은 Node.js 교과서(조현영 저) 라는 책으로 공부한 내용을 정리한 글입니다. 보안 쪽은 좀 더 보완이 필요할 듯 싶습니다…

JWT(JSON Web Token)

JWT는 JSON 형식의 데이터를 저장하는 토큰입니다. 헤더.페이로드.시그니처 세 부분으로 구성되어 있습니다.

  • 헤더: 토큰 종류, 해시 알고리즘 정보
  • 페이로드: 토큰 내용물이 인코딩된 부분. 내용을 볼 수 있으므로 민감 정보는 넣지 않아야 함.
  • 시그니처: 토큰 변조 유무 확인을 위한 일련의 문자열. 시그니처는 숨기지 않아도 되나 JWT 비밀키로 생성되므로 비밀키는 반드시 숨겨야 함.

장점

  • 서버는 JWT 토큰 검증만 하면 되기 때문에 서버에 별도의 저장소를 마련할 필요가 없습니다.
  • JWT 비밀키를 알지 않는 이상 위변조가 불가능하므로 내용물이 바뀌지 않았는지 걱정할 필요가 없습니다. 다만 외부에 노출되어도 좋은 정보에 한해서 사용하는 것을 권장합니다.

단점

  • 한 번 발급된 JWT 토큰은 유효기간이 만료될 때까지 사용이 가능합니다. 유효기간이 지나기 전에 탈취된 토큰이 악용될 수 있습니다.
  • 내용(페이로드)이 암호화되지 않으므로 누구나 내용을 볼 수 있습니다.
  • 세션/쿠키 방식에 비해 JWT 토큰의 길이가 기므로 토큰 발급 및 검증 요청이 많아질수록 서버의 자원 낭비가 발생할 수 있습니다.

준비

npm 패키지 설치

필요한 npm 패키지는 express-generator, dotenv, jsontokenweb 입니다.

  • express-generator는 Express 서버 프로젝트를 자동으로 생성해줍니다.
  • dotenv는 환경변수를 쉽게 사용할 수 있도록 해줍니다.
  • jsontokenweb은 JWT 인증을 사용하기 위해 필요합니다.

npm i express-generator dotenv jsontokenweb 명령어를 실행하여 필요한 npm 패키지를 설치합니다.

Postman 설치

JWT 토큰 발급 시 POST 요청을, 발급된 토큰을 테스트할 때 GET 요청을 할 겁니다. 이런 요청을 보내고 테스트하기 위해 필요한 프로그램이 Postman입니다.

JWT 토큰 발급

환경변수

JWT 인증에 사용할 비밀키를 환경변수에 등록해둡니다. 비밀키는 소스코드에 입력해도 되나 외부에 쉽게 노출되지 않도록 환경변수에 등록하여 관리하는 것이 좋습니다.

Express 서버 프로젝트의 루트 디렉토리에 .env 파일을 생성한 후 JWT 인증에 사용할 비밀키를 입력해줍니다. 아무렇게나 입력해도 됩니다.

JWT_SECRET=JwTsEcReTkEyOrHaShInG

토큰 유효성 검증

JWT 토큰이 유효한지 검사하는 메서드를 만듭니다.

/*** routes/middlewares.js ***/

const jwt = require('jsonwebtoken');

exports.verifyToken = (req, res, next) => {
  // 인증 완료
  try {
    // 요청 헤더에 저장된 토큰(req.headers.authorization)과 비밀키를 사용하여 토큰 반환
    req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
    return next();
  }

  // 인증 실패
  catch (error) {
    // 유효기간이 초과된 경우
    if (error.name === 'TokenExpiredError') {
      return res.status(419).json({
        code: 419,
        message: '토큰이 만료되었습니다.'
      });
    }

    // 토큰의 비밀키가 일치하지 않는 경우
    return res.status(401).json({
      code: 401,
      message: '유효하지 않은 토큰입니다.'
    });
  }
}

토큰 발급

jsonwebtoken의 sign() 메서드로 JWT 토큰을 발급합니다. 이 때 토큰에 들어갈 내용(페이로드)과 비밀키 그리고 옵션을 넣어줍니다.

여기서는 localhost:3000/token 주소로 POST 요청 시 토큰을 발급하는 라우터를 만들었습니다. 또한 localhost:3000/token/test 주소로 GET 요청 시 발급된 토큰을 테스트하는 라우터도 만들었습니다.

/*** routes/token.js ***/

const express = require('express');
const jwt = require('jsonwebtoken');
require('dotenv').config();

const { verifyToken } = require('./middlewares');

const router = express.Router();

// 토큰을 발급하는 라우터
router.post('/', async (req, res) => {
  try {
    // 대충 DB에서 사용자 정보를 찾는 코드: 대충 id, nick 정보를 찾았다고 가정
    // API 키를 발급하여 사용하면 좋음(?)
    const id = 'ingyeo';
    const nick = 'ing-yeo';

    // jwt.sign() 메소드: 토큰 발급
    const token = jwt.sign({
      id,
      nick,
    }, process.env.JWT_SECRET, {
      expiresIn: '1m', // 1분
      issuer: '토큰발급자',
    });

    return res.json({
      code: 200,
      message: '토큰이 발급되었습니다.',
      token,
    });
  }

  catch (error) {
    console.error(error);
    return res.status(500).json({
      code: 500,
      message: '서버 에러',
    });
  }
});

// 발급된 토큰을 테스트하는 라우터
router.get('/test', verifyToken, (req, res) => {
  res.json(req.decoded);
});

module.exports = router;

이렇게 만든 라우터를 app.js에 연결합니다.

/*** app.js ***/
// ...
const tokenRouter = require('./routes/token');
// ...

// ...
app.use('/token', tokenRouter);
// ...

토큰 발급 테스트

Postman에서 다음과 같이 설정한 후 Send 버튼을 클릭하여 JWT 토큰을 발급받습니다.

  • Request 종류: POST
  • 접속주소: localhost:3000/token

다음과 같은 응답이 돌아옵니다. 여기서 token 값이 JWT 토큰입니다.

{
    "code": 200,
    "message": "토큰이 발급되었습니다.",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImluZ3llbyIsIm5pY2siOiJpbmcteWVvIiwiaWF0IjoxNTgxODM0MzA3LCJleHAiOjE1ODE4MzQ2MDcsImlzcyI6Iu2GoO2BsOuwnOq4ieyekCJ9.FOm0uK2EmuC1xpPPsJQHvUpfC3d8KLGzdK3Qy3fSZ0o"
}

토큰 유효성 테스트

Postman에서 다음과 같이 설정한 후 Send 버튼을 클릭하여 발급된 JWT 토큰을 테스트합니다.

  • Request 종류: GET
  • 접속주소: localhost:3000/token
  • Headers 탭에서 KEY에 authorization을, VALUE에 방금 전 발급받은 JWT 토큰 값을 입력합니다.

토큰이 유효한 경우 내용(페이로드)이 포함된 응답이 돌아옵니다.

{
    "id": "ingyeo",
    "nick": "ing-yeo",
    "iat": 1581831357,
    "exp": 1581831477,
    "iss": "토큰발급자"
}

토큰이 유효하지 않은 경우, 즉 발급한 지 1분이 지난 토큰을 보낸 경우 토큰이 만료되었다는 응답이 돌아옵니다.

{
    "code": 419,
    "message": "토큰이 만료되었습니다."
}

JWT 토큰 인증을 사용한 API 서버 만들기

API 라우터 생성

api 라는 이름의 라우터를 만들어보겠습니다.

/*** routes/api.js ***/

const express = require('express');
const router = express.Router();

const { verifyToken } = require('./middlewares');

// 토큰을 사용하여 API를 제공하는 라우터
router.get('/', verifyToken, (req, res) => {
  // 대충 DB에 이런 데이터가 있다고 가정
  const users = [
    { id: 1, name: 'Node.js' },
    { id: 2, name: 'npm' },
    { id: 3, name: 'Pengsu' },
  ]

  // 모든 정보 제공
  res.json(users);
});

// 경로 매개변수(:param)를 사용한 라우팅
router.get('/:id', verifyToken, async (req, res) => {
  // 대충 DB에 이런 데이터가 있다고 가정
  const users = [
    { id: 1, name: 'Node.js' },
    { id: 2, name: 'npm' },
    { id: 3, name: 'Pengsu' },
  ]

  // 특정 정보를 찾아 제공
  user = users.find(u => u.id === parseInt(req.params.id))
  res.send(user);
});

module.exports = router;

이렇게 만든 라우터를 app.js에 연결합니다.

/*** app.js ***/
// ...
const tokenRouter = require('./routes/token');
const apiRouter = require('./routes/api');
// ...

// ...
app.use('/token', tokenRouter);
app.use('/api', apiRouter);
// ...

API 사용

Postman 등으로 GET 요청 시 JWT 인증에 성공하면 아래와 같은 응답이 돌아옵니다.

  • Request 종류: GET
  • 접속주소: localhost:3000/api
  • 헤더의 authorization 속성에 JWT 토큰 값 입력
[
    {
        "id": 1,
        "name": "Node.js"
    },
    {
        "id": 2,
        "name": "npm"
    },
    {
        "id": 3,
        "name": "Pengsu"
    }
]

GET 외에도 POST, PUT, DELETE 등도 비슷하게 구현하면 됩니다. 다 적기엔 설명이 길어지니 노알못 Node.js 정리: 초간단 RESTful API 서버 만들기 글을 참고하여 만드시면 될 것 같습니다.

토큰 및 API 사용량 제한하기

express-rate-limit 패키지

express-rate-limit 패키지를 사용하면 Express 서버에 접속하는 클라이언트의 사용량을 제한할 수 있습니다. npm i express-rate-limit 명령어를 사용하여 해당 패키지를 설치합니다.

리미터 생성

토큰 및 API 사용을 제한하는 리미터 메서드를 생성합니다. 여기서는 토큰을 1분당 한 번, API를 1분에 최대 5번 호출하도록 제한하는 리미터를 만들어보겠습니다.

/*** routes/middlewares.js ***/

/* 위에서 설명한 verifyToken() 메서드 관련 코드는 생략... */

const RateLimit = require('express-rate-limit');

exports.tokenLimiter = new RateLimit({
  windowMs: 1000 * 60, // 기준 시간 (1000ms * 60 = 1분)
  max: 1, // 허용 횟수
  delayMs: 0, // 호출 간격
  handler(req, res) { // 제한 초과 시 콜백함수
    res.status(this.statusCode).json({
      code: this.statusCode, // 기본값: 429
      message: '1분에 한 번만 요청할 수 있습니다.',
    });
  },
});

exports.apiLimiter = new RateLimit({
  windowMs: 1000 * 60, // 기준 시간 (1000ms * 60 = 1분)
  max: 5, // 허용 횟수
  delayMs: 0, // 호출 간격
  handler(req, res) { // 제한 초과 시 콜백함수
    res.status(this.statusCode).json({
      code: this.statusCode, // 기본값: 429
      message: '1분에 최대 다섯 번 요청할 수 있습니다.',
    });
  },
});

리미터 연결

라우터에 리미터 메서드를 콜백함수로 연결합니다.

/*** routes/token.js ***/

const express = require('express');
const jwt = require('jsonwebtoken');
require('dotenv').config();

const { verifyToken, tokenLimiter } = require('./middlewares');

const router = express.Router();

// 토큰 발급 라우터에 tokenLimiter 연결
router.post('/', tokenLimiter, async (req, res) => {
  // 생략...
});

module.exports = router;
/*** routes/api.js ***/

const express = require('express');
const router = express.Router();

const { verifyToken, apiLimiter } = require('./middlewares');

// API 제공 라우터에 apiLimiter 연결
router.get('/', apiLimiter, verifyToken, (req, res) => {
  // 생략...
});

router.get('/:id', apiLimiter, verifyToken, async (req, res) => {
  // 생략...
});

module.exports = router;

참고: API 응답 코드

응답코드 메시지
200 JSON 데이터 제공 성공
401 유효하지 않은 토큰
410 새로운 버전 사용 권고(Deprecated Warning)
419 토큰 만료
429 토큰 발급횟수 또는 API 사용횟수 초과

댓글 남기기