From d63fe79560bb20daebe9910b3227e7215a601757 Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Thu, 19 Jun 2025 18:30:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/Auth.tsx | 13 ++++++++++--- src/pages/LoginPage/components/Login.tsx | 9 ++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/apis/Auth.tsx b/src/apis/Auth.tsx index fe66523..d0f0664 100644 --- a/src/apis/Auth.tsx +++ b/src/apis/Auth.tsx @@ -1,3 +1,4 @@ +import axios from 'axios'; import { api } from './api'; export interface JoinFormType { @@ -14,12 +15,18 @@ export interface LoginFormType { export const authAPI = { login: async (data: LoginFormType) => { - const response = await api.post('auth/login', data); - // console.log(response.data); + const response = await axios.post( + `${import.meta.env.VITE_SERVER_ADDRESS}/auth/login`, + data, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + localStorage.setItem('accessToken', response.data.accessToken); localStorage.setItem('refreshToken', response.data.refreshToken); - localStorage.setItem('token', response.data.token); localStorage.setItem('_id', response.data.user.id); localStorage.setItem('isAdmin', response.data.user.isAdmin); if (data.rememberPhone) { diff --git a/src/pages/LoginPage/components/Login.tsx b/src/pages/LoginPage/components/Login.tsx index 43a6d13..79e2c03 100644 --- a/src/pages/LoginPage/components/Login.tsx +++ b/src/pages/LoginPage/components/Login.tsx @@ -28,7 +28,8 @@ const Login = () => { if (authAPI.checkAuth()) { nav('/manage/map'); } - }, []); + }, [nav]); + //아이디 저장이 되어있는 경우 useEffect(() => { const remembered = authAPI.getRememberedPhone(); @@ -66,7 +67,6 @@ const Login = () => { severity: 'error', autoHideDuration: 6000, }); - console.error('login Error:', error); }, }); @@ -96,6 +96,7 @@ const Login = () => { }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { + e.preventDefault(); onSubmit(); } }; @@ -143,7 +144,9 @@ const Login = () => { 아이디 저장 - 로그인 + + 로그인 + ); From e01c9566d85411ded58d6bdc8672d607ea2139d4 Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Thu, 19 Jun 2025 20:30:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20token=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=8B=9C=20RaceCondition=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 18 ++++++ src/apis/api.tsx | 148 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e7c1f95..78680d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,27 @@ import './styles/font.css'; import { NotificationProvider } from './components/NotificationProvider'; import { NavermapsProvider } from 'react-naver-maps'; import { BrowserRouter } from 'react-router-dom'; +// import { useEffect } from 'react'; +// import { api, getRefreshStatus, resetRefreshState } from './apis/api'; function App() { const queryClient = new QueryClient(); + + // //리프레시 함수 디버깅을 위한 전역함수 등록 (개발시에만 사용) + // useEffect(() => { + // (window as any).checkRefreshStatus = getRefreshStatus; + // (window as any).resetRefresh = resetRefreshState; + // (window as any).api = api; + // const interval = setInterval(() => { + // const status = getRefreshStatus(); + // if (status.isRefreshing || status.queueLength > 0) { + // console.log('🔄 Refresh Status:', status); + // } + // }, 1000); + + // return () => clearInterval(interval); + // }, []); + return ( <> diff --git a/src/apis/api.tsx b/src/apis/api.tsx index 4610ffd..ff7951e 100644 --- a/src/apis/api.tsx +++ b/src/apis/api.tsx @@ -1,4 +1,8 @@ -import axios from 'axios'; +import axios, { + AxiosResponse, + AxiosError, + InternalAxiosRequestConfig, +} from 'axios'; export const api = axios.create({ baseURL: `${import.meta.env.VITE_SERVER_ADDRESS}`, @@ -8,24 +12,76 @@ export const api = axios.create({ withCredentials: true, }); +// TypeScript 타입 정의 +interface QueueItem { + resolve: (token: string) => void; + reject: (error: any) => void; +} + +interface RefreshTokenResponse { + accessToken: string; + refreshToken?: string; +} + +// Race Condition 방지를 위한 전역 변수들 +let isRefreshing = false; +let failedQueue: QueueItem[] = []; + +// 대기열에 있는 요청들을 처리하는 함수 +const processQueue = (error: any, token: string | null = null): void => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token!); + } + }); + + failedQueue = []; +}; + // Request interceptor -api.interceptors.request.use((config) => { - const accessToken = localStorage.getItem('accessToken'); // 'token' → 'accessToken'으로 변경 - if (accessToken) { +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const accessToken = localStorage.getItem('accessToken'); + if (accessToken && config.headers) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); -// Response interceptor +// Response interceptor with Race Condition prevention api.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // 로그인 요청은 interceptor 적용 안 함 + if (originalRequest.url?.includes('/auth/login')) { + return Promise.reject(error); + } // 401 에러이고, 재시도하지 않은 요청인 경우 if (error.response?.status === 401 && !originalRequest._retry) { + // 이미 refresh가 진행 중인 경우 + if (isRefreshing) { + // 대기열에 추가하고 결과를 기다림 + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: (token: string) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}`; + } + resolve(api(originalRequest)); + }, + reject, + }); + }); + } + originalRequest._retry = true; + isRefreshing = true; try { const refreshToken = localStorage.getItem('refreshToken'); @@ -33,8 +89,10 @@ api.interceptors.response.use( throw new Error('No refresh token available'); } + console.log('Token refresh 시작...'); + // Refresh Token으로 새로운 Access Token 요청 - const response = await axios.post( + const response = await axios.post( `${import.meta.env.VITE_SERVER_ADDRESS}/auth/refresh`, {}, { @@ -53,34 +111,76 @@ api.interceptors.response.use( localStorage.setItem('refreshToken', newRefreshToken); } + console.log('Token refresh 성공'); + + // 대기 중인 모든 요청들에게 새 토큰 전달 + processQueue(null, accessToken); + // 원래 요청에 새로운 Access Token 적용하여 재시도 - originalRequest.headers.Authorization = `Bearer ${accessToken}`; + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + } return api(originalRequest); } catch (refreshError) { - // Refresh Token도 만료되었거나 오류 발생 console.error('Token refresh failed:', refreshError); - // 모든 토큰 정보 삭제 (아이디 저장 기능은 유지) - const rememberedPhone = localStorage.getItem('rememberedPhone'); - const rememberPhoneChecked = localStorage.getItem( - 'rememberPhoneChecked' - ); + // 대기 중인 모든 요청들을 reject + processQueue(refreshError, null); - localStorage.clear(); + // 네트워크 에러 vs 인증 에러 구분 + const axiosRefreshError = refreshError as AxiosError; + if ( + axiosRefreshError.response?.status === 401 || + axiosRefreshError.response?.status === 403 || + (refreshError as Error).message === 'No refresh token available' + ) { + // 진짜 인증 만료 - 로그아웃 처리 + console.log('인증 만료로 인한 로그아웃 처리'); - // 아이디 저장이 체크되어 있었다면 복원 - if (rememberPhoneChecked === 'true' && rememberedPhone) { - localStorage.setItem('rememberedPhone', rememberedPhone); - localStorage.setItem('rememberPhoneChecked', 'true'); - } + // 모든 토큰 정보 삭제 (아이디 저장 기능은 유지) + const rememberedPhone = localStorage.getItem('rememberedPhone'); + const rememberPhoneChecked = localStorage.getItem( + 'rememberPhoneChecked' + ); + + localStorage.clear(); - // 로그인 페이지로 리다이렉트 - window.location.href = '/'; + // 아이디 저장이 체크되어 있었다면 복원 + if (rememberPhoneChecked === 'true' && rememberedPhone) { + localStorage.setItem('rememberedPhone', rememberedPhone); + localStorage.setItem('rememberPhoneChecked', 'true'); + } + + // 로그인 페이지로 리다이렉트 + window.location.href = '/'; + } else { + // 네트워크 에러 등 - 토큰은 유지하고 원본 에러만 반환 + console.warn('Network error during refresh, keeping tokens'); + } return Promise.reject(refreshError); + } finally { + isRefreshing = false; } } return Promise.reject(error); } ); + +// 디버깅을 위한 함수 +//리프레시 토큰 상태 출력 +export const getRefreshStatus = (): { + isRefreshing: boolean; + queueLength: number; +} => ({ + isRefreshing, + queueLength: failedQueue.length, +}); + +// 강제로 refresh 상태를 리셋하는 함수 +export const resetRefreshState = (): void => { + isRefreshing = false; + processQueue(new Error('Refresh state reset'), null); + console.log('Refresh state has been reset'); +};