fix(auth): isolate client cache by session

This commit is contained in:
lingniu
2026-07-16 04:26:20 +08:00
parent b39917d9d6
commit 6b4a0726b2
6 changed files with 131 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, expect, test, vi } from 'vitest';
import { api } from './client';
import { PLATFORM_UNAUTHORIZED_EVENT } from '../v2/auth/session';
afterEach(() => {
vi.restoreAllMocks();
@@ -15,6 +16,23 @@ test('authenticated requests use the session-only bearer token', async () => {
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
});
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
const unauthorized = vi.fn();
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: { message: '访问令牌无效' } })
} as Response);
await expect(api.monitorSummary()).rejects.toThrow('访问令牌无效');
expect(unauthorized).toHaveBeenCalledTimes(1);
await expect(api.session()).rejects.toThrow('访问令牌无效');
expect(unauthorized).toHaveBeenCalledTimes(1);
window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
});
test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response);
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });

View File

@@ -55,7 +55,7 @@ import type {
VehicleServiceSummary,
VehicleRow
} from './types';
import { getAccessToken } from '../v2/auth/session';
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
export type RawFrameQuery = {
keyword?: string;
@@ -90,6 +90,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
const response = await fetch(path, requestInit);
if (!response.ok) {
if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession();
throw new Error(await responseErrorMessage(response));
}
const envelope = (await response.json()) as ApiEnvelope<T>;

View File

@@ -0,0 +1,73 @@
import { QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
import { AuthGate, usePlatformSession } from './AuthGate';
const mocks = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock('../../api/client', () => ({ api: { session: mocks.session } }));
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
const { session, logout } = usePlatformSession();
useQuery({
queryKey: ['protected-vehicle-data'],
queryFn: ({ signal }) => new Promise(() => signal.addEventListener('abort', onAbort, { once: true })),
retry: false
});
const mutation = useMutation({ mutationFn: async () => ({ ok: true }) });
return <div><strong>{session.name}</strong><button type="button" onClick={() => mutation.mutate()}></button><button type="button" onClick={logout}>退</button></div>;
}
afterEach(() => {
cleanup();
mocks.session.mockReset();
window.sessionStorage.clear();
});
test('treats login and logout as complete query and mutation cache boundaries', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' });
client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) });
const aborted = vi.fn();
mocks.session.mockImplementation(async () => {
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
return { name: 'next-user', role: 'viewer', authMode: 'enforce' };
});
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>);
const token = await screen.findByPlaceholderText('Bearer token');
fireEvent.change(token, { target: { value: 'next-user-token' } });
fireEvent.click(screen.getByRole('button', { name: '进入平台' }));
expect(await screen.findByText('next-user')).toBeInTheDocument();
expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined();
expect(client.getMutationCache().getAll()).toHaveLength(0);
await waitFor(() => expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeDefined());
fireEvent.click(screen.getByRole('button', { name: '创建处置缓存' }));
await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '退出测试会话' }));
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1));
expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined();
expect(client.getMutationCache().getAll()).toHaveLength(0);
expect(window.sessionStorage.length).toBe(0);
});
test('an expired protected request returns to login and removes the active user cache', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAccessToken('next-user-token');
mocks.session.mockImplementation(async () => {
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
return { name: 'active-user', role: 'viewer', authMode: 'enforce' };
});
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={() => undefined} /></AuthGate></QueryClientProvider>);
expect(await screen.findByText('active-user')).toBeInTheDocument();
client.setQueryData(['active-user-detail'], { plate: '粤A00002' });
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
expect(getAccessToken()).toBe('');
});

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, FormEvent, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { api } from '../../api/client';
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
type AuthContextValue = {
session: PlatformSession;
@@ -17,6 +17,7 @@ export function usePlatformSession() {
}
export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [tokenVersion, setTokenVersion] = useState(0);
const [draftToken, setDraftToken] = useState('');
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
@@ -26,19 +27,29 @@ export function AuthGate({ children }: { children: ReactNode }) {
retry: false,
staleTime: Infinity
});
const clearClientSession = useCallback(() => {
void queryClient.cancelQueries();
queryClient.clear();
}, [queryClient]);
const logout = useCallback(() => {
clearAccessToken();
clearClientSession();
setDraftToken('');
setAttempted(false);
setTokenVersion((value) => value + 1);
}, [clearClientSession]);
useEffect(() => {
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
}, [logout]);
const login = (event: FormEvent) => {
event.preventDefault();
setAccessToken(draftToken);
clearClientSession();
setAttempted(true);
setTokenVersion((value) => value + 1);
};
const logout = () => {
clearAccessToken();
setDraftToken('');
setAttempted(false);
setTokenVersion((value) => value + 1);
};
if (session.isPending) {
return <div className="v2-auth-screen"><div className="v2-auth-card"><i className="v2-auth-spinner" /><strong>访</strong></div></div>;

View File

@@ -1,4 +1,5 @@
const TOKEN_KEY = 'vehicle-platform.access-token';
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
export type PlatformRole = 'viewer' | 'operator' | 'admin';
@@ -22,6 +23,10 @@ export function clearAccessToken() {
window.sessionStorage.removeItem(TOKEN_KEY);
}
export function notifyUnauthorizedSession() {
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
}
export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin';
}

View File

@@ -2,6 +2,19 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: session-scoped client cache
Logout previously removed only the bearer token. TanStack Query's inactive query results and mutation records could therefore remain in browser memory for their normal lifecycle and be reused after another operator logged in on the same workstation. An expired token also left the current route and its cached vehicle data visible while protected requests repeatedly returned 401.
Authentication is now a hard browser-data boundary. Login, explicit logout and any authenticated API 401 cancel in-flight queries and clear both query and mutation caches before the next session renders. The session-validation endpoint is excluded from the global 401 event so an invalid token remains a local login error, and 403 remains an authorization result rather than terminating a valid session. The API already sends `Cache-Control: no-store`, so protected responses are also excluded from the HTTP cache.
Component tests prove that login and logout remove prior query and mutation entries, logout aborts an in-flight protected read, and a protected 401 returns the application to the login screen without retaining the active result. API tests separately prove that `/api/v2/session` 401 does not recursively trigger global logout. This follows TanStack Query's documented `cancelQueries` and `clear` lifecycle controls and OWASP's requirement to clear client-side state after logout; Auth0 likewise treats local application-session termination as an explicit part of logout:
- <https://tanstack.com/query/v4/docs/reference/QueryClient>
- <https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html>
- <https://cornucopia.owasp.org/cards/FRE3>
- <https://auth0.com/docs/manage-users/sessions/manage-multi-site-sessions>
## 2026-07-16: incremental fleet-map refresh
The 15-second monitor refresh previously treated every new response object as a complete visual change. Even when only `asOf` changed, it regenerated MassMarks styles/data, cleared both label layers and constructed every plate marker again. With hundreds of visible vehicles this caused avoidable canvas work and periodic main-thread pressure.