jwt인증 로직 정리 (axios interceptor)

Ryan Cho·2025년 1월 20일
0

FE개발자로서 로그인과 같은 인증에 관련된 부분에 대해 jwt방식으로 정리해보자

인증 서버 구현

git clone https://github.com/shoveller/simple-jwt-express
(vscode에서 서버실행 'REST Client' 플러그인)

백엔드 로직이 목표가 아니기에 인증서버는 간단하게 사용한다

JWT기준 인증서버의 구성요소 (엔드포인트)

위 주소로 클론을 받으면 가장 필수적인 jwt인증 서버의 3가지 엔드포인트로 구성되어있다.

1. 통행증 발급용 ('/login')

통행증, 즉 jwt토큰을 발급받는 용도의 엔드포인트이다.
post요청을 통해 얻어지는 jwt토큰(accessToken)은 아래의 주소에서 쉽게 검사해 볼 수있다.

https://jwt.io/

요청으로 얻어지는 (아이디와 비밀번호의 검사 등 세부적인 로직은 제외한다) 토큰은 크게 두가지로 얻어진다.

  • accessToken
  • refreshToken
    (두 토큰 모두 decode에 성공 할 시에 유효한 토큰으로 간주)

이 둘의 차이는 토큰의 정보 속의 만료시간의 차이이다(exp).
accessToken이 만료됐을 때 통행증을 갱신하기 위한 목적으로 만료시간이 더 긴 refreshToken이 존재한다.

2. 통행증 검사용 ('/resource')

보호된 리소스에 접근하기 위해 accessToken을 사용해서 해당 api를 호출할 수 있다.
어떻게 사용하는가?
Authorization 헤더의 Bearer스키마를 통해 토큰을 전달한다.

  headers: {
             Authorization: `Bearer ${accessToken}`,
         },

위의 인증서버의 '/resource' 엔드포인트에 accessToken을 전달하면 userName이 리소스로 출력된다.

3. 통행증 갱신용 ('/refresh')

accessToken 만료시, refreshToken을 사용해서 (refreshToken도 유효성 검사는 필수) 새로운 accessToken을 발급한다.

클라이언트의 인증

위의 인증서버를 이용해서 클라이언트 인증을 구현해보자.

레시피로 react-router와 react-query를 사용한다.(선호한다)

인증 로직은 다음 그림과 같다.

axios interceptor 적용

하지만 모든 인증관련 요청에 매번 localStorage에서 토큰을 가져오고, 헤더의 토큰을 주입하는 등 불필요하게 중복코드가 많이 발생하게된다. 이를 자동화 하기위해 axiosinterceptor기능을 사용한다.

axios클라이언트(인스턴스)로 리퀘스트를 보낼 때 엑세스 토큰 주입

(나가는 값을 암시하는 axios interceptor + 성공함수)

axiosInstance.interceptors.request.use((config:InternalAxiosRequestConfig) => {
    const accessToken = localStorage.getItem('accessToken')
    config.headers.Authorization = `Bearer ${accessToken}`

    return config
})

axios클라이언트(인스턴스)로 응답을 받을 때 엑세스 토큰이 있는지 확인

(성공함수는 단순 res => res, 실패함수를 대비 async(error) => {}

axiosInstance.interceptors.response.use((res)=>res, async (error:AxiosError) => {
    // 1. 이전 요청 설정을 보관한다.
    const prevConfig = error.config

    // 2. 통행증을 재발급 받는다 (리프레시 토큰이 필요한 상황일 때)
    const refreshToken = localStorage.getItem('refreshToken') as string
    const {accessToken, refreshToken: newRefreshToken} = await refresh(refreshToken)

    //새로운 엑세스 토큰으로 헤더 업데이트
    localStorage.setItem('accessToken', accessToken)
    localStorage.setItem('refreshToken', newRefreshToken)
    // 3. 이전 요청 설정에 통행증을 추가해서 다시 요청한다.
    return axiosInstance(prevConfig!)
})

api를 정의한 파일의 전체 코드는 다음과 같다.

import axios, {AxiosError, InternalAxiosRequestConfig} from 'axios'

type TokensResponseType = {
    accessToken: string;
    refreshToken: string;
};

type MessageResponseType = {
    message: string;
};

const axiosInstance = axios.create({baseURL: "http://localhost:3000"})

//axios클라이언트(인스턴스)로 리퀘스트를 보낼 때 엑세스 토큰 주입 (나가는 값을 암시하는 axios interceptor + 성공함수)
axiosInstance.interceptors.request.use((config:InternalAxiosRequestConfig) => {
    const accessToken = localStorage.getItem('accessToken')
    config.headers.Authorization = `Bearer ${accessToken}`

    return config
})

//axios클라이언트(인스턴스)로 응답을 받을 때 엑세스 토큰이 있는지 확인 (성공함수는 단순 res => res, 실패함수를 대비 async(error) => {}
axiosInstance.interceptors.response.use((res)=>res, async (error:AxiosError) => {
    // 1. 이전 요청 설정을 보관한다.
    const prevConfig = error.config

    // 2. 통행증을 재발급 받는다 (리프레시 토큰이 필요한 상황일 때)
    const refreshToken = localStorage.getItem('refreshToken') as string
    const {accessToken, refreshToken: newRefreshToken} = await refresh(refreshToken)

    //새로운 엑세스 토큰으로 헤더 업데이트
    localStorage.setItem('accessToken', accessToken)
    localStorage.setItem('refreshToken', newRefreshToken)
    // 3. 이전 요청 설정에 통행증을 추가해서 다시 요청한다.
    return axiosInstance(prevConfig!)
})


export const login = (username: string) => {
    return axiosInstance.post("/login",{
        headers: {
            "Content-Type": "application/json",
        },
            username
    }).then<TokensResponseType>(res => res.data)
};

export const resource = () => {
    return axiosInstance.get("/resource",{
    }).then<MessageResponseType>(res => res.data)
};

export const refresh = (refreshToken: string) => {
    return fetch("http://localhost:3000/refresh", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            refreshToken,
        }),
    }).then<TokensResponseType>((res) => res.json());
};

라우터 레이아웃의 활용

더 나아가 인증 관련 라우터들의 레이아웃으로 accessToken이 없을 경우 다른 페이지로 navigate를 하도록 전역적으로 설정할 수 있다.

const AuthLayout = () => {
    const accessToken = localStorage.getItem('accessToken')
    if(!accessToken) {
        return <Navigate to='/login' replace={true}/>
    }
    return <Outlet/>

}

const router = createBrowserRouter(createRoutesFromElements(
    <Route>
        <Route element={<AuthLayout/>}>
            <Route path='/' element={<Home/>}/>
            <Route path='/login' element={<Login/>}/>
        </Route>
    </Route>
))

로그아웃

로그아웃 기능은 로컬스토리지에서 accessToken을 제거하는것으로 해결할 수 있다.

    const navigate = useNavigate()
    const onClick = () => {
        localStorage.removeItem('accessToken')
        localStorage.removeItem('refreshToken')

        navigate('/login')
    }

보안이슈 적용

플로우를 설명하기 위해 모든 토큰을 로컬스토리지에 저장을 했지만,
실제 서비스에서 적용할때는 절대로 로컬스토리지에 저장하면 안된다.
CSRF 취약점과 XSS취약점을 대비

accessToken => 웹애플리케이션 내의 로컬 변수에 저장

refreshToken => secure httpOnly 쿠키에 저장

참고: 외부 링크

하이브리드 방식

accessToken을 메모리에 저장하면 새로고침 시 state가 초기화되기 때문에, 이를 막기 위해
새로고침이 됐을 때 refresh토큰을 이용해서 매번 토큰을 갱신하면 해결할 수 있음

// auth/useAuth.ts
import { useAtom } from 'jotai';
import { accessTokenAtom } from '../store/auth.store';

export const useAuth = () => {
  const [accessToken, setAccessToken] = useAtom(accessTokenAtom);

  const initializeAuth = async () => {
    try {
      const { data } = await api.post('/auth/refresh');
      setAccessToken(data.accessToken);
      return true;
    } catch (error) {
      setAccessToken(null);
      return false;
    }
  };

  return { accessToken, setAccessToken, initializeAuth };
};

// App.tsx
function App() {
  const { initializeAuth } = useAuth();
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const init = async () => {
      await initializeAuth();
      setIsLoading(false);
    };

    init();
  }, []);

  if (isLoading) {
    return <LoadingSpinner />;
  }

  return <Router>{/* 라우트 설정 */}</Router>;
}
profile
From frontend to fullstack

0개의 댓글