From 80b7ee886850e73b5d42c1ff10d685a4e583a511 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 10 Apr 2026 21:32:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20P1-04=20AuthGuard=20race=20condit?= =?UTF-8?q?ion=20=E2=80=94=20always=20validate=20cookie=20before=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: loadFromStorage() set isAuthenticated=true from localStorage without validating the HttpOnly cookie. On page refresh with expired cookie, children rendered and made failing API calls before AuthGuard could redirect. Fix: - authStore: isAuthenticated starts false, never trusted from localStorage - AuthGuard: always calls GET /auth/me on mount (unless login flow set it) - Three-state guard (checking/authenticated/unauthenticated) eliminates race --- admin-v2/src/router/AuthGuard.tsx | 62 +++++++++++++++++-------------- admin-v2/src/stores/authStore.ts | 8 ++-- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/admin-v2/src/router/AuthGuard.tsx b/admin-v2/src/router/AuthGuard.tsx index 5d2a678..c16aaef 100644 --- a/admin-v2/src/router/AuthGuard.tsx +++ b/admin-v2/src/router/AuthGuard.tsx @@ -3,10 +3,14 @@ // ============================================================ // // Auth strategy: -// 1. If Zustand has isAuthenticated=true (normal flow after login) -> authenticated -// 2. If isAuthenticated=false but account in localStorage -> call GET /auth/me -// to validate HttpOnly cookie and restore session +// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me +// 2. If cookie valid -> restore session and render children // 3. If cookie invalid -> clean up and redirect to /login +// 4. If already authenticated (from login flow) -> render immediately +// +// This eliminates the race condition where localStorage had account data +// but the HttpOnly cookie was expired, causing children to render and +// make failing API calls. import { useEffect, useRef, useState } from 'react' import { Navigate, useLocation } from 'react-router-dom' @@ -14,40 +18,44 @@ import { Spin } from 'antd' import { useAuthStore } from '@/stores/authStore' import { authService } from '@/services/auth' +type GuardState = 'checking' | 'authenticated' | 'unauthenticated' + export function AuthGuard({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) - const account = useAuthStore((s) => s.account) const login = useAuthStore((s) => s.login) const logout = useAuthStore((s) => s.logout) const location = useLocation() - // Track restore attempt to avoid double-calling - const restoreAttempted = useRef(false) - const [restoring, setRestoring] = useState(false) + // Track validation attempt to avoid double-calling (React StrictMode) + const validated = useRef(false) + const [guardState, setGuardState] = useState( + isAuthenticated ? 'authenticated' : 'checking' + ) useEffect(() => { - if (restoreAttempted.current) return - restoreAttempted.current = true - - // If not authenticated but account exists in localStorage, - // try to validate the HttpOnly cookie via /auth/me - if (!isAuthenticated && account) { - setRestoring(true) - authService.me() - .then((meAccount) => { - // Cookie is valid — restore session - login(meAccount) - setRestoring(false) - }) - .catch(() => { - // Cookie expired or invalid — clean up stale data - logout() - setRestoring(false) - }) + // Already authenticated from login flow — skip validation + if (isAuthenticated) { + setGuardState('authenticated') + return } + + // Prevent double-validation in React StrictMode + if (validated.current) return + validated.current = true + + // Validate HttpOnly cookie via /auth/me + authService.me() + .then((meAccount) => { + login(meAccount) + setGuardState('authenticated') + }) + .catch(() => { + logout() + setGuardState('unauthenticated') + }) }, []) // eslint-disable-line react-hooks/exhaustive-deps - if (restoring) { + if (guardState === 'checking') { return (
@@ -55,7 +63,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { ) } - if (!isAuthenticated) { + if (guardState === 'unauthenticated') { return } diff --git a/admin-v2/src/stores/authStore.ts b/admin-v2/src/stores/authStore.ts index 7e01abe..f73ef59 100644 --- a/admin-v2/src/stores/authStore.ts +++ b/admin-v2/src/stores/authStore.ts @@ -37,9 +37,11 @@ function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: bo if (raw) { try { account = JSON.parse(raw) } catch { /* ignore */ } } - // If account exists in localStorage, mark as authenticated (cookie validation - // happens in AuthGuard via GET /auth/me — this is just a UI hint) - return { account, isAuthenticated: account !== null } + // IMPORTANT: Do NOT set isAuthenticated = true from localStorage alone. + // The HttpOnly cookie must be validated via GET /auth/me before we trust + // the session. This prevents the AuthGuard race condition where children + // render and make API calls with an expired cookie. + return { account, isAuthenticated: false } } interface AuthState {