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
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<RouterProvider router={router} />
Expand Down
2 changes: 0 additions & 2 deletions src/constants/auth.ts

This file was deleted.

13 changes: 4 additions & 9 deletions src/hooks/auth/useEmailVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/auth/useTokenRefresh.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
65 changes: 39 additions & 26 deletions src/lib/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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(
Expand All @@ -50,50 +65,48 @@ 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;

try {
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 = [];
}
Expand Down
10 changes: 8 additions & 2 deletions src/pages/auth/RedirectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
19 changes: 15 additions & 4 deletions src/store/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,22 +18,31 @@ interface IAuthState {

const useAuthStore = create<IAuthState>((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;
2 changes: 0 additions & 2 deletions src/types/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export interface ICommonResponse<T> {
data: T;
}

// useCoreQuery 옵션 타입
export type TUseQueryCustomOptions<
TQueryFnData = unknown,
TData = TQueryFnData,
Expand All @@ -20,7 +19,6 @@ export type TUseQueryCustomOptions<
"queryKey" | "queryFn"
>;

// useCoreMutation 옵션 타입
export type TUseMutationCustomOptions<
TData = unknown,
TVariables = unknown,
Expand Down