mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
优化登录,优化阅读
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// 刷新令牌并发保护与失败分类的回归测试。
|
||||
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
} from './authRefresh.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`authRefresh: ${name}`)
|
||||
}
|
||||
|
||||
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
|
||||
const got = await promise
|
||||
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
|
||||
}
|
||||
|
||||
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
|
||||
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
|
||||
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
|
||||
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
|
||||
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
|
||||
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
|
||||
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
|
||||
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
let release: (() => void) | null = null
|
||||
const run = createSingleFlightRunner(async () => {
|
||||
calls += 1
|
||||
const current = calls
|
||||
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
|
||||
if (current === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
const first = run()
|
||||
const second = run()
|
||||
check('single flight shares one promise', first === second)
|
||||
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
|
||||
queueMicrotask(() => release?.())
|
||||
check('single flight result', (await first) === 1)
|
||||
check('single flight stayed single', calls === 1)
|
||||
check('single flight re-runs after settle', (await run()) === 2)
|
||||
}
|
||||
|
||||
// ── 协调器 ────────────────────────────────────────────────────────────────
|
||||
function coordinatorHarness(overrides: {
|
||||
refreshToken?: string | null
|
||||
accessToken?: string | null
|
||||
request?: (token: string) => Promise<RefreshTokens>
|
||||
onApply?: (tokens: RefreshTokens) => void
|
||||
onClear?: () => void
|
||||
syncFromStorage?: () => void
|
||||
}) {
|
||||
const state = {
|
||||
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
|
||||
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
|
||||
applied: [] as RefreshTokens[],
|
||||
cleared: 0,
|
||||
requests: [] as string[],
|
||||
}
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => state.accessToken,
|
||||
readRefreshToken: () => state.refreshToken,
|
||||
applyTokens: (tokens) => {
|
||||
state.applied.push(tokens)
|
||||
state.accessToken = tokens.token
|
||||
state.refreshToken = tokens.refresh_token
|
||||
overrides.onApply?.(tokens)
|
||||
},
|
||||
clearSession: () => {
|
||||
state.cleared += 1
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
overrides.onClear?.()
|
||||
},
|
||||
request: async (token) => {
|
||||
state.requests.push(token)
|
||||
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
|
||||
},
|
||||
runExclusive: (task) => task(),
|
||||
syncFromStorage: () => overrides.syncFromStorage?.(),
|
||||
})
|
||||
return { state, refreshSession }
|
||||
}
|
||||
|
||||
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({})
|
||||
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
|
||||
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
|
||||
check('concurrent refresh sends one request', state.requests.length === 1)
|
||||
check('concurrent refresh applies once', state.applied.length === 1)
|
||||
}
|
||||
|
||||
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('bad gateway', 502)
|
||||
},
|
||||
})
|
||||
await expectOutcome('gateway error', refreshSession(), 'transient')
|
||||
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('network down')
|
||||
},
|
||||
})
|
||||
await expectOutcome('network error', refreshSession(), 'transient')
|
||||
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
|
||||
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('refresh token revoked', 401)
|
||||
},
|
||||
})
|
||||
await expectOutcome('revoked token', refreshSession(), 'invalid')
|
||||
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
|
||||
}
|
||||
|
||||
// 没有 refresh token 时直接判定失效,不发请求。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
|
||||
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
|
||||
check('missing refresh token sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = 'rt-other-tab'
|
||||
state.accessToken = 'at-other-tab'
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
|
||||
check('other tab rotation sends no request', state.requests.length === 0)
|
||||
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
|
||||
}
|
||||
|
||||
// 另一个标签页登出了:本标签页也应判定失效。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = null
|
||||
state.accessToken = null
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
|
||||
check('other tab logout sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
|
||||
{
|
||||
let attempt = 0
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw new RefreshRequestError('network down')
|
||||
return { token: 'at-retry', refresh_token: 'rt-retry' }
|
||||
},
|
||||
})
|
||||
await expectOutcome('first transient attempt', refreshSession(), 'transient')
|
||||
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
|
||||
check('retry used the same token', state.requests[1] === 'rt-1')
|
||||
check('retry applied new tokens', state.refreshToken === 'rt-retry')
|
||||
}
|
||||
|
||||
console.log('authRefresh.test.ts ok')
|
||||
@@ -0,0 +1,148 @@
|
||||
// 刷新令牌的并发保护与失败分类。
|
||||
//
|
||||
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
|
||||
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
|
||||
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
|
||||
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
|
||||
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
|
||||
//
|
||||
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
|
||||
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
|
||||
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
|
||||
export class RefreshRequestError extends Error {
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'RefreshRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
|
||||
*
|
||||
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
|
||||
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
|
||||
* 否则一次瞬时故障就会把用户永久踢到登录页。
|
||||
*/
|
||||
export function refreshFailureOutcome(status?: number): RefreshOutcome {
|
||||
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 400 || status === 401 || status === 403) {
|
||||
return 'invalid'
|
||||
}
|
||||
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
|
||||
return 'transient'
|
||||
}
|
||||
|
||||
/** 从抛出的错误里取出 HTTP 状态码。 */
|
||||
export function httpStatusOf(error: unknown): number | undefined {
|
||||
if (error instanceof RefreshRequestError) return error.status
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
|
||||
*
|
||||
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
|
||||
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
|
||||
*/
|
||||
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
|
||||
let inflight: Promise<T> | null = null
|
||||
return () => {
|
||||
if (inflight) return inflight
|
||||
inflight = Promise.resolve()
|
||||
.then(task)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨标签页互斥执行(Web Locks API)。
|
||||
*
|
||||
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
|
||||
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
|
||||
* 重新读取 localStorage,直接复用刚轮换出来的会话。
|
||||
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
|
||||
*/
|
||||
export function createBrowserExclusiveRunner(
|
||||
lockName: string,
|
||||
): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
return <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
||||
if (!locks || typeof locks.request !== 'function') return task()
|
||||
try {
|
||||
return locks.request(lockName, () => task()) as Promise<T>
|
||||
} catch {
|
||||
return task()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
applyTokens: (tokens: RefreshTokens) => void
|
||||
clearSession: () => void
|
||||
request: (refreshToken: string) => Promise<RefreshTokens>
|
||||
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
|
||||
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
|
||||
syncFromStorage: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
|
||||
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
|
||||
*/
|
||||
export function createRefreshCoordinator(
|
||||
deps: RefreshCoordinatorDeps,
|
||||
): () => Promise<RefreshOutcome> {
|
||||
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
|
||||
const tokenAtEntry = deps.readRefreshToken()
|
||||
// 没有 refresh token 就无从恢复会话。
|
||||
if (!tokenAtEntry) return 'invalid'
|
||||
|
||||
return deps.runExclusive(async () => {
|
||||
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
|
||||
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
|
||||
await deps.syncFromStorage()
|
||||
const current = deps.readRefreshToken()
|
||||
if (!current) return 'invalid'
|
||||
if (current !== tokenAtEntry) {
|
||||
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
|
||||
return deps.readAccessToken() ? 'refreshed' : 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
deps.applyTokens(await deps.request(current))
|
||||
return 'refreshed'
|
||||
} catch (error) {
|
||||
const outcome = refreshFailureOutcome(httpStatusOf(error))
|
||||
if (outcome === 'invalid') deps.clearSession()
|
||||
return outcome
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user