diff --git a/apps/web/src/apis/Auth/server/postReissueToken.ts b/apps/web/src/apis/Auth/server/postReissueToken.ts index 3f5066e7..d7d35038 100644 --- a/apps/web/src/apis/Auth/server/postReissueToken.ts +++ b/apps/web/src/apis/Auth/server/postReissueToken.ts @@ -1,5 +1,6 @@ import useAuthStore from "@/lib/zustand/useAuthStore"; import { publicAxiosInstance } from "@/utils/axiosInstance"; +import { isTokenExpired } from "@/utils/jwtUtils"; /** * @description 토큰 재발급 서버사이드 함수 @@ -13,6 +14,9 @@ const postReissueToken = async (): Promise => { if (!newAccessToken) { throw new Error("재발급된 토큰이 유효하지 않습니다."); } + if (isTokenExpired(newAccessToken)) { + throw new Error("재발급된 토큰이 이미 만료되었습니다."); + } // 재발급 성공 시, 새로운 토큰을 Zustand 스토어에 저장 useAuthStore.getState().setAccessToken(newAccessToken); diff --git a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx index f6356a6a..1dc682a3 100644 --- a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx +++ b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx @@ -6,7 +6,7 @@ import { postReissueToken } from "@/apis/Auth"; import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { UserRole } from "@/types/mentor"; -import { tokenParse } from "@/utils/jwtUtils"; +import { isTokenExpired, tokenParse } from "@/utils/jwtUtils"; // 레이지 로드 컴포넌트 const MenteePage = lazy(() => import("./_ui/MenteePage")); @@ -16,6 +16,7 @@ const MentorClient = () => { const router = useRouter(); const { isLoading, accessToken, isInitialized, refreshStatus, setRefreshStatus } = useAuthStore(); const [isRefreshing, setIsRefreshing] = useState(false); + const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken)); // 어드민 전용: 뷰 전환 상태 (true: 멘토 뷰, false: 멘티 뷰) const [showMentorView, setShowMentorView] = useState(true); @@ -28,8 +29,8 @@ const MentorClient = () => { return; } - // 이미 초기화되었고 토큰이 없는 경우에만 재발급 시도 - if (!isInitialized || accessToken || isRefreshing || refreshStatus === "refreshing") { + // 초기화 이후 유효한 access token이 없을 때만 재발급 시도 + if (!isInitialized || hasValidAccessToken || isRefreshing || refreshStatus === "refreshing") { return; } @@ -49,7 +50,7 @@ const MentorClient = () => { }; attemptTokenRefresh(); - }, [isInitialized, accessToken, isRefreshing, refreshStatus, setRefreshStatus, router]); + }, [isInitialized, hasValidAccessToken, isRefreshing, refreshStatus, setRefreshStatus, router]); // 초기화 전이거나 로딩 중이거나 재발급 중일 때 스피너 표시 if (!isInitialized || isLoading || refreshStatus === "refreshing" || isRefreshing) { @@ -57,7 +58,7 @@ const MentorClient = () => { } // 초기화 완료 후에도 토큰이 없으면 리다이렉트 (useEffect에서 처리되지만 fallback) - if (!accessToken) { + if (!hasValidAccessToken) { return ; } diff --git a/apps/web/src/lib/web-socket/useConnectWebSocket.ts b/apps/web/src/lib/web-socket/useConnectWebSocket.ts index cc831805..c52129aa 100644 --- a/apps/web/src/lib/web-socket/useConnectWebSocket.ts +++ b/apps/web/src/lib/web-socket/useConnectWebSocket.ts @@ -5,6 +5,7 @@ import SockJS from "sockjs-client"; import { normalizeChatMessage, type RawChatMessage } from "@/apis/chat/normalize"; import { type ChatMessage, ConnectionStatus } from "@/types/chat"; +import { isTokenExpired } from "@/utils/jwtUtils"; import useAuthStore from "../zustand/useAuthStore"; interface UseConnectWebSocketProps { @@ -26,6 +27,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U const [submittedMessages, setSubmittedMessages] = useState([]); const accessToken = useAuthStore((state) => state.accessToken); const isInitialized = useAuthStore((state) => state.isInitialized); + const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken)); useEffect(() => { if (!roomId) { @@ -33,7 +35,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U return; } - if (!isInitialized || !accessToken || accessToken.trim() === "") { + if (!isInitialized || !hasValidAccessToken) { setConnectionStatus(ConnectionStatus.Pending); return; } @@ -90,7 +92,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U } clientRef.current = null; }; - }, [roomId, clientRef, accessToken, isInitialized]); + }, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized]); // 관리하는 connectionStatus를 반환 return { connectionStatus, submittedMessages, setSubmittedMessages }; diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts index a3a8c865..17cd5938 100644 --- a/apps/web/src/lib/zustand/useAuthStore.ts +++ b/apps/web/src/lib/zustand/useAuthStore.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { UserRole } from "@/types/mentor"; +import { isTokenExpired } from "@/utils/jwtUtils"; const parseUserRoleFromToken = (token: string | null): UserRole | null => { - if (!token) return null; + if (!token || isTokenExpired(token)) return null; try { const payload = JSON.parse(atob(token.split(".")[1])) as { role?: string }; @@ -87,7 +88,19 @@ const useAuthStore = create()( onRehydrateStorage: () => (state) => { // hydration 완료 후 isInitialized를 true로 설정 if (state) { - state.userRole = parseUserRoleFromToken(state.accessToken); + const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken)); + + if (!hasValidToken) { + state.accessToken = null; + state.userRole = null; + state.isAuthenticated = false; + state.refreshStatus = "idle"; + } else { + state.userRole = parseUserRoleFromToken(state.accessToken); + state.isAuthenticated = true; + state.refreshStatus = "success"; + } + state.isInitialized = true; } }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index f4294a86..5fd9a1cd 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,5 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { isTokenExpired } from "@/utils/jwtUtils"; const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 @@ -13,13 +14,14 @@ export function middleware(request: NextRequest) { // HTTP-only 쿠키의 refreshToken 확인 const refreshToken = request.cookies.get("refreshToken")?.value; + const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken)); // 정확한 경로 매칭 const needLogin = loginNeedPages.some((path) => { return url.pathname === path || url.pathname.startsWith(`${path}/`); }); - if (needLogin && !refreshToken) { + if (needLogin && !hasValidRefreshToken) { url.pathname = "/login"; url.searchParams.delete("reason"); return NextResponse.redirect(url); diff --git a/apps/web/src/utils/axiosInstance.ts b/apps/web/src/utils/axiosInstance.ts index be02b52a..f3a99dc0 100644 --- a/apps/web/src/utils/axiosInstance.ts +++ b/apps/web/src/utils/axiosInstance.ts @@ -3,6 +3,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios"; import { postReissueToken } from "@/apis/Auth/server"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { toast } from "@/lib/zustand/useToastStore"; +import { isTokenExpired } from "@/utils/jwtUtils"; // --- 글로벌 변수 --- let reissuePromise: Promise | null = null; @@ -34,6 +35,34 @@ const redirectToLogin = (message: string) => { export const convertToBearer = (token: string) => `Bearer ${token}`; +const tryReissueAccessToken = async (): Promise => { + if (reissuePromise) { + await reissuePromise; + return useAuthStore.getState().accessToken; + } + + const { setLoading, clearAccessToken, setInitialized, setRefreshStatus } = useAuthStore.getState(); + + reissuePromise = (async () => { + setRefreshStatus("refreshing"); + setLoading(true); + try { + await postReissueToken(); + setRefreshStatus("success"); + } catch { + clearAccessToken(); + setRefreshStatus("failed"); + } finally { + setLoading(false); + setInitialized(true); + reissuePromise = null; + } + })(); + + await reissuePromise; + return useAuthStore.getState().accessToken; +}; + // --- Axios 인스턴스 --- // 인증이 필요 없는 공용 API 요청에 사용 export const publicAxiosInstance: AxiosInstance = axios.create({ @@ -52,40 +81,24 @@ export const axiosInstance: AxiosInstance = axios.create({ // 1. 요청 인터셉터 (Request Interceptor) axiosInstance.interceptors.request.use( async (config) => { - const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } = - useAuthStore.getState(); + const { accessToken, clearAccessToken, refreshStatus } = useAuthStore.getState(); + + // 만료된 access token은 즉시 제거하고 refresh 재발급 경로를 타게 한다. + if (accessToken && isTokenExpired(accessToken)) { + clearAccessToken(); + } + + const validAccessToken = useAuthStore.getState().accessToken; // 토큰이 있으면 헤더에 추가하고 진행 - if (accessToken) { - config.headers.Authorization = convertToBearer(accessToken); + if (validAccessToken) { + config.headers.Authorization = convertToBearer(validAccessToken); return config; } if (refreshStatus !== "failed") { try { - // 이미 reissue가 진행 중인지 확인 - if (reissuePromise) { - await reissuePromise; - } else { - // 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용) - reissuePromise = (async () => { - setRefreshStatus("refreshing"); - setLoading(true); - try { - await postReissueToken(); - setRefreshStatus("success"); - } catch { - clearAccessToken(); - setRefreshStatus("failed"); - } finally { - setLoading(false); - setInitialized(true); - reissuePromise = null; - } - })(); - - await reissuePromise; - } + await tryReissueAccessToken(); // reissue 완료 후 업데이트된 토큰으로 헤더 설정 const updatedAccessToken = useAuthStore.getState().accessToken; @@ -119,12 +132,31 @@ axiosInstance.interceptors.request.use( ); // 2. 응답 인터셉터 (Response Interceptor) -// 역할: 401 에러 시 로그인 페이지로 리다이렉트 +// 역할: 401 에러 시 access 재발급 1회 재시도 후 실패하면 로그인 페이지로 리다이렉트 axiosInstance.interceptors.response.use( (response) => response, - (error: AxiosError) => { - // 401 에러 시 로그인 페이지로 리다이렉트 - if (error.response?.status === 401) { + async (error: AxiosError) => { + const status = error.response?.status; + + if (status === 401) { + const originalRequest = error.config as (typeof error.config & { _retry?: boolean }) | undefined; + + if (originalRequest && !originalRequest._retry && useAuthStore.getState().refreshStatus !== "failed") { + originalRequest._retry = true; + + try { + const reissuedAccessToken = await tryReissueAccessToken(); + + if (reissuedAccessToken) { + originalRequest.headers = originalRequest.headers ?? {}; + originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken); + return axiosInstance(originalRequest); + } + } catch { + // 재발급 실패 시 아래 로그인 리다이렉트로 처리 + } + } + redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요."); } diff --git a/docs/auth-refresh-edge-cases.md b/docs/auth-refresh-edge-cases.md new file mode 100644 index 00000000..a2d0b73c --- /dev/null +++ b/docs/auth-refresh-edge-cases.md @@ -0,0 +1,48 @@ +# Access/Refresh 토큰 엣지케이스 정리 + +이 문서는 웹 앱의 로그인 유지 로직에서 자주 발생하는 토큰 상태별 동작을 정리합니다. +특히 `멘토(/mentor*)`, `커뮤니티(/community*)` 경로에서 발생 빈도가 높은 케이스를 우선 다룹니다. + +## 1. 현재 인증 판단 경계 + +- 서버 진입(Next middleware): `refreshToken` 쿠키 유효성으로 1차 진입 제어 +- 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 + 필요 시 `/auth/reissue` 재발급 +- 멘토 진입 페이지: 렌더 전에 access 유효성 확인 후 필요 시 재발급 +- 채팅 소켓 연결: access 유효성 확인 후 연결 + +## 2. 토큰 상태별 케이스 매트릭스 + +| 케이스 | 토큰 상태 | 주로 발생 화면 | 기대 동작 | 현재 처리 | +| --- | --- | --- | --- | --- | +| A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 즉시 로그인 이동 | middleware에서 로그인 리다이렉트 | +| B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 즉시 로그인 이동 | middleware에서 만료 refresh 차단 | +| C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 글쓰기 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 | +| D | refresh 유효, access 만료 | 멘토 목록/채팅, 커뮤니티 작성/수정 | 만료 access 폐기 -> 재발급 -> 요청 진행 | 만료 access 선제 정리 + 재발급 | +| E | refresh 유효, access 유효하지만 서버에서 401(폐기/불일치) | 멘토 API, 커뮤니티 mutation | 재발급 1회 후 원요청 재시도 | response interceptor에서 1회 retry | +| F | refresh 유효, access 없음 + 동시 다중 요청 | 멘토 페이지 초기 렌더 | 재발급 요청 1회만 수행 | `reissuePromise` 락으로 중복 방지 | +| G | refresh 재발급 실패 | 멘토/커뮤니티 보호 요청 | 무한 재시도 금지 + 로그인 이동 | `refreshStatus=failed`로 차단 후 리다이렉트 | +| H | 멘토 채팅 소켓 연결 시 access 만료 | `/mentor/chat/*` | 만료 토큰으로 연결 시도 금지 | 소켓 훅에서 만료 access 차단 | + +## 3. 멘토/커뮤니티에서 자주 터지는 이유 + +1. 두 경로 모두 보호 페이지로 분류되어 진입 시점의 인증 상태 흔들림이 바로 노출됨 +2. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생 +3. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라, 클릭 시점에 문제 노출 + +## 4. 이번 보완 포인트 + +- middleware에서 `refreshToken`의 단순 존재가 아니라 **만료 여부까지 검사** +- axios request interceptor에서 **만료된 access를 즉시 폐기**하고 재발급 경로로 전환 +- axios response interceptor에서 401 발생 시 **재발급 1회 후 원요청 재시도** +- 멘토 클라이언트 렌더 분기에서 **만료 access를 유효 토큰으로 취급하지 않도록 보정** +- 소켓 연결 훅에서 **만료 access로 연결 시도 금지** + +## 5. 수동 검증 체크리스트 + +1. refresh 없음 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 +2. refresh 만료 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 +3. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 화면 유지 후 정상 렌더 +4. refresh 유효 + access 만료 상태에서 `/mentor` 진입 시 로그인 튕김 없이 복구 +5. refresh 유효 + access 만료 상태에서 커뮤니티 글 작성/댓글 시도 시 재발급 후 정상 요청 +6. access가 서버에서 무효 처리된 상태(401)에서 요청 시 1회 재시도 후 실패 시 로그인 이동 +