JWT, access token, refresh token, auto refresh, axios를 이용한 토큰 자동 발급 받기

GJ·2022년 4월 22일
0

JWT(JSON Web Token)

일반적으로 로그인을 하면 서버에서는 클라이언트에 아이디/비밀번호 대신에 request 할 때 사용할 수 있는 토큰을 준다.
request 할 때 마다 아이디와 비밀번호를 사용하게 된다면 보안상 문제가 될 수 있기 때문이다.
세션 인증 방식은 서버의 데이터베이스나 메모리에 발급해준 토큰을 저장해두고 request가 올 때 마다 해당 사용자의 토큰이 서버에 존재하는지 확인하는 방식으로 이루어진다.
이런 방식은 직관적이고 서버에서 특정 클라이언트의 로그아웃을 원하는 경우 해당 토큰만 서버에서 삭제해버리면 된다.
그러나 세션 인증 방식에는 단점이 있는데 request마다 서버가 토큰이 존재하는지 조회가 필요하다는 것이다.
서버나 데이터베이스가 분산되어 있는 경우에는 해당 토큰이 어디 존재하는지 찾는것부터가 비용이 된다.
JWT를 이용한 인증 방식은 이런 단점을 보완한다.
JWT는 이 토큰이 무결한지에 대한 정보와 사용자 정보, 유효기간 정보가 포함된다.
서버는 request를 받았을때 토큰만 보고서 이 토큰이 유효한 토큰인지 바로 확인이 가능한다.
즉, 서버내에 저장된 토큰을 찾는 과정이 생략되기 때문에 클라이언트가 response를 받는 시간이 짧아지고, 서버가 분산으로 구성되어 있더라도 문제가 없다.

JWT의 구성(https://jwt.io/)

자세한 내용은 jwt.io에 접속하면 playground가 구성되어 있으므로 쉽게 확인 가능하다.
구조는 크게 Header, Payload, Signature로 이루어져 있고 각각은 .으로 구분되어 있다.
Header에는 어떤 알고리즘으로 암호화되어 있는지 등의 정보가 json 형태로 되어있다.
Payload에는 토큰 주인의 정보, 토큰의 유효시간 등 토큰에 대한 정보가 json 형태로 저장된다.
Signature는 위 두 정보와 비밀키를 조합한 새로운 키의 형태로 되어있다. 서버
Header와 Payload는 json 형태이지만, 전송될때는 암호화된 문자열 형태로 전송되므로, 실제 jwt 토큰은 (문자열.문자열.문자열) 형태를 보인다.

JWT의 단점

서버는 request를 받았을때 토큰이 유효한지 확인하고 response를 반환한다.
이때의 토큰을 access token이라고 한다.
클라이언트가 access token을 이용해서 서버에서 데이터를 가져오는것이다.
jwt를 access token으로 사용하게 되면 서버에서 request를 받았을때 바로 토큰이 유효한지 확인 할 수 있지만, 서버는 유효한지만 확인해 줄 수 있고 해당 토큰을 비활성화 할수는 없다.(로그아웃이 불가능하다.)
서버가 jwt를 무효화 시키는 방법은 애초에 발급을 할때 유효 시간을 주는 방법 밖에는 없다.
만약 access token을 다른 악의적인 사용자가 탈취해서 사용한다면 서버에서는 토큰이 탈취되었다는 사실을 알았다고 하더라도 어쩔수가 없다.

refresh token

그래서 JWT를 사용할때는 refresh token이라는것을 사용한다.
서버는 클라이언트가 로그인에 성공하면(아이디 비밀번호가 일치하면) access token 뿐만 아니라 refresh token도 반환한다.
access token의 유효 시간을 매우 짧게 해놓고, 토큰이 만료되었다면 refresh token으로 다시 access token을 받아가도록 하는 것이다.
refresh token의 유효 시간은 길게 해놓되, 기존 세션 인증 방식처럼 관리한다면 가끔씩 access token을 새로 발급할때만 비용이 든다.
로그아웃하게되면 refresh token을 무효화해서 더 이상 access token을 가져가지 못하도록 하면 된다.

access token 갱신 전략

서버는 access token가 유효한지 확인하고 유효하면 그대로 request를 진행하고, 유효하지 않으면 401에러를 반환한다.
클라이언트는 크게 사전, 사후에 처리하는 방법 두 가지로 토큰을 갱신 할 수 있다.

1. 보내기 전에 access token 새로 발급받기

jwt에는 유효시한이 포함되어 있기 때문에 클라이언트에서 request를 보내기 전에 시한이 얼마 남지 않았다면 refresh token으로 다시 받아오면 된다.

2. 일단 request를 보내고 401에러 발생시 access token 새로 발급받기

서버에서는 토큰의 시한이 지났다면 401에러가 발생할 것이고, 에러가 발생했을때 access token을 새로 받아서 다시 request를 보내는 것이다.

생각해야 할 문제점

위 방법대로 생각하면 진행이 간단할 것 같지만, 동시에 request가 발생하는 경우에는 request가 서버에 연속적으로 도달하면서 access token이 연속으로 발급받아지기 때문에 정상적인 재 request가 어렵다.
따라서 큐를 만들어서 access token이 발급되는 동안에는 기존 request와 새로운 request를 큐에 쌓아놓고, access token이 발급되면 큐에 쌓아놓은 request를 진행하도록 해야 한다.

클라이언트 axios에서 토큰 갱신하기

axios에는 interceptor라는 미들웨어가 있어서 request, response 시에 어떤 작업을 하고 결과를 return 할 수 있다.
request할 때는 request 이전에 위의 1번 전략을 사용하고, response할 때는 response를 사용하기 전에 위의 2번 전략을 사용하면 된다.
access token을 새로 받아오는것 조차도 실패하면 로그인 페이지로 돌아가도록 한다.

/****************************************************************
 * axios instance
 ****************************************************************/
const client = axios.create({ baseURL: `${process.env.REACT_APP_API_URL}` });

export default client;

/****************************************************************
 * token handling
 ****************************************************************/
/**
 * accessToken과 refreshToken을 스토리지에 저장하고 axios 헤더에 설정합니다.
 * @param {string} accessToken
 * @param {string} refreshToken
 */
export const setToken = (accessToken, refreshToken) => {
  window.sessionStorage.setItem("refreshToken", refreshToken);
  window.sessionStorage.setItem("accessToken", accessToken);
  client.defaults.headers.Authorization = `Bearer ${accessToken}`;
};
/**
 * 저장된 토큰을 세션 스토리지에서 삭제하고 axios 헤더에서 삭제합니다.
 */
export const removeToken = () => {
  window.sessionStorage.removeItem("accessToken");
  window.sessionStorage.removeItem("refreshToken");
  client.defaults.headers.Authorization = null;
};

let isRefreshing = false;
const failedTaskQueue = [];
/**
 * 요청에 실패한 요청을 resolve와 함께 큐에 쌓습니다.
 */
const enroleFailedTask = (request, resolve) => {
  failedTaskQueue.push([request, resolve]);
};
/**
 * 큐에 쌓인 실패한 요청을 모두 요청 하고 그 결과를 resolve 해줍니다.
 */
const resolveFailedTask = () => {
  const accessToken = window.sessionStorage.getItem("accessToken");
  while (true) {
    const failedTask = failedTaskQueue.shift();
    if (failedTask) {
      const request = failedTask[0];
      const resolve = failedTask[1];
      request.headers.Authorization = `Bearer ${accessToken}`;
      resolve(request);
    } else {
      break;
    }
  }
};
/**
 * 기존 토큰을 갱신하고 등록합니다.
 */
const renewToken = async () => {
  isRefreshing = true;
  const refreshToken = window.sessionStorage.getItem("refreshToken");
  try {
    const response = await fetchNewToken({
      refreshToken: refreshToken,
    });
    setToken(response.data.access_token, response.data.refresh_token);
  } catch (error) {
    alert("Please login again.");
    removeToken();
    window.location.href = window.location.origin;
  }
  isRefreshing = false;
  resolveFailedTask(); // 쌓였던 실패 요청 재실행
};
/****************************************************************
 * error handling
 ****************************************************************/
client.interceptors.request.use(
  /**
   * @param {import('axios').AxiosRequestConfig} request
   * @returns {Promise<import('axios').AxiosResponse>}
   */
  async (request) => {
    if (
      request.method === "post" &&
      ["/login", "/user", "/token", "/pwd", "/activation"].includes(request.url)
    ) {
      // 토큰을 사용하지 않는 엔드포인트 정상 진행
      return Promise.resolve(request);
    } else {
      // 토큰을 사용하는 엔드포인트
      const accessToken = window.sessionStorage.getItem("accessToken");
      const JWT = decodeJWT(accessToken);
      const expireTimestamp = JWT.exp;
      const currentTimestamp = getTimestamp();
      // console.log(expireTimestamp - currentTimestamp);
      if (expireTimestamp - currentTimestamp < 60) {
        // 토큰의 유효시간이 60초 미만일 경우 토큰 재발급
        const taskPromise = new Promise((resolve) => {
          enroleFailedTask(request, resolve);
        });
        if (!isRefreshing) renewToken();
        return taskPromise;
      } else {
        // 토큰의 유효시간이 60초 이상일 경우 정상 진행
        return Promise.resolve(request);
      }
    }
  },
  /**
   * @param {import('axios').AxiosError} error
   * @returns {Promise<import('axios').AxiosError>}
   */
  (error) => {
    return Promise.reject(error);
  }
);
client.interceptors.response.use(
  /**
   * @param {import('axios').AxiosResponse} response
   * @returns {Promise<import('axios').AxiosResponse>}
   */
  (response) => Promise.resolve(response),
  /**
   * @param {import('axios').AxiosError} error
   * @returns {Promise<import('axios').AxiosError>}
   */
  async (error) => {
    if (!error.response) {
      alert("Network or Server has a problem.");
    } else if (error.response.status === 401) {
      const request = error.config;
      if (request.url !== "/token") {
        const taskPromise = new Promise((resolve) => {
          enroleFailedTask(request, resolve);
        }).then((request) => axios(request));
        if (!isRefreshing) renewToken();
        return taskPromise;
      }
    }
    return Promise.reject(error);
  }
);
profile
Frontend Developer

0개의 댓글