Express에서 JWT로 인증시스템 구현하기 ( Access Token과 Refresh Token )

kshired·2021년 6월 7일
44
post-thumbnail

이 글을 쓰게 된 이유

Express에서 jwt를 이용하여 access token으로만 인증을 하는 글은 많이 있는데, refresh token까지 구현한 자료는 그렇게 많지않아 이 글을 쓰게 되었습니다.

이 글은 구현에 치중되어 있어, JWT의 자세한 특징을 소개하지는 않습니다.자세한 정보가 필요하다면, Refrence의 글을 읽는 것을 추천합니다.

그래서 JWT(Json Web Token)이 뭔데?

JWT는 클라이언트와 서버 사이 통신시 권한을 인가하기 위해 사용하는 토큰입니다.
헤더 - 페이로드 - 서명 세 부분으로 이루어져 있는 것이 특징입니다.

Header
JWT를 검증하는데 필요한 정보를 가진 JSON을 Base64 알고리즘 기반으로 인코딩한 문자열입니다.
검증을 하기위한 내용을 가지고 있습니다.

Payload
JWT에 저장 된 값입니다.
(name,value)의 쌍으로 이루어져 있고, JWT에는 이 값들을 여러 개 할당 할 수 있습니다.
Payload의 값은 암호화되지 않기에, 비밀번호와 같은 민감한 값을 넣으면 안됩니다.

Signature
JWT를 인코딩하거나 유효성 검증을 할 때 사용하는 암호화 된 코드입니다.

Access token과 Refresh Token

1. Acess Token만을 이용한 서버 인증 방식

  • 사용자가 로그인을 합니다.
  • 서버에서 사용자 확인 후, Access Token(JWT)에 권한 인증을 위한 정보를 Payload에 넣고 생성합니다.
  • 생성한 토큰을 클라이언트에게 반환하고, 클라이언트는 이 토큰을 저장합니다.
  • 클라이언트는 권한 인증이 필요한 요청을 할 때마다 이 토큰을 헤더에 실어 보냅니다.
  • 서버는 헤더의 토큰을 검증하고, Payload의 값을 디코딩하여 사용자의 권한을 확인하고 데이터를 반환합니다.
  • 만약, 토큰이 valid하지 않거나 만료되었다면 새로 로그인을하여 토큰을 발급받아야합니다.

2. Refresh Token
Access Token만을 이용한 인증 방식의 문제는 제 3자에게 토큰을 탈취당하게 되면, 토큰의 유효기간이 만료되기 전까지는 막을 방법이 없다는 점입니다. 그렇기에 대부분 토큰의 유효기간을 짧게 설정합니다. 하지만, 짧게 설정하면 로그인을 자주해야하는 단점이 있어 사용자가 불편을 겪게 됩니다. 이를 어떻게 해결할까요?

Refresh Token으로 해결을하게 됩니다. Refresh Token이란 Access Token과 같은 JWT입니다. 하지만 차이점이 있다면, Refresh Token은 아주 긴 유효기간을 가지며 Access Token이 만료되었을 때 새로 발급을 해주기 위한 토큰이라는 점입니다.

그래서 보통 Acess Token의 유효기간을 1시간, Refresh Token의 유효기간을 2주정도로 정합니다. 그후 Access Token이 만료되었을 때, Refresh Token이 만료되지 않았다면 Access Token을 재발급하는 형태로 인증을 하게 됩니다.

3. Access Token과 Refresh Token을 모두 이용한 서버 인증 방식

  • 사용자가 로그인합니다.
  • 서버에서 사용자 확인 후, Access Token(JWT)에 권한 인증을 위한 정보를 Payload에 넣고 생성하고 Refresh Token도 생성하여 서버에 저장한 후 두 토큰 모두를 클라이언트에게 반환합니다.
  • 클라이언트는 두 토큰을 저장합니다.
  • 클라이언트가 권한 인증이 필요한 요청을 할 때마다 Access Token을 헤더에 실어 보냅니다.
  • 서버는 헤더의 토큰을 검증하고, Payload의 값을 디코딩하여 사용자의 권한을 확인하고 데이터를 반환합니다.
  • 만약, 토큰이 만료되었다면 서버는 클라이언트에게 만료되었다는 응답을 보냅니다.
  • 클라이언트는 만료 된 토큰을 재발급 받기위해, 만료 된 Access Token과 Refresh Token을 헤더에 실어 서버에게 새로운 토큰 발급을 요청합니다.
  • 서버는 Access Token과 Refresh Token을 모두 검증 한 후, Refresh Token이 만료되지 않았다면 새로운 Access Token을 발급하여 클라이언트에게 반환합니다.

1번의 방식보다는 꽤 복잡하지만, 확실히 Access Token만을 사용했을 때보다 안전하다는 장점이 있습니다.

Express에 적용하기

이미 Express로 구성 된 프로젝트에 추가한다고 가정을하고 시작하겠습니다. 기초적인 Express 세팅은 하지 않겠습니다.

jsonwebtoken 모듈 설치

jsonwebtoken 모듈은 Node.js에서 JWT를 쉽게 발급하고, 검증 할 수 있게 도와주는 모듈입니다.
npm install --save jsonwebtoken

redis 모듈 설치

Refresh Token을 Redis에 저장 할 것이기 때문에 redis 모듈을 설치하겠습니다.
npm install --save redis

Redis를 위한 유틸 함수 작성

Redis에 Refresh Token을 저장 할 것이기 때문에, 미리 Redis를 세팅하겠습니다.

// redis.js
const redis = require('redis');

const redisClient = redis.createClient(process.env.REDIS_PORT);

module.exports = redisClient

JWT를 위한 유틸 함수 작성

// jwt-util.js
const { promisify } = require('util');
const jwt = require('jsonwebtoken');
const redisClient = require('./redis');
const secret = process.env.SECRET;


module.exports = {
  sign: (user) => { // access token 발급
    const payload = { // access token에 들어갈 payload
      id: user.id,
      role: user.role,
    };

    return jwt.sign(payload, secret, { // secret으로 sign하여 발급하고 return
      algorithm: 'HS256', // 암호화 알고리즘
      expiresIn: '1h', 	  // 유효기간
    });
  },
  verify: (token) => { // access token 검증
    let decoded = null;
    try {
      decoded = jwt.verify(token, secret);
      return {
        ok: true,
        id: decoded.id,
        role: decoded.role,
      };
    } catch (err) {
      return {
        ok: false,
        message: err.message,
      };
    }
  },
  refresh: () => { // refresh token 발급
    return jwt.sign({}, secret, { // refresh token은 payload 없이 발급
      algorithm: 'HS256',
      expiresIn: '14d',
    });
  },
  refreshVerify: async (token, userId) => { // refresh token 검증
    /* redis 모듈은 기본적으로 promise를 반환하지 않으므로,
       promisify를 이용하여 promise를 반환하게 해줍니다.*/
    const getAsync = promisify(redisClient.get).bind(redisClient);
    
    try {
      const data = await getAsync(userId); // refresh token 가져오기
      if (token === data) {
        try {
          jwt.verify(token, secret);
          return true;
        } catch (err) {
          return false;
        }
      } else {
        return false;
      }
    } catch (err) {
      return false;
    }
  },
};

JWT를 로그인에 적용

const jwt = require('./utils/jwt-util');
const redisClient = require('./utils/redis');

const login = async (req, res) => {
  //... user 로그인 로직

  if (success) { // id, pw가 맞다면..
    // access token과 refresh token을 발급합니다.
    const accessToken = jwt.sign(user);
    const refreshToken = jwt.refresh();
	
    // 발급한 refresh token을 redis에 key를 user의 id로 하여 저장합니다.
    redisClient.set(user.id, refreshToken);

    res.status(200).send({ // client에게 토큰 모두를 반환합니다.
      ok: true,
      data: {
        accessToken,
        refreshToken,
      },
    });
  } else {
    res.status(401).send({
      ok: false,
      message: 'password is incorrect',
    });
  }
};

JWT 인증 middleware 작성

인증이 필요한 Express router에 사용 할 middleware를 작성합니다.
보통 JWT는 헤더에 다음과 같은 형태로 담겨옵니다.

{
  "Authorizaiton":"Bearer jwt-token"
}
// authJWT.js
const { verify } = require('./util/jwt-util');

const authJWT = (req, res, next) => {
  if (req.headers.authorization) {
    const token = req.headers.authorization.split('Bearer ') [1]; // header에서 access token을 가져옵니다.
    const result = verify(token); // token을 검증합니다.
    if (result.ok) { // token이 검증되었으면 req에 값을 세팅하고, 다음 콜백함수로 갑니다.
      req.id = result.id;
      req.role = result.role;
      next();
    } else { // 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
      res.status(401).send({
        ok: false,
        message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
      });
    }
  }
};

module.exports = authJWT;

JWT middleware를 Express Router에 적용.

// user.js
const express = require('express');
const { editProfile } = require('./profile');
const authJwt = require('../midlewares/authJWT');

const router = express.Router();

//... 다른 router 설정들

/* user 프로필을 변경하려면 권한이 필요하기 때문에 authJWT 미들웨어를 적용합니다.
   이제 이 router는 access token을 헤더에 담아서 요청해야합니다. */
router.get('/profile', authJWT, editProfile);

module.exports = router;

Access token 재발급을 위한 함수 작성.

재발급을 위해서 클라이언트는 access token과 refresh token 모두를 헤더에 담아 보내야합니다. 저는 클라이언트가 다음과 같이 헤더에 토큰들을 담아서 보낸다고 가정하겠습니다.

{
  "Authorizaiton":"Bearer access-token",
  "Refresh":"refresh-token"
}

토큰을 재발급 할 때는 다음과 같은 시나리오들이 존재합니다.

  1. access token이 만료되고, refresh token도 만료 된 경우 => 새로 로그인해야합니다.
  2. access token이 만료되고, refresh token은 만료되지 않은 경우 => 새로운 access token을 발급합니다.
  3. access token이 만료되지 않은경우 => refresh 할 필요가 없습니다.

위 시나리오를 그대로 구현하면 됩니다.

// refresh.js
const { sign, verify, refreshVerify } = require('../util/jwt-util');
const jwt = require('jsonwebtoken');

const refresh = async (req, res) => {
  // access token과 refresh token의 존재 유무를 체크합니다.
  if (req.headers.authorization && req.headers.refresh) {
    const authToken = req.headers.authorization.split('Bearer ')[1];
    const refreshToken = req.headers.refresh;

    // access token 검증 -> expired여야 함.
    const authResult = verify(authToken);

    // access token 디코딩하여 user의 정보를 가져옵니다.
    const decoded = jwt.decode(authToken);
	
    // 디코딩 결과가 없으면 권한이 없음을 응답.
    if (decoded === null) {
      res.status(401).send({
        ok: false,
        message: 'No authorized!',
      });
    }
	
    /* access token의 decoding 된 값에서
      유저의 id를 가져와 refresh token을 검증합니다. */
    const refreshResult = refreshVerify(refreshToken, decoded.id);

    // 재발급을 위해서는 access token이 만료되어 있어야합니다.
    if (authResult.ok === false && authResult.message === 'jwt expired') {
      // 1. access token이 만료되고, refresh token도 만료 된 경우 => 새로 로그인해야합니다.
      if (refreshResult.ok === false) {
        res.status(401).send({
          ok: false,
          message: 'No authorized!',
        });
      } else {
        // 2. access token이 만료되고, refresh token은 만료되지 않은 경우 => 새로운 access token을 발급
        const newAccessToken = sign(user);

        res.status(200).send({ // 새로 발급한 access token과 원래 있던 refresh token 모두 클라이언트에게 반환합니다.
          ok: true,
          data: {
            accessToken: newAccessToken,
            refreshToken,
          },
        });
      }
    } else {
      // 3. access token이 만료되지 않은경우 => refresh 할 필요가 없습니다.
      res.status(400).send({
        ok: false,
        message: 'Acess token is not expired!',
      });
    }
  } else { // access token 또는 refresh token이 헤더에 없는 경우
    res.status(400).send({
      ok: false,
      message: 'Access token and refresh token are need for refresh!',
    });
  }
};

module.exports = refresh;

Refresh를 위해 user router에 refresh 추가.

// user.js
const express = require('express');
const { editProfile } = require('./profile');
const refresh = require('./refresh');
const authJwt = require('../midlewares/authJWT');

const router = express.Router();

//... 다른 router 설정들

/* access token을 재발급 하기 위한 router.
  클라이언트는 access token과 refresh token을 둘 다 헤더에 담아서 요청해야합니다. */
router.get('/refresh', refresh);

module.exports = router;

이제 모두 적용되었으며, 이것을 응용하여 여러분의 프로젝트에 적용하면 될 것입니다!

위 방식을 적용한 제 사이드 프로젝트도 github에 존재하니 한 번 들려주시면 감사하겠습니다!
https://github.com/kshired/ShoppingCart

Reference

profile
글 쓰는 개발자

6개의 댓글

comment-user-thumbnail
2021년 7월 14일

안녕하세요, 좋은 글 정말 잘 읽었습니다. 궁금한 게 있어서 질문 드리는데요,
refresh부분에서 access token을 decode해서 유저 정보를 가져와 refresh token을 검증하시는데, access token이 만료되면 유저정보를 가져오는 게 불가능하지 않나요?? TokenExpiredError만 나오고, 안의 정보는 안나오지 않나요?

1개의 답글
comment-user-thumbnail
2021년 9월 15일

와... 정말 감사합니다.
리프레쉬 토큰을 할당 받는 부분이 많이 궁금했는데 귀한 참고가 되었습니다. 꾸벅 orz
(추석명절도 잘 보내시기를 바랍니다용...!!!)

답글 달기
comment-user-thumbnail
2023년 1월 18일

JWT를 로그인에 적용하는 부분은 jwt-util.js에 작성하면 되나요

답글 달기
comment-user-thumbnail
2023년 3월 24일

안녕하세요, 이렇게 좋은 글을 공유해주셔서 감사합니다!!

답글 달기
comment-user-thumbnail
2024년 2월 24일

좋은 글 감사합니다. server가 AccessToken과 Refresh Token을 header에 담아 client로 응답하는데, AccessToken과 Refresh Token은 client 어디에 저장하였는지 알 수 있을까요?

답글 달기