diff --git a/src/App.tsx b/src/App.tsx index ca86023..2101c5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,13 @@ import "./App.css"; import { RouterProvider } from "react-router-dom"; +import { useTokenRefresh } from "@/hooks/auth/useTokenRefresh"; + import { router } from "@/routes/Router"; function App() { + useTokenRefresh(); + return ( <> diff --git a/src/constants/auth.ts b/src/constants/auth.ts deleted file mode 100644 index a42594d..0000000 --- a/src/constants/auth.ts +++ /dev/null @@ -1,2 +0,0 @@ -// 인증번호 입력 시간 상수화 -export const AUTH_TIMER_DURATION = 180; diff --git a/src/hooks/auth/useEmailVerification.ts b/src/hooks/auth/useEmailVerification.ts index ac39224..9355ee1 100644 --- a/src/hooks/auth/useEmailVerification.ts +++ b/src/hooks/auth/useEmailVerification.ts @@ -4,8 +4,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { AUTH_TIMER_DURATION } from "@/constants/auth"; - import { step01Schema } from "@/utils/validation"; import { useAuth } from "@/hooks/auth/useAuth"; @@ -43,14 +41,11 @@ export const useEmailVerification = ({ const watchedEmail = useWatch({ control, name: "email" }); const watchedCode = useWatch({ control, name: "code" }); - const { formattedTime, restart, stop, isExpired } = useTimer( - AUTH_TIMER_DURATION, - { - onExpire: () => { - toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요."); - }, + const { formattedTime, restart, stop, isExpired } = useTimer(0, { + onExpire: () => { + toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요."); }, - ); + }); const handleEditEmail = useCallback(() => { setSendCode(false); diff --git a/src/hooks/auth/useTokenRefresh.ts b/src/hooks/auth/useTokenRefresh.ts new file mode 100644 index 0000000..e046b60 --- /dev/null +++ b/src/hooks/auth/useTokenRefresh.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; + +import { reissueToken } from "@/api/auth/auth"; +import useAuthStore from "@/store/useAuthStore"; + +export const useTokenRefresh = () => { + const { setAccessToken, login, logout } = useAuthStore(); + + useEffect(() => { + const initAuth = async () => { + try { + const { data } = await reissueToken(); + if (data.accessToken) { + // TODO: 재발급 성공 시 로그인 처리 + login("user@example.com", data.accessToken); + } + } catch (error) { + console.log("토큰 재발급 실패:", error); + logout(); + } + }; + + initAuth(); + }, [login, logout, setAccessToken]); +}; diff --git a/src/lib/axiosInstance.ts b/src/lib/axiosInstance.ts index cde2d72..846cd2a 100644 --- a/src/lib/axiosInstance.ts +++ b/src/lib/axiosInstance.ts @@ -2,23 +2,26 @@ import axios, { type AxiosRequestConfig } from "axios"; import useAuthStore from "@/store/useAuthStore"; +const BASE_URL = import.meta.env.VITE_API_BASE_URL; + +if (!BASE_URL) { + throw new Error("API 서버 주소(VITE_API_BASE_URL)가 설정되지 않았습니다."); +} + const axiosConfig: AxiosRequestConfig = { - baseURL: import.meta.env.VITE_API_BASE_URL, + baseURL: BASE_URL, withCredentials: true, headers: { "Content-Type": "application/json", }, }; -// 일반 API 요청용 export const axiosInstance = axios.create(axiosConfig); - -// 토큰 재발급 전용 export const authInstance = axios.create(axiosConfig); axiosInstance.interceptors.request.use( (config) => { - const token = localStorage.getItem("accessToken"); + const token = useAuthStore.getState().accessToken; if (token) { config.headers.Authorization = `Bearer ${token}`; @@ -31,16 +34,28 @@ axiosInstance.interceptors.request.use( ); let isRefreshing = false; -let refreshSubscribers: ((token: string) => void)[] = []; +interface IRefreshSubscriber { + resolve: (token: string) => void; + reject: (error: unknown) => void; +} + +let refreshSubscribers: IRefreshSubscriber[] = []; -// 대기 요청 처리 const onRefreshed = (accessToken: string) => { - refreshSubscribers.forEach((callback) => callback(accessToken)); + refreshSubscribers.forEach(({ resolve }) => resolve(accessToken)); refreshSubscribers = []; }; -const addRefreshSubscriber = (callback: (token: string) => void) => { - refreshSubscribers.push(callback); +const onRefreshFailed = (error: unknown) => { + refreshSubscribers.forEach(({ reject }) => reject(error)); + refreshSubscribers = []; +}; + +const addRefreshSubscriber = ( + resolve: (token: string) => void, + reject: (error: unknown) => void, +) => { + refreshSubscribers.push({ resolve, reject }); }; axiosInstance.interceptors.response.use( @@ -50,28 +65,29 @@ axiosInstance.interceptors.response.use( async (error) => { const originalRequest = error.config; - // 401 에러 감지 if (error.response?.status === 401) { - // 재발급 진행 중: 대기열 등록 if (isRefreshing) { - return new Promise((resolve) => { - addRefreshSubscriber((accessToken: string) => { - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - resolve(axiosInstance(originalRequest)); - }); + return new Promise((resolve, reject) => { + addRefreshSubscriber( + (accessToken: string) => { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + resolve(axiosInstance(originalRequest)); + }, + (refreshError: unknown) => { + reject(refreshError); + }, + ); }); } - // 재발급 실패: 로그아웃 if ( originalRequest.url?.includes("/api/auth/reissue") || originalRequest._retry ) { - localStorage.removeItem("accessToken"); + useAuthStore.getState().logout(); return Promise.reject(error); } - // 첫 401: 재발급 시도 originalRequest._retry = true; isRefreshing = true; @@ -79,21 +95,18 @@ axiosInstance.interceptors.response.use( const { data } = await authInstance.post("/api/auth/reissue"); const newAccessToken = data.data.accessToken; - localStorage.setItem("accessToken", newAccessToken); - // 대기 요청 일괄 처리 + useAuthStore.getState().setAccessToken(newAccessToken); onRefreshed(newAccessToken); - // 현재 요청 재시도 originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return axiosInstance(originalRequest); } catch (refreshError) { - // 재발급 실패: 로그아웃 - console.error("Token reissue failed:", refreshError); + console.error("토큰 재발급 실패:", refreshError); + onRefreshFailed(refreshError); useAuthStore.getState().logout(); return Promise.reject(refreshError); } finally { - // 상태 초기화 isRefreshing = false; refreshSubscribers = []; } diff --git a/src/pages/auth/RedirectPage.tsx b/src/pages/auth/RedirectPage.tsx index be011a8..2c6537a 100644 --- a/src/pages/auth/RedirectPage.tsx +++ b/src/pages/auth/RedirectPage.tsx @@ -19,16 +19,22 @@ export default function RedirectPage() { if (parts.length === 2) return parts.pop()?.split(";").shift(); }; + const deleteCookie = (name: string) => { + document.cookie = + name + "=; Max-Age=0; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;"; + }; + const accessToken = getCookie("access_token"); if (accessToken) { - // TODO: Zustand 로그인 상태 업데이트 - 추후 내 정보 조회 API 연동 시 수정 + // TODO: 로그인 상태 업데이트 login("social@user.com", accessToken); + deleteCookie("access_token"); + toast.success("소셜 로그인되었습니다."); navigate("/", { replace: true }); } else { - console.error("No access token found in cookies."); toast.error("소셜 로그인에 실패했습니다. 다시 시도해주세요."); navigate("/login", { replace: true }); } diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index bb19dff..e1f95d9 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -2,12 +2,14 @@ import { create } from "zustand"; interface IAuthState { isLoggedIn: boolean; + accessToken: string | null; email: string; password: string; socialId: number; login: (email: string, accessToken: string) => void; logout: () => void; + setAccessToken: (token: string) => void; setEmail: (email: string) => void; setPassword: (password: string) => void; setSocialId: (socialId: number) => void; @@ -16,22 +18,31 @@ interface IAuthState { const useAuthStore = create((set) => ({ isLoggedIn: false, + accessToken: null, email: "", password: "", socialId: -1, + login: (email, accessToken) => { - localStorage.setItem("accessToken", accessToken); - set({ isLoggedIn: true, email }); + set({ isLoggedIn: true, email, accessToken }); }, logout: () => { localStorage.removeItem("accessToken"); - set({ isLoggedIn: false, email: "", password: "", socialId: -1 }); + localStorage.removeItem("refreshToken"); + set({ + isLoggedIn: false, + accessToken: null, + email: "", + password: "", + socialId: -1, + }); }, + setAccessToken: (token) => set({ accessToken: token }), setEmail: (email) => set({ email }), setPassword: (password) => set({ password }), setSocialId: (socialId) => set({ socialId }), - resetAuth: () => set({ email: "", password: "" }), + resetAuth: () => set({ email: "", password: "", accessToken: null }), })); export default useAuthStore; diff --git a/src/types/common/common.ts b/src/types/common/common.ts index 8e3643a..1847dfe 100644 --- a/src/types/common/common.ts +++ b/src/types/common/common.ts @@ -11,7 +11,6 @@ export interface ICommonResponse { data: T; } -// useCoreQuery 옵션 타입 export type TUseQueryCustomOptions< TQueryFnData = unknown, TData = TQueryFnData, @@ -20,7 +19,6 @@ export type TUseQueryCustomOptions< "queryKey" | "queryFn" >; -// useCoreMutation 옵션 타입 export type TUseMutationCustomOptions< TData = unknown, TVariables = unknown,