Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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]);
};
42 changes: 27 additions & 15 deletions src/lib/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,30 @@ 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 @@ -30,29 +37,32 @@ axiosInstance.interceptors.request.use(
},
);

// 토큰 재발급 상태 및 대기열
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

// 대기 요청 처리
// 대기 중인 요청 처리
const onRefreshed = (accessToken: string) => {
refreshSubscribers.forEach((callback) => callback(accessToken));
refreshSubscribers = [];
};

// 대기열에 요청 추가
const addRefreshSubscriber = (callback: (token: string) => void) => {
refreshSubscribers.push(callback);
};

// [응답 인터셉터] 토큰 만료 처리
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;

// 401 에러 감지
// 401 인증 에러 발생
if (error.response?.status === 401) {
// 재발급 진행 중: 대기열 등록
// 이미 재발급 중이면 대기열에 추가
if (isRefreshing) {
return new Promise((resolve) => {
addRefreshSubscriber((accessToken: string) => {
Expand All @@ -62,34 +72,36 @@ axiosInstance.interceptors.response.use(
});
}

// 재발급 실패: 로그아웃
// 재발급 요청 자체가 실패했거나 이미 재시도한 경우 -> 로그아웃
if (
originalRequest.url?.includes("/api/auth/reissue") ||
originalRequest._retry
) {
localStorage.removeItem("accessToken");
useAuthStore.getState().logout();
return Promise.reject(error);
}

// 첫 401: 재발급 시도
// 첫 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);
useAuthStore.getState().logout();
return Promise.reject(refreshError);
} finally {
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