홍준혁

토큰 기반 인증, 효율적으로 관리하는 법 본문

React.js

토큰 기반 인증, 효율적으로 관리하는 법

홍준혁 [Hong-JunHyeok] 2022. 5. 25. 16:01

React에서 토큰 기반 인증을 어떤 식으로 하는 것이 가장 효율적일까요?

많은 방법이 있겠지만 이번 포스트에서는 제가 생각했을 때 가장 효율적이라고 생각하는 인증 방식을 기술하려고 합니다.

 

먼저 토큰이 흘러가는 방식을 한번 생각해봅시다.

AccessToken으로 인증을 진행하게 됩니다. 만약 AccessToken이 만료된다면 RefreshToken으로 Refresh를 해주어야 하죠.

왜 AccessToken을 계속 사용하지 않고 Refresh를 주기적으로 해주어야 할까요? 그 이유는 보안에 있습니다. 

AccessToken이 중간에 탈취된다면 정보 누출의 위험이 있겠죠. 그래서 AccessToken의 만료기간을 짧게 주고 RefreshToken의 만료기간을 길게 유지해 인증을 유지하는 것입니다.

 

위 스펙을 리액트 코드에서 구현해보도록 하겠습니다. 

// context/AuthContext.ts

import { createContext, useState } from "react";

const AuthContext = createContext({});

export const AuthProvider = ({ children }) => {
  const [auth, setAuth] = useState({});

  return (
    <AuthContext.Provider value={{ auth, setAuth }}>
      {children}
    </AuthContext.Provider>
  )
}

export default AuthContext;

Context API를 사용해서 AuthContext를 구현했습니다.

AuthProvider를 전역 공간에 묶어주고, useAuth()를 실행하게 되면 auth과 setAuth를 취득할 수 있게 됩니다.

이때, auth에 들어가는 정보는 유저의 정보, AccessToken정도가 되겠네요. 그럼 RefreshToken은 어디에 저장할까요?

이 부분에 대해서는 나중에 Intercepter를 구현할 때 계속해보도록 하겠습니다.

이제 App.js에 다음과 같이 작성해주세요.

위와 같은 작업을 하는 이유는 전역적으로 auth context의 상태를 공유하기 위해서입니다.

이제 Context를 사용하기 위한 Hook을 따로 작성해봅시다.

import { useContext } from 'react';
import AuthContext from '@/context/AuthContext';

const useAuth = () => {
  return useContext(AuthContext)
}

export default useAuth

useContext의 반환 값은 해당 context의 내용을 리턴합니다. 

로그인을 할 때 setAuth로 유저의 정보를 담아주면 되겠죠? 한번 작성해봅시다.

const { email,password } = data;
      const login = async (email: string, password: string) => {
        const response = await customAxios.post('/auth/login', {
          email,
          password,
        });
        setAuth(response.data.payload);
      }

 

 

그러면 로그인을 한 후에 auth를 콘솔에 찍어보면 다음과 같이 나옵니다.

유저가 로그인했는지 여부를 확인하려면 auth.user가 있는지 확인하면 될 것 같네요.

다음과 같이 auth.user의 여부로 헤더의 조건부 처리를 하였습니다.

하지만 문제가 있죠. refreshToken입니다. 저희는 accessToken에 대해서는 관리를 해주었지만 refreshToken은 전혀 관리하지 않았습니다. 

사실, RefreshToken을 어디서 관리해야 하는지에 대해서는 논란이 좀 있는 부분입니다. Refresh를 클라이언트에 저장하면 해킹 위험이 있다 라는 관점에서 Server Side(DB)에서 Refresh토큰을 관리하는 기술이 등장했는데, HLOG에서는 RefreshToken을 Cookie로 저장을 했습니다. 보안상의 측면에서는 부족하겠지만, HttpOnly처리를 해준다던지 Secure코딩을 해준다던지 여러 가지 방어 코드를 작성할 수 있기 때문에 나쁘지 않을 것 같다고 생각했습니다.

그러면 서버에서는

다음과 같이 코드를 작성을 해주어야 할 것입니다. 그럼, 클라이언트에 정상적으로 쿠키가 담겨있을까요?

아마, 정상적인 브라우저라면 안 담겨있을 겁니다. withCrediential 설정을 해주지 않아서 그렇습니다.

 

Server 측 코드에서는 다음과 같이 작성해주고

app.use(
  cors({
    origin: true,
    credentials: true
  })
);

Client 측 코드에서는 withCredentials를 설정해주어야 합니다. 

import axios, { AxiosRequestConfig } from 'axios';

const AxiosConfigure: AxiosRequestConfig = {
  baseURL: process.env.API_SERVER_URL,
  timeout: 1000,
  withCredentials: true
}

export default axios.create(AxiosConfigure);

이렇게 작성해주면 로그인을 할 때, 정상적으로 쿠키에 저장이 되는 모습입니다.

쿠키의 특성상, RefreshToken은 모든 요청 시 Header에 담기게 될 것입니다.

즉, AccessToken은 Local스토리지에서 관리하고, RefreshToken은 쿠키에서 관리하게 되는 것이죠.

 

그러면 Refresh를 어느 타이밍에 해주면 적절할까요?

AccessToken이 만료되었을 때 해주는 게 적절해 보입니다. 이때 등장하는 것이 인터셉터입니다.

인터셉터란 then이나 catch로 어떠한 작업이 처리되기 전에 가로채는 작업을 수행하게 됩니다.

https://yamoo9.github.io/axios/guide/interceptors.html

 

인터셉터 | Axios 러닝 가이드

인터셉터 then이나catch로 처리되기 전에 요청이나 응답을 가로챌 수 있습니다. axios.interceptors.request.use( function (config) { return config; }, function (error) { return Promise.reject(error); }); axios.interceptors.response.us

yamoo9.github.io

이 인터셉터로 토큰이 만료되었다는 사실을 알아낸 후에 refresh를 하는 작업을 해보도록 하겠습니다.

그리고 이 인터셉터 로직은 Hook으로 분리하여 사용하겠습니다.

import useAuth from "./useAuth";
import { useEffect } from 'react';
import customAxios from "@/utils/customAxios";

const useInterceptedAxios = () => {
  const { auth } = useAuth();

  useEffect(() => {
    const requestIntercept = customAxios.interceptors.request.use(
      (config) => {
        // 모든 Request Header에 Access토큰을 넣어주는 역할
      }
    );
    const responseIntercept = customAxios.interceptors.response.use(
      response => response, 
      async (error) => {
        // 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
      }
    );

    return () => {
      customAxios.interceptors.request.eject(requestIntercept);
      customAxios.interceptors.response.eject(responseIntercept);
    }
  }, [auth])
}

export default useInterceptedAxios;

위와 같이 기반을 다져봤습니다.

그리고 이 인터셉터들은 중첩되기 때문에 unmount가 될 때, eject(제거)해주는 작업을 해야 합니다.

먼저, requestIntercepter로 모든 요청에 토큰을 넣어보도록 하겠습니다.

const requestIntercept = customAxios.interceptors.request.use(
      (config) => {
        // 모든 Request Header에 Access토큰을 넣어주는 역할
        if(!config.headers['authorization']) {
          config.headers['authorization'] = `${auth.accessToken}`
        }
        return config;
      }, (error) => Promise.reject(error)
    );

앞서 만들었던 useAuth에서 accessToken을 가져와 header에 넣어줍니다. 그러면 매 요청에 토큰이 전송되겠죠.

다음은 responseIntercepter입니다.

const responseIntercept = customAxios.interceptors.response.use(
      response => response, 
      async (error) => {
        // 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
        const prevRequest = error?.config;
        if(error?.response?.status === 403 && !prevRequest?.sent) {
          prevRequest.sent = true;
          const response = await customAxios.post('/auth/refresh');
          const newAccessToken = response.data.payload.accessToken;
          setAuth((prev) => ({
            ...prev,
            accessToken: newAccessToken
          }))
          prevRequest.headers['authorization'] = `${newAccessToken}`;
          return customAxios(prevRequest)
        }
      }
    );

이제 refresh로직이 잘 동작할 것입니다.

 

참고로 기존의 axios사용을 hook으로 변경해야 합니다.

const customAxios = useInterceptedAxios();

 

오늘은 토큰 기반 인증을 하는 방법에 대해서 글을 써봤습니다.

토큰을 관리하는 방법은 정말 다양합니다. 위의 로직은 쉽고 간단하게 refresh를 구현할 수 있다는 장점이 있지만 보안적인 측면에서는 떨어질 수 있습니다. 그럼, 마치겠습니다.

 


2022.6.21 추가

트러블슈팅

- Axios Intercept 중복되는 문제 

해당 문제는 제 코드에 있었습니다.

import customAxios from '@/utils/customAxios';
import useLocalStorage from '@/utils/useLocalStorage';
import useRefreshToken from './useRefreshToken';

const useInterceptedAxios = () => {
  const { storedValue: hlogToken } = useLocalStorage('hlog_access_token', '');
  const refreshToken = useRefreshToken();

  customAxios.interceptors.request.use((config) => {
    // 모든 Request Header에 Access토큰을 넣어주는 역할
    if (!config.headers.authorization) {
      config.headers.authorization = hlogToken;
    }
    return config;
  }, (error) => error);

  customAxios.interceptors.response.use(
    (response) => response,
    async (error) => {
      // 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
      const prevRequest = error?.config;
      if (error?.response?.status === 403 && !prevRequest?.sent) {
        prevRequest.sent = true;
        const data = await refreshToken();
        prevRequest.headers.authorization = data.accessToken;
        return customAxios(prevRequest);
      }
      return customAxios;
    },
  );

  return customAxios;
};

export default useInterceptedAxios;

이렇게 작성을 했었어요. 그래서 사용하는 측에서 다음과 같이 작성을 하면 모든 요청에 intercept가 중첩되게 되는 문제가 발생했습니다.

중첩되게 되면 (심지어 React-Query라서 중첩이 더 심하게 됩니다.) 매 요청/응답 시 중첩된 인터셉터가 100번 1000번 실행되게 됩니다.

처음에는 이를 인지하지 못하고 있었는데, 점점 사용 시간이 늘어날수록 체감이 되어서 이를 수정하기로 결정했습니다.

위 작업을 Hook로 할 필요성을 크게 느끼지 못해서 customAxios.ts내부 코드에서 intercept하는 작업을 한번만 수행한다면

중첩되는 문제가 해소될 것 같아서 코드를 수정해보도록 하겠습니다.

customAxios를 내보내는 코드에서 다음과 같이 작성을 합니다.

import axios, { AxiosRequestConfig } from 'axios';

const AxiosConfigure: AxiosRequestConfig = {
  baseURL: process.env.API_SERVER_URL,
  timeout: 1000,
  withCredentials: true,
};

const customAxios = axios.create(AxiosConfigure);
const hlogToken = localStorage.getItem('hlog_access_token');

customAxios.interceptors.request.use((config) => {
  // 모든 Request Header에 Access토큰을 넣어주는 역할
  if (!config.headers.authorization && hlogToken) {
    config.headers.authorization = JSON.parse(hlogToken);
  }
  return config;
}, (error) => error);

customAxios.interceptors.response.use(
  (response) => response,
  async (error) => {
    // 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
    const prevRequest = error?.config;
    if (error?.response?.status === 403 && !prevRequest?.sent) {
      prevRequest.sent = true;
      const refreshToken = async () => {
        const response = await customAxios.post('/auth/refresh');
        const { accessToken } = response.data.payload;

        return accessToken;
      };
      const accessToken = await refreshToken();
      prevRequest.headers.authorization = accessToken;
      return customAxios(prevRequest);
    }
    return customAxios;
  },
);

export default customAxios;

위 코드와 다른점은, request.use, response.use는 딱 한번만 실행된다는 점이 다릅니다.

이제 메모리 누수가 발생할 요인이 없고 정상적으로 애플리케이션도 동작하는 모습입니다.

이제 custom axios 인터셉터는 더이상 Hooks로 관리하는 주체가 아니라서 그것을 분리하는 작업을 수행해야 합니다.

사실, Hook으로 관리하는게 더욱 좋은 방법인 것 같은데 추후에는 Hook으로 수정하면서 포스트를 수정하도록 하겠습니다.

 

감사합니다.

Comments