Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<QueryClientProvider client={queryClient}>
Expand Down
13 changes: 10 additions & 3 deletions src/apis/Auth.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import axios from 'axios';
import { api } from './api';

export interface JoinFormType {
Expand All @@ -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) {
Expand Down
148 changes: 124 additions & 24 deletions src/apis/api.tsx
Original file line number Diff line number Diff line change
@@ -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}`,
Expand All @@ -8,33 +12,87 @@ 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');
if (!refreshToken) {
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<RefreshTokenResponse>(
`${import.meta.env.VITE_SERVER_ADDRESS}/auth/refresh`,
{},
{
Expand All @@ -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');
};
9 changes: 6 additions & 3 deletions src/pages/LoginPage/components/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const Login = () => {
if (authAPI.checkAuth()) {
nav('/manage/map');
}
}, []);
}, [nav]);

//아이디 저장이 되어있는 경우
useEffect(() => {
const remembered = authAPI.getRememberedPhone();
Expand Down Expand Up @@ -66,7 +67,6 @@ const Login = () => {
severity: 'error',
autoHideDuration: 6000,
});
console.error('login Error:', error);
},
});

Expand Down Expand Up @@ -96,6 +96,7 @@ const Login = () => {
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
onSubmit();
}
};
Expand Down Expand Up @@ -143,7 +144,9 @@ const Login = () => {
<Checkbox />
<CheckboxLabel>아이디 저장</CheckboxLabel>
</CheckboxWrapper>
<LoginButton onClick={onSubmit}>로그인</LoginButton>
<LoginButton type="button" onClick={onSubmit}>
로그인
</LoginButton>
</InputWrapper>
</>
);
Expand Down
Loading