자바스크립트 공부 일지 19

Access Token and Refresh Token 으로 더 안전한 jwt토큰 사용하기

Access Token, Refresh Token

Access Token

  • Access Token은 사용자의 권한이 확인(ex: 로그인) 되었을 경우 해당 사용자를 인증하는 용도로 발급하게됩니다.

이전 구현하였던 Cookiejwt를 발급하고 설정한 Expires 기간이 지날 때 인증이 만료되게 하는것 또한 Access Token이라고 부를 수 있습니다.

사용자가 Access Token을 가지고 인증을 요청할 경우 Token을 생성할 때 사용한 **비밀키(Secret Key)**를 가지고 인증하기 때문에, 복잡한 설계없이 코드를 구현할 수 있고, 여러 분기를 거치지 않아도 된다는 장점이 있습니다.

Access Token의 경우 Stateless(무상태) 즉, Node.js 서버가 죽었다 살아나더라도 동일한 동작을하는 방식입니다. 즉, jwt를 이용해 사용자의 인증 여부는 확인할 수 있지만 처음 발급한 사용자 본인인지 확인할 수는 없습니다.

Access Token은 그 자체로도 사용자를 인증하는 모든 정보를 가지고 있습니다. 그렇기 때문에 토큰을 가지고 있는 시간이 늘어날 수록 탈취되었을 때는 피해가 더욱 커지게 됩니다.

만약 토큰이 탈취되었다고 인지하더라도 저희들은 해당 토큰이 탈취된 토큰인지 알 수 없고, 고의적으로 만료를 시킬 수도 없을 것 입니다. 그러므로 저희들은 언제든지 사용자의 토큰이 탈취될 수 있다고 생각을 하고, 피해를 최소화 할 수 있는 방향으로 개발을 진행해야합니다.

Refresh Token

  • Refresh Token은 Access Token 처럼 해당하는 사용자의 모든 인증 정보를 관리하는 것이 아닌, 특정한 사용자가 Access Token을 발급받기 위한 용도로만 사용됩니다. redis 혹은 mongoDB와 같이 접근이 빠른 NoSQL을 사용할 수 있죠.

Refesh Token은 사용자의 인증정보를 사용자가 가지고 있는 것이 아닌, 서버에서 해당 사용자의 정보를 저장소 또는 별도의 DB에 저장하여 관리합니다. 그렇기 때문에, 서버에서 특정 Token 만료가 필요할 경우 저장된 Token을 제거하여 사용자의 인증 여부를 언제든지 제어가 가능하다는 장점이 있습니다.

그렇다면 어째서 바로 Access Token을 발급하지 않고, Refresh Token을 거쳐서 Access Token을 발급하는것일까요? 사용자에게 발급한 Token이 탈취당할 경우 피해를 최소화 하기 위해서 사용합니다.

저희가 실제 세계에서 사용하는 OTP와 같이 짧은 시간 내에서만 인증 정보를 사용할 수 있게하고, 주기적으로 재발급하여, 토큰이 유출되더라도 오랜 기간동안 피해를 입는것이 아닌, 짧은 기간동안만 사용가능하도록 하여 피해를 최소화할 수 있게 됩니다.

언제든지 토큰이 탈취될 수 있다는 것을 가정하고, 탈취를 막는것이 어렵다면, 우리는 탈취된 토큰자체를 사용할 수 있는 기간을 줄여서 피해를 막을 것 입니다.

프로젝트 구현

  • Refresh Token은 사용자가 서버와 최초 인증시에 발급을 받게되는데요. 이번 프로젝트에서는 하나의 파일에서 Refesh Token과 Access Token이 어떤식으로 동작하는지 확인해보겠습니다.

  • 개발할 API는 다음과 같습니다. Refresh Token, Access Token 발급 : GET http://localhost:3002/set-token/:id
    Token 인증 받기 : GET http://localhost:3002/get-token/

다음과 같이 프로젝트를 초기화하고 express jsonwebtoken cookie-parser를 설치합니다.

bash
npm init -y
npm install express jsonwebtoken cookie-parser -S

우선 검증 이전에 사용자에게 전달할 토큰 코드는 다음과 같습니다.

js
// app.js
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const express = require("express");
const app = express();
const port = 3002;
const SECRET_KEY = `HangHae99`;

app.use(cookieParser());

// app.js
let tokenObject = {}; // Refresh Token을 저장할 Object

app.get("/set-token/:id", (req, res) => {
  const id = req.params.id;
  const accessToken = createAccessToken(id);
  const refreshToken = createRefreshToken();

  // {
  //   refreshToken: id
  // }
  // Refresh Token을 가지고 해당 유저의 정보를 서버에 저장합니다.
  tokenObject[refreshToken] = id; 
  // Access Token을 Cookie에 전달한다.
  res.cookie('accessToken', accessToken); 
  // Refresh Token을 Cookie에 전달한다.
  res.cookie('refreshToken', refreshToken); 

  return res.status(200).send({ "message": "Token이 정상적으로 발급되었습니다." });
})

// Access Token을 생성합니다.
function createAccessToken(id) {
  const accessToken = jwt.sign(
    { id: id }, // JWT 데이터
    SECRET_KEY, // 비밀키로 암호화
    { expiresIn: '10s' }) // Access Token이 10초 뒤에 만료되도록 설정합니다.
                          // 10초가 지나면 Access Token을 발급받도록

  return accessToken;
}

// Refresh Token을 생성합니다.
function createRefreshToken() {
  const refreshToken = jwt.sign(
    {}, // JWT 데이터 - 데이터를 가지지 않음. 
        // 해당하는 Refresh Token을 가진 경우 서버에 있는 Refresh Token을 사용할 것이므로 jwt가 존재하지 않음
    SECRET_KEY, // 비밀키
    { expiresIn: '7d' }) 
    // Refresh Token이 7일 뒤에 만료되도록 설정합니다.
    // Refresh Token의 경우 계속적으로 사용하면서 Access Token을 발급받기 위함.
    // 사용 기간이 길더라도 실제 서버 인증을 받아야 Access Token을 발급받고 사용자 권한을 사용할 수 있기 때문에
    // 기간 설정에 있어 문제가 크진 않으나 너무 길어도 문제가 있음

  return refreshToken;
}

app.get("/", (req, res) => {
  res.status(200).send("Hello Token!");
})

app.listen(port, () => {
  console.log(port, '포트로 서버가 열렸어요!');
})

이제 사용자에게 토큰을 전달 받아 검증하는 코드를 작성해 완성해보겠습니다.

js
// app.js
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const express = require("express");
const app = express();
const port = 3002;
const SECRET_KEY = `HangHae99`;

app.use(cookieParser());

// app.js
let tokenObject = {}; // Refresh Token을 저장할 Object NoSQL과 같은 DB에 저장한다고 가정.

app.get("/set-token/:id", (req, res) => {
  const id = req.params.id;
  const accessToken = createAccessToken(id);
  const refreshToken = createRefreshToken();

  // {
  //   refreshToken: id
  // }
  tokenObject[refreshToken] = id; // Refresh Token을 가지고 해당 유저의 정보를 서버에 저장합니다.
  res.cookie('accessToken', accessToken); // Access Token을 Cookie에 전달한다.
  res.cookie('refreshToken', refreshToken); // Refresh Token을 Cookie에 전달한다.

  return res.status(200).send({ "message": "Token이 정상적으로 발급되었습니다." });
})

// Access Token을 생성합니다.
function createAccessToken(id) {
  const accessToken = jwt.sign(
    { id: id }, // JWT 데이터
    SECRET_KEY, // 비밀키로 암호화
    { expiresIn: '10s' }) // Access Token이 10초 뒤에 만료되도록 설정합니다.
                          // 10초가 지나면 Access Token을 발급받도록

  return accessToken;
}

// Refresh Token을 생성합니다.
function createRefreshToken() {
  const refreshToken = jwt.sign(
    {}, // JWT 데이터 - 데이터를 가지지 않음. 
        // 해당하는 Refresh Token을 가진 경우 서버에 있는 Refresh Token을 사용할 것이므로 jwt가 존재하지 않음
    SECRET_KEY, // 비밀키
    { expiresIn: '7d' }) // Refresh Token이 7일 뒤에 만료되도록 설정합니다.
                         // Refresh Token의 경우 계속적으로 사용하면서 Access Token을 발급받기 위함.
                         // 사용 기간이 길더라도 실제 서버 인증을 받아야 Access Token을 발급받고 사용자 권한을 사용할 수 있기 때문에
                         // 기간 설정에 있어 문제가 크진 않으나 너무 길어도 문제가 있음

  return refreshToken;
}

// app. js
app.get("/get-token", (req, res) => {
  // 1. 이전에 클라이언트에게 전달한 쿠키를 받음.
  const accessToken = req.cookies.accessToken;
  const refreshToken = req.cookies.refreshToken;

  // 2. 전달했던 쿠키가 존재하지 않는다면 에러
  if (!refreshToken) return res.status(400).json({ "message": "Refresh Token이 존재하지 않습니다." });
  if (!accessToken) return res.status(400).json({ "message": "Access Token이 존재하지 않습니다." });

  // 3. validation 체크 jwt.verify
  const isAccessTokenValidate = validateAccessToken(accessToken);
  const isRefreshTokenValidate = validateRefreshToken(refreshToken);

  // 3-1. refreshToken 검증 실패
  if (!isRefreshTokenValidate) return res.status(419).json({ "message": "Refresh Token이 만료되었습니다." });

  // 3-2. accessToken 검증 실패
  // refreshToken을 이용해(refreshToken은 validation true인 상태) accessToken 재발급
  if (!isAccessTokenValidate) {
    // 4. accessToken을 발급할 당시 당사자의 id를 DB에서 조회한다고 가정
    const accessTokenId = tokenObject[refreshToken];

    // accessToken의 id가 존재하지 않는 경우
    // Refresh Token이 인증됐고, 비밀키도 정상이고, 만료시간도 정상인데 id가 존재하지 않는 경우.
    // 대표적 사례 
    // accessToken 혹은 refreshToken이 탈취당했다고 가정할 때
    // refreshToken이 서버에서 자동적으로 만료를 고의적으로 시켜줄 수 있음.
    // 5. 서버에 가지고 있는 refreshToken 토큰과 일치하지 않는 경우(탈취), 서버에서 고의로 삭제한 경우에 대한 검증
    if (!accessTokenId) return res.status(419).json({ "message": "Refresh Token의 정보가 서버에 존재하지 않습니다." });

    // 6. accessToken 재발급
    const newAccessToken = createAccessToken(accessTokenId);
    // 7. 똑같이 accessToken 키를 가진 newAccessToken 으로 대체
    res.cookie('accessToken', newAccessToken);
    return res.json({ "message": "Access Token을 새롭게 발급하였습니다." });
  }

  // accessToken도 만료기간이 지나지 않았고, refreshToken도 정상적인 경우
  // 사용자 id를 가져옴
  const { id } = getAccessTokenPayload(accessToken);
	return res.json({ "message": `${id}의 Payload를 가진 Token이 성공적으로 인증되었습니다.` });
})

// Access Token을 검증합니다.
function validateAccessToken(accessToken) {
  try {
    jwt.verify(accessToken, SECRET_KEY); // JWT를 검증합니다.
    return true;
  } catch (error) {
    // 대표적 에러 1
    // jwt 토큰과 설정한 비밀키가 일치하지 않은 경우
    // 대표적 에러 2
    // 만료기간이 지난 경우 
    return false;
  }
}

// Refresh Token을 검증합니다.
function validateRefreshToken(refreshToken) {
  try {
    jwt.verify(refreshToken, SECRET_KEY); // JWT를 검증합니다.
    return true;
  } catch (error) {
    return false;
  }
}

// Access Token의 Payload를 가져옵니다.
function getAccessTokenPayload(accessToken) {
  try {
    const payload = jwt.verify(accessToken, SECRET_KEY); // JWT에서 Payload를 가져옵니다.
    return payload;
  } catch (error) {
    return null;
  }
}

app.get("/", (req, res) => {
  res.status(200).send("Hello Token!");
})

app.listen(port, () => {
  console.log(port, '포트로 서버가 열렸어요!');
})