React와 Axios로 구현하는 효율적인 인증 시스템: Access & Refresh token 자동 갱신

Wynter24·2023년 10월 29일
12

Refresh token 사용하는 이유

Access Token의 보안 문제

‣ 문제점

Access Token이 탈취되면 만료될 때까지 탈취자가 사용자 권한으로 접근이 가능하다. (보안 취약)

‣ 기본 개념

Access Token은 발급 후 서버에 저장되지 않고, 토큰 자체로 인증


Refresh Token의 역할

두 토큰의 차이

Refresh Token과 Access Token 모두 JWT 형태이다.
하지만 Access Token인증에, Refresh Token은 토큰 재발급에 사용된다.

‣ 작동 원리

  • 사용자가 로그인하면 서버는 클라이언트에게 Access Token과 Refresh Token을 동시에 발급한다.
  • 서버는 데이터베이스에 Refresh Token을 저장하고, 클라이언트는 Access Token과 Refresh Token을 쿠키, 세션 혹은 웹스토리지에 저장하고 요청이 있을때마다 이 둘을 헤더에 담아서 보낸다.
  • Access Token은 짧은 기간, Refresh Token은 긴 기간 유효.

‣ 재발급 과정

Access Token 만료 시(만료된 Access Token을 서버에 보내면), 서버는 같이 보내진 Refresh Token을 DB에 있는 것과 비교해서 일치하면 다시 Access Token을 재발급한다.
즉, Refresh Token 접근에 대한 권한을 주는 것이 아니라 Access Token 재발급에만 관여하는 것이다.

사용 예시

‣ 예시 설정
Access Token 유효기간 1시간, Refresh Token 유효기간 2주.
‣ 토큰 재발급
Access Token 만료 후, Refresh Token으로 새로운 Access Token 발급 받는다.
‣ 로그아웃 시
사용자가 로그아웃하면 Refresh Token 삭제. 새 로그인 시, 새로운 토큰 발급.

따라서 짧은 Access Token과 긴 Refresh Token 조합으로 보안 강화 및 사용자 편의 제공할 수 있다. 하지만 Refresh Token도 보안 위험 있으므로 적절한 유효기간 설정이 필요하다.


코드 작성하기

✅인터셉터 설정

모든 응답과 오류를 가로채서 특정 로직을 수행
axios.interceptors.response.use → 응답 인터셉터를 설정

성공 응답 처리😊

받은 응답을 그대로 반환

// 응답 인터셉터 설정
    const interceptor = axios.interceptors.response.use(
      response => {
        // 성공적인 응답 처리
        return response;
      },

오류 처리😔

오류가 발생하면 오류로부터 원래의 요청 설정 (originalConfig) 및 오류 메시지와 상태 코드를 추출한다.

async error => {
        // 오류 처리
        const originalConfig = error.config; // 기존에 수행하려고 했던 작업
        const msg = error.response.data.msg; // error msg from backend
        const status = error.response.status; // 현재 발생한 에러 코드
  • 액세스 토큰 만료 처리
    액세스 토큰이 만료된 경우, 새로운 토큰을 요청하고 원래 요청을 재시도
    상태 코드가 401이고, 오류 메시지가 "Expired Access Token. 토큰이 만료되었습니다." 인 경우, 새 액세스 토큰을 요청한다.
if (status === 401 ) {
          // 액세스 토큰 만료 시 처리
          if(msg == "Expired Access Token. 토큰이 만료되었습니다.") {
            // 새 토큰 요청
            await axios.post(
              `${import.meta.env.VITE_APP_GENERATED_SERVER_URL}/api/token/reissue`,{},
              {headers: {
                Authorization: `${localStorage.getItem('Authorization')}`,
                Refresh: `${localStorage.getItem('Refresh')}`,
              }},
            )

새 토큰 요청이 성공하면 로컬 스토리지에 새 토큰을 저장하고, 원래 요청을 새 토큰으로 다시 시도한다.
(수정됨. 아래 코드 리팩토링 참고!)
.then((res) => {
              // 새 토큰 저장 및 기존 토큰 삭제
              localStorage.setItem("authorization", res.headers.authorization);
              localStorage.setItem("refresh", res.headers.refresh);
              localStorage.removeItem('Authorization');
              localStorage.removeItem('Refresh');

              // 원래 요청 재시도
              originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
              originalConfig.headers["refresh"]= res.headers.refresh;

              return refreshAPI(originalConfig);
            })

요청이 성공적으로 완료되면 페이지를 리로드한다.

(코드 리팩토링)

.then((res) => {
              // 로컬스토리지에 새 토큰 저장
              localStorage.setItem("Authorization", res.headers.authorization);
              localStorage.setItem("Refresh", res.headers.refresh);

              // 새로 응답받은 데이터로 토큰 만료로 실패한 요청에 대한 인증 시도 (header에 토큰 담아 보낼 때 사용)
              originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
              originalConfig.headers["refresh"]= res.headers.refresh;

              // 새로운 토큰으로 재요청
              return refreshAPI(originalConfig);
            })
  • 만약 이미 키에 값이 존재한다면, 기존의 값을 새 값으로 덮어쓰게 된다. localStorage.setItem("Authorization", res.headers.authorization); 를 실행하면 Authorization이라는 키에 대한 기존의 값을 새로운 res.headers.authorization 값으로 대체된다.
    따라서 기존 토큰을 명시적으로 removeItem으로 제거할 필요가 없다! setItem을 호출할 때마다 해당 키에 대한 최신 값으로 업데이트되기 때문이다.
  • 로컬스토리지에 저장되는 변수명을 대문자로 수정한 이유는 백엔드에서 로그인을 확인할 때 저 변수명으로 확인한다고 해서 수정했다.

재발급하여 로컬스토리지에 저장한 토큰을 사용하지 않고 응답 데이터 토큰을 사용하는 이유


.then(() =>{
              window.location.reload(); 
            })
  • 리프레시 토큰 만료 처리
    리프레시 토큰이 만료되면 로컬 스토리지를 비우고(사용자를 로그아웃 처리하고) 사용자를 로그인 페이지로 리디렉트한다.
     else{
            // 리프레시 토큰 만료 시 처리
            localStorage.clear();
            navigate("/"); 
            window.alert("토큰이 만료되어 자동으로 로그아웃 되었습니다.")
          }

✅인터셉터 제거

컴포넌트가 사라질 때(언마운트될 때) 설정한 인터셉터를 제거하여 다른 컴포넌트나 페이지에서의 영향 방지

// 컴포넌트 언마운트 시 인터셉터 제거
    return () => {
      axios.interceptors.response.eject(interceptor);
    };

전체 코드 (컴포넌트)

import axios from 'axios';
import { useEffect } from 'react';
import { useNavigate } from 'react-router'

export default function TokenRefresher() {
  const navigate = useNavigate();

  useEffect(() => {
    const refreshAPI = axios.create({
      baseURL: import.meta.env.VITE_APP_GENERATED_SERVER_URL,
      headers: {"Content-Type": "application/json"} // header의 Content-Type을 JSON 형식의 데이터를 전송한다
    });

    const interceptor = axios.interceptors.response.use(
      // 성공적인 응답 처리
      response => {
        // console.log('Starting Request', response)
        return response;
      },
      async error => {
        const originalConfig = error.config; // 기존에 수행하려고 했던 작업
        const msg = error.response.data.msg; // error msg from backend
        const status = error.response.status; // 현재 발생한 에러 코드
        // access_token 재발급
        if (status === 401 ) {
          if(msg == "Expired Access Token. 토큰이 만료되었습니다") {
            // console.log("토큰 재발급 요청");
            await axios.post(
              `${import.meta.env.VITE_APP_GENERATED_SERVER_URL}/api/token/reissue`,{},
              {headers: {
                Authorization: `${localStorage.getItem('Authorization')}`,
                Refresh: `${localStorage.getItem('Refresh')}`,
              }},
            )
            .then((res) => {
              // console.log("res : ", res);
              // 새 토큰 저장
              localStorage.setItem("Authorization", res.headers.authorization);
              localStorage.setItem("Refresh", res.headers.refresh);

              // 새로 응답받은 데이터로 토큰 만료로 실패한 요청에 대한 인증 시도 (header에 토큰 담아 보낼 때 사용)
              originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
              originalConfig.headers["refresh"]= res.headers.refresh;

              // console.log("New access token obtained.");
              // 새로운 토큰으로 재요청
              return refreshAPI(originalConfig);
            })
            .catch(() => {
              console.error('An error occurred while refreshing the token:', error);
            });
          }
          // refresh_token 재발급과 예외 처리
          // else if(msg == "만료된 리프레시 토큰입니다") {
          else{
            localStorage.clear();
            navigate("/"); 
            // window.alert("토큰이 만료되어 자동으로 로그아웃 되었습니다.")
          }
        }
        else if(status == 400 || status == 404 || status == 409) {
          // window.alert(msg); 
          // console.log(msg)
        }
        // console.error('Error response:', error);
        // 다른 모든 오류를 거부하고 처리
        return Promise.reject(error);
      },
    );
    return () => {
      axios.interceptors.response.eject(interceptor);
    };
  }, []);
  return (
    <div></div>
  )
}

정리
이 컴포넌트는 API 요청 중 액세스 토큰이 만료된 경우 자동으로 (리프레시 토큰으로) 새 토큰을 요청하고, 해당 토큰으로 원래의 요청을 다시 시도한다.
또한, 리프레시 토큰이 만료된 경우 사용자를 로그아웃시키고 로그인 페이지로 이동시킨다.
useEffect의 반환 함수에서 설정한 인터셉터를 제거한다. 이는 컴포넌트가 언마운트될 때 인터셉터가 더 이상 적용되지 않도록 한다.


적용하기 (라우터)

const Router = () => {
  return (
    <BrowserRouter>
      <TokenRefresher />
      <ConditionalLayout>
        <Routes>
          <Route path="/" element={<Home />} />\
      </ConditionalLayout>
    </BrowserRouter>
  );
};

export default Router;

인터셉터를 사용함으로써 전체 애플리케이션의 HTTP 통신 관련 로직을 효율적으로 관리할 수 있다. 특히 토큰 기반 인증 시스템에서는 토큰의 자동 갱신과 관련된 로직을 중앙에서 처리할 수 있어 코드의 복잡성을 크게 줄이고 보안성과 사용자 경험을 향상시키는 데 큰 도움이 될 것이다.


참고자료
Access Token & Refresh Token 원리
[React] axios interceptor를 이용한 token refresh

profile
내가 다시 보려고 쓰는 개발.log

0개의 댓글