From 3e44779578ec88ab95c65e829ca66fc087d610cc Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Fri, 9 Oct 2026 09:55:11 +0800 Subject: [PATCH] fix(frontend): ignore expired responses from prior sessions --- frontend/src/api/client.ts | 10 +++++++++- frontend/src/api/transport.ts | 9 +++++++-- frontend/src/composables/useSession.ts | 5 ++++- frontend/tests/submissionFlow.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1b3d723..3fc064d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,11 +3,19 @@ import type { paths } from './generated/schema' import { boundedFetch } from './transport' let expired: () => void = () => {} +let authorizationVersion = 0 +export function advanceAuthorization() { + authorizationVersion++ +} export function onUnauthorized(handler: () => void) { expired = handler } export const api = createClient({ baseUrl: import.meta.env.VITE_API_BASE_URL || '', - fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()), + fetch: boundedFetch( + globalThis.fetch.bind(globalThis), + () => expired(), + () => authorizationVersion, + ), }) diff --git a/frontend/src/api/transport.ts b/frontend/src/api/transport.ts index 3efffed..004d93a 100644 --- a/frontend/src/api/transport.ts +++ b/frontend/src/api/transport.ts @@ -15,8 +15,13 @@ export class ApiError extends Error { } } -export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): typeof fetch { +export function boundedFetch( + fetcher: typeof fetch, + unauthorized: () => void, + authorizationVersion: () => number = () => 0, +): typeof fetch { return async (input, init) => { + const version = authorizationVersion() const request = new Request(input, init) try { const response = await fetcher( @@ -26,7 +31,7 @@ export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): t signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]), }), ) - if (response.status === 401) unauthorized() + if (response.status === 401 && version === authorizationVersion()) unauthorized() return response } catch (error) { if (request.signal.aborted) throw error diff --git a/frontend/src/composables/useSession.ts b/frontend/src/composables/useSession.ts index 581be13..dda50c8 100644 --- a/frontend/src/composables/useSession.ts +++ b/frontend/src/composables/useSession.ts @@ -1,5 +1,5 @@ import { readonly, ref } from 'vue' -import { api, onUnauthorized } from '../api/client' +import { advanceAuthorization, api, onUnauthorized } from '../api/client' import { ApiError, csrfHeaders, unwrap } from '../api/transport' import type { components } from '../api/generated/schema' @@ -7,6 +7,7 @@ const user = ref(null) let generation = 0 let pending: Promise | undefined onUnauthorized(() => { + advanceAuthorization() generation++ user.value = null }) @@ -30,6 +31,7 @@ async function refresh() { } async function login(username: string, password: string) { + advanceAuthorization() const current = ++generation user.value = null const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } })) @@ -37,6 +39,7 @@ async function login(username: string, password: string) { } async function logout() { + advanceAuthorization() const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() }) if (!response.response.ok && response.response.status !== 401) throw new ApiError(response.response.status) diff --git a/frontend/tests/submissionFlow.test.ts b/frontend/tests/submissionFlow.test.ts index 2512462..24734de 100644 --- a/frontend/tests/submissionFlow.test.ts +++ b/frontend/tests/submissionFlow.test.ts @@ -193,3 +193,28 @@ test('transport expires identity on401, preserves cancellation and never fills f ) await assert.rejects(offline('http://localhost/api'), /网络中断/) }) + +test('an old401 cannot expire a newly authenticated session', async () => { + let version = 0, + expired = 0 + let complete: (response: Response) => void = () => {} + const transport = boundedFetch( + async () => + new Promise((resolve) => { + complete = resolve + }), + () => { + expired++ + }, + () => version, + ) + const old = transport('http://localhost/api/v1/auth/me') + version++ // A later login owns the browser identity. + complete(new Response('{}', { status: 401 })) + await old + assert.equal(expired, 0) + const current = transport('http://localhost/api/v1/auth/me') + complete(new Response('{}', { status: 401 })) + await current + assert.equal(expired, 1) +})