Skip to content

Commit e03d454

Browse files
authored
Merge pull request #41 from WhereYouAd/refactor/#40
[refactor#40] 보안 및 인증 로직의 안정성 강화
2 parents c3b1923 + d2553ea commit e03d454

8 files changed

Lines changed: 95 additions & 45 deletions

File tree

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ import "./App.css";
22

33
import { RouterProvider } from "react-router-dom";
44

5+
import { useTokenRefresh } from "@/hooks/auth/useTokenRefresh";
6+
57
import { router } from "@/routes/Router";
68

79
function App() {
10+
useTokenRefresh();
11+
812
return (
913
<>
1014
<RouterProvider router={router} />

src/constants/auth.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

src/hooks/auth/useEmailVerification.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
44
import { toast } from "sonner";
55
import type { z } from "zod";
66

7-
import { AUTH_TIMER_DURATION } from "@/constants/auth";
8-
97
import { step01Schema } from "@/utils/validation";
108

119
import { useAuth } from "@/hooks/auth/useAuth";
@@ -43,14 +41,11 @@ export const useEmailVerification = ({
4341
const watchedEmail = useWatch({ control, name: "email" });
4442
const watchedCode = useWatch({ control, name: "code" });
4543

46-
const { formattedTime, restart, stop, isExpired } = useTimer(
47-
AUTH_TIMER_DURATION,
48-
{
49-
onExpire: () => {
50-
toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요.");
51-
},
44+
const { formattedTime, restart, stop, isExpired } = useTimer(0, {
45+
onExpire: () => {
46+
toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요.");
5247
},
53-
);
48+
});
5449

5550
const handleEditEmail = useCallback(() => {
5651
setSendCode(false);

src/hooks/auth/useTokenRefresh.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect } from "react";
2+
3+
import { reissueToken } from "@/api/auth/auth";
4+
import useAuthStore from "@/store/useAuthStore";
5+
6+
export const useTokenRefresh = () => {
7+
const { setAccessToken, login, logout } = useAuthStore();
8+
9+
useEffect(() => {
10+
const initAuth = async () => {
11+
try {
12+
const { data } = await reissueToken();
13+
if (data.accessToken) {
14+
// TODO: 재발급 성공 시 로그인 처리
15+
login("[email protected]", data.accessToken);
16+
}
17+
} catch (error) {
18+
console.log("토큰 재발급 실패:", error);
19+
logout();
20+
}
21+
};
22+
23+
initAuth();
24+
}, [login, logout, setAccessToken]);
25+
};

src/lib/axiosInstance.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,26 @@ import axios, { type AxiosRequestConfig } from "axios";
22

33
import useAuthStore from "@/store/useAuthStore";
44

5+
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
6+
7+
if (!BASE_URL) {
8+
throw new Error("API 서버 주소(VITE_API_BASE_URL)가 설정되지 않았습니다.");
9+
}
10+
511
const axiosConfig: AxiosRequestConfig = {
6-
baseURL: import.meta.env.VITE_API_BASE_URL,
12+
baseURL: BASE_URL,
713
withCredentials: true,
814
headers: {
915
"Content-Type": "application/json",
1016
},
1117
};
1218

13-
// 일반 API 요청용
1419
export const axiosInstance = axios.create(axiosConfig);
15-
16-
// 토큰 재발급 전용
1720
export const authInstance = axios.create(axiosConfig);
1821

1922
axiosInstance.interceptors.request.use(
2023
(config) => {
21-
const token = localStorage.getItem("accessToken");
24+
const token = useAuthStore.getState().accessToken;
2225

2326
if (token) {
2427
config.headers.Authorization = `Bearer ${token}`;
@@ -31,16 +34,28 @@ axiosInstance.interceptors.request.use(
3134
);
3235

3336
let isRefreshing = false;
34-
let refreshSubscribers: ((token: string) => void)[] = [];
37+
interface IRefreshSubscriber {
38+
resolve: (token: string) => void;
39+
reject: (error: unknown) => void;
40+
}
41+
42+
let refreshSubscribers: IRefreshSubscriber[] = [];
3543

36-
// 대기 요청 처리
3744
const onRefreshed = (accessToken: string) => {
38-
refreshSubscribers.forEach((callback) => callback(accessToken));
45+
refreshSubscribers.forEach(({ resolve }) => resolve(accessToken));
3946
refreshSubscribers = [];
4047
};
4148

42-
const addRefreshSubscriber = (callback: (token: string) => void) => {
43-
refreshSubscribers.push(callback);
49+
const onRefreshFailed = (error: unknown) => {
50+
refreshSubscribers.forEach(({ reject }) => reject(error));
51+
refreshSubscribers = [];
52+
};
53+
54+
const addRefreshSubscriber = (
55+
resolve: (token: string) => void,
56+
reject: (error: unknown) => void,
57+
) => {
58+
refreshSubscribers.push({ resolve, reject });
4459
};
4560

4661
axiosInstance.interceptors.response.use(
@@ -50,50 +65,48 @@ axiosInstance.interceptors.response.use(
5065
async (error) => {
5166
const originalRequest = error.config;
5267

53-
// 401 에러 감지
5468
if (error.response?.status === 401) {
55-
// 재발급 진행 중: 대기열 등록
5669
if (isRefreshing) {
57-
return new Promise((resolve) => {
58-
addRefreshSubscriber((accessToken: string) => {
59-
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
60-
resolve(axiosInstance(originalRequest));
61-
});
70+
return new Promise((resolve, reject) => {
71+
addRefreshSubscriber(
72+
(accessToken: string) => {
73+
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
74+
resolve(axiosInstance(originalRequest));
75+
},
76+
(refreshError: unknown) => {
77+
reject(refreshError);
78+
},
79+
);
6280
});
6381
}
6482

65-
// 재발급 실패: 로그아웃
6683
if (
6784
originalRequest.url?.includes("/api/auth/reissue") ||
6885
originalRequest._retry
6986
) {
70-
localStorage.removeItem("accessToken");
87+
useAuthStore.getState().logout();
7188
return Promise.reject(error);
7289
}
7390

74-
// 첫 401: 재발급 시도
7591
originalRequest._retry = true;
7692
isRefreshing = true;
7793

7894
try {
7995
const { data } = await authInstance.post("/api/auth/reissue");
8096

8197
const newAccessToken = data.data.accessToken;
82-
localStorage.setItem("accessToken", newAccessToken);
8398

84-
// 대기 요청 일괄 처리
99+
useAuthStore.getState().setAccessToken(newAccessToken);
85100
onRefreshed(newAccessToken);
86101

87-
// 현재 요청 재시도
88102
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
89103
return axiosInstance(originalRequest);
90104
} catch (refreshError) {
91-
// 재발급 실패: 로그아웃
92-
console.error("Token reissue failed:", refreshError);
105+
console.error("토큰 재발급 실패:", refreshError);
106+
onRefreshFailed(refreshError);
93107
useAuthStore.getState().logout();
94108
return Promise.reject(refreshError);
95109
} finally {
96-
// 상태 초기화
97110
isRefreshing = false;
98111
refreshSubscribers = [];
99112
}

src/pages/auth/RedirectPage.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@ export default function RedirectPage() {
1919
if (parts.length === 2) return parts.pop()?.split(";").shift();
2020
};
2121

22+
const deleteCookie = (name: string) => {
23+
document.cookie =
24+
name + "=; Max-Age=0; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;";
25+
};
26+
2227
const accessToken = getCookie("access_token");
2328

2429
if (accessToken) {
25-
// TODO: Zustand 로그인 상태 업데이트 - 추후 내 정보 조회 API 연동 시 수정
30+
// TODO: 로그인 상태 업데이트
2631
login("[email protected]", accessToken);
2732

33+
deleteCookie("access_token");
34+
2835
toast.success("소셜 로그인되었습니다.");
2936
navigate("/", { replace: true });
3037
} else {
31-
console.error("No access token found in cookies.");
3238
toast.error("소셜 로그인에 실패했습니다. 다시 시도해주세요.");
3339
navigate("/login", { replace: true });
3440
}

src/store/useAuthStore.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { create } from "zustand";
22

33
interface IAuthState {
44
isLoggedIn: boolean;
5+
accessToken: string | null;
56
email: string;
67
password: string;
78
socialId: number;
89

910
login: (email: string, accessToken: string) => void;
1011
logout: () => void;
12+
setAccessToken: (token: string) => void;
1113
setEmail: (email: string) => void;
1214
setPassword: (password: string) => void;
1315
setSocialId: (socialId: number) => void;
@@ -16,22 +18,31 @@ interface IAuthState {
1618

1719
const useAuthStore = create<IAuthState>((set) => ({
1820
isLoggedIn: false,
21+
accessToken: null,
1922
email: "",
2023
password: "",
2124
socialId: -1,
25+
2226
login: (email, accessToken) => {
23-
localStorage.setItem("accessToken", accessToken);
24-
set({ isLoggedIn: true, email });
27+
set({ isLoggedIn: true, email, accessToken });
2528
},
2629
logout: () => {
2730
localStorage.removeItem("accessToken");
28-
set({ isLoggedIn: false, email: "", password: "", socialId: -1 });
31+
localStorage.removeItem("refreshToken");
32+
set({
33+
isLoggedIn: false,
34+
accessToken: null,
35+
email: "",
36+
password: "",
37+
socialId: -1,
38+
});
2939
},
3040

41+
setAccessToken: (token) => set({ accessToken: token }),
3142
setEmail: (email) => set({ email }),
3243
setPassword: (password) => set({ password }),
3344
setSocialId: (socialId) => set({ socialId }),
34-
resetAuth: () => set({ email: "", password: "" }),
45+
resetAuth: () => set({ email: "", password: "", accessToken: null }),
3546
}));
3647

3748
export default useAuthStore;

src/types/common/common.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface ICommonResponse<T> {
1111
data: T;
1212
}
1313

14-
// useCoreQuery 옵션 타입
1514
export type TUseQueryCustomOptions<
1615
TQueryFnData = unknown,
1716
TData = TQueryFnData,
@@ -20,7 +19,6 @@ export type TUseQueryCustomOptions<
2019
"queryKey" | "queryFn"
2120
>;
2221

23-
// useCoreMutation 옵션 타입
2422
export type TUseMutationCustomOptions<
2523
TData = unknown,
2624
TVariables = unknown,

0 commit comments

Comments
 (0)