토큰 관리 문제
초기에 카카오 로그인 이후 토큰을 발급받은 과정에서 토큰을 쿼리 파라미터를 통해 취득하여 전역 변수에 저장하는 방식으로 사용했다. 하지만, 쿼리 파라미터 방식은
XSS
공격으로 토큰이 탈취되는 위험성뿐만 아니라 CSRF
공격에도 매우 취약하다.const serchParams = useSearchParams() const accessToken = serchParams.get('accessToken')
따라서 리프레시 토큰은 세션 쿠키로, 액세스 토큰은
body
로 전달받아 전역 변수에 안전하게 저장하고, 백엔드에서는 httpOnly
및 secure
를 설정하여 자바스크립트로 접근이 불가능하도록 보안성 강화했다. 리프레시 토큰은 서버에서 관리하고, 액세스 토큰은 자바스크립트 변수에 담아져있기 때문에 탈취 위험성 제거된다. 이렇게 저장된 토큰 값은 요청 인터셉터에서 감지한 후에 요청의 header
에 토큰을 첨부하여 안전하게 API 요청 콜을 보내는 방식을 사용했다.하지만, 로그인을 어떻게 유지할지에 대한 또 다른 문제가 생겼다.
리코일 선언 시점
액세스 토큰이 만료될 시점에
isTokenValid
함수로 토큰의 유효성 검증한 후에 액세스 토큰이 만료되었다는 신호가 오면 재발급 토큰 API를 콜 하여 새로운 액세스 토큰을 발급받아 전역변수에 담아주는 로직을 생각했다.export const responseErrorRejecter = async (error: AxiosError): Promise<AxiosError> => { ... if (status === 403) { const hasToken = IsTokenValid(accessToken) if (!hasToken) { const response = await refresh() setNewAccessToken(response.data.result) error.headers = { Authorization: `Bearer ${error.response.data.reissuedAccessToken}`, } as AxiosRequestHeaders return Promise.reject(error) } } ... }
하지만, 리코일로 선언된
setNewAccessToken
은 리코일 특성상 클라이언트 사이드이므로 비동기 로직에서 가져올 수 없는 문제가 생겼다. 토큰을 전역 변수로 선언했기 때문에 로그인을 유지하려면 setNewAccessToken
에 무조건 새로운 토큰을 넣어야 하는 상황이다.그래서 비동기 로직에서 처리하지 않고, 따로 커스텀 훅을 만들어 클라이언트 사이드에서 리코일의 토큰 값을 가져와 토큰 만료 시 재발급 토큰을 호출하여 로그인이 유지되는 로직을 생각했다.
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <RecoilProvider> <QueryProvider> <UseAxiosWrapper> {children} </UseAxiosWrapper> </QueryProvider> </RecoilProvider> ) }
리코일과, 리액트 쿼리
state
가 커스텀 훅 UseAxiosWrapper
에서 관찰되어야 하기 때문에 UseAxiosWrapper
을 제일 아래 계층에 선언했다.액세스 토큰의 만료 시에는 재발급 토큰 API를 호출하여 새로운 액세스 토큰을 발급받고, 이를 전역 변수에 저장하도록 했다. 프론트엔드에서 axios 응답 인터셉터로 응답 과정을 낚아채
header
에 발급받은 토큰을 첨부하여 재요청을 보내도록 했다.const setNewAccessToken = useSetRecoilState<string | undefined>(accessTokenState) const accessToken = useRecoilValue<string | undefined>(accessTokenSelector) useEffect(() => { ... const hasToken = IsTokenValid(accessToken) if (!hasToken) { if (accessToken) { config.headers = { Authorization: `Bearer ${accessToken}`, } as AxiosRequestHeaders } else { const response = await refresh() setNewAccessToken(response.data.result) config.headers = { Authorization: `Bearer ${response.data.result}`, } as AxiosRequestHeaders } return config }, [accessToken])
분기 처리
그렇다면 로그인을 유지하려면? 응답 인터셉터가 있으니, 요청 인터셉터도 있겠다는 생각으로 찾아봤더니 있었다.
요청 인터셉터로 로그인을 유지를, 응답 인터셉터로 토큰이 만료되었을 때는 재요청을 보내도록 분기 처리했다.
useEffect(() => { // 요청 예외처리 const requestInterceptor = withAuth.interceptors.request.use( async (config: InternalAxiosRequestConfig<AxiosRequestConfig>) => { ... const hasToken = IsTokenValid(accessToken) if (!hasToken) { if (accessToken) { config.headers = { Authorization: `Bearer ${accessToken}`, } as AxiosRequestHeaders } else { const response = await refresh() setNewAccessToken(response.data.result) config.headers = { Authorization: `Bearer ${response.data.result}`, } as AxiosRequestHeaders } return config } ... , [accessToken]) } ) // 응답 예외처리 const responseInterceptor = withAuth.interceptors.response.use( response => response, error => { ... if (error.response.data.status === 401) { setNewAccessToken(error.response.data.reissuedAccessToken) error.headers = { Authorization: `Bearer ${error.response.data.reissuedAccessToken}`, } as AxiosRequestHeaders return withAuth.request(error.config) } ... } ) return () => { withAuth.interceptors.request.eject(requestInterceptor) withAuth.interceptors.request.eject(responseInterceptor) } }, [accessToken])
로그인 이전에는?
최상단
RootLayout
에 선언되었기 때문에 매 요청마다 발생한다. 로그인 플로우 타기 이전 모든 요청들은 위 과정들이 불필요하다. 메모리 낭비하지 않기 위해 요청 인터셉터에서 예외 처리했다.const authExceptPaths = ['login', 'sign', 'findEmail', 'findPassword', 'oauth/kakaoLogin', 'oauth/kakaoCallback'] const isAuthNeeded = config.url && !authExceptPaths.includes(config.url) if (!isAuthNeeded) { return config }
후기
로그인과 토큰을 A-Z까지 직접적으로 다뤄본 게 처음이어서 플로우를 이해하는데 꽤나 고생했었다. 그만큼 이해가 되었지만, 이면으로는 프론트엔드적으로 혼자보다는 누군가와 함께 공유해 보고, 같이 고민했더라면 더 좋았을 것 같다는 아쉬움이 남는다.
댓글