fix(frontend): ignore expired responses from prior sessions
This commit is contained in:
@@ -3,11 +3,19 @@ import type { paths } from './generated/schema'
|
|||||||
import { boundedFetch } from './transport'
|
import { boundedFetch } from './transport'
|
||||||
|
|
||||||
let expired: () => void = () => {}
|
let expired: () => void = () => {}
|
||||||
|
let authorizationVersion = 0
|
||||||
|
export function advanceAuthorization() {
|
||||||
|
authorizationVersion++
|
||||||
|
}
|
||||||
export function onUnauthorized(handler: () => void) {
|
export function onUnauthorized(handler: () => void) {
|
||||||
expired = handler
|
expired = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = createClient<paths>({
|
export const api = createClient<paths>({
|
||||||
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
|
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
|
||||||
fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()),
|
fetch: boundedFetch(
|
||||||
|
globalThis.fetch.bind(globalThis),
|
||||||
|
() => expired(),
|
||||||
|
() => authorizationVersion,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => {
|
return async (input, init) => {
|
||||||
|
const version = authorizationVersion()
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
try {
|
try {
|
||||||
const response = await fetcher(
|
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)]),
|
signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (response.status === 401) unauthorized()
|
if (response.status === 401 && version === authorizationVersion()) unauthorized()
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request.signal.aborted) throw error
|
if (request.signal.aborted) throw error
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readonly, ref } from 'vue'
|
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 { ApiError, csrfHeaders, unwrap } from '../api/transport'
|
||||||
import type { components } from '../api/generated/schema'
|
import type { components } from '../api/generated/schema'
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ const user = ref<components['schemas']['CurrentUser'] | null>(null)
|
|||||||
let generation = 0
|
let generation = 0
|
||||||
let pending: Promise<boolean> | undefined
|
let pending: Promise<boolean> | undefined
|
||||||
onUnauthorized(() => {
|
onUnauthorized(() => {
|
||||||
|
advanceAuthorization()
|
||||||
generation++
|
generation++
|
||||||
user.value = null
|
user.value = null
|
||||||
})
|
})
|
||||||
@@ -30,6 +31,7 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string) {
|
async function login(username: string, password: string) {
|
||||||
|
advanceAuthorization()
|
||||||
const current = ++generation
|
const current = ++generation
|
||||||
user.value = null
|
user.value = null
|
||||||
const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } }))
|
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() {
|
async function logout() {
|
||||||
|
advanceAuthorization()
|
||||||
const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() })
|
const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() })
|
||||||
if (!response.response.ok && response.response.status !== 401)
|
if (!response.response.ok && response.response.status !== 401)
|
||||||
throw new ApiError(response.response.status)
|
throw new ApiError(response.response.status)
|
||||||
|
|||||||
@@ -193,3 +193,28 @@ test('transport expires identity on401, preserves cancellation and never fills f
|
|||||||
)
|
)
|
||||||
await assert.rejects(offline('http://localhost/api'), /网络中断/)
|
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<Response>((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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user