diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 79a3d212..7ab91f0b 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,5 +1,9 @@ /** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ +/* 用户 —— 认证传输与稳定会话身份 */ +export { createUserApis, userApis } from './user' +export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user' + /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' export { projectApis } from './project' diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts new file mode 100644 index 00000000..79623e07 --- /dev/null +++ b/frontend/src/entities/user/api.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { registerApiUnauthorizedRecovery, type ApiClient } from '@/shared/api' + +import { createUserApis } from './api' + +const tokenResponse = { + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'bearer', + expires_in: 900, + user: { + id: 7, + email: 'reader@example.com', + nickname: 'Reader', + email_verified_at: '2026-08-07T01:02:03Z', + status: 37, + last_login_at: '2026-08-07T01:02:03Z', + create_at: '2026-08-01T01:02:03Z', + update_at: '2026-08-07T01:02:03Z', + }, +} + +describe('createUserApis', () => { + let request: ReturnType + let client: ApiClient + + beforeEach(() => { + request = vi.fn() + client = { + request: request as unknown as ApiClient['request'], + requestList: vi.fn() as unknown as ApiClient['requestList'], + } + }) + + it('maps every authentication command to its exact backend path and request body', async () => { + request + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + + const apis = createUserApis({ client }) + + await apis.sendCode({ email: 'reader@example.com', purpose: 'reset_password' }) + await apis.register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: 'Reader', + }) + await apis.login({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + await apis.loginByCode({ email: 'reader@example.com', code: '123456' }) + await apis.refresh('refresh-token') + await apis.logout('refresh-token') + await apis.changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' }) + + expect(request.mock.calls).toEqual([ + [ + '/auth/send-code', + { + method: 'POST', + json: { email: 'reader@example.com', purpose: 'reset_password' }, + }, + ], + [ + '/auth/register', + { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: 'Reader', + }, + }, + ], + [ + '/auth/login', + { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }, + }, + ], + [ + '/auth/login-by-code', + { + method: 'POST', + json: { email: 'reader@example.com', code: '123456' }, + }, + ], + ['/auth/refresh', { method: 'POST', json: { refresh_token: 'refresh-token' } }], + ['/auth/logout', { method: 'POST', json: { refresh_token: 'refresh-token' } }], + [ + '/auth/change-password', + { + method: 'POST', + json: { old_password: 'password-123', new_password: 'new-password-123' }, + }, + ], + ]) + }) + + it('maps token and current-user payloads while preserving an unknown numeric status', async () => { + request.mockResolvedValueOnce(tokenResponse).mockResolvedValueOnce({ + id: 7, + email: 'reader@example.com', + nickname: null, + email_verified_at: null, + status: 37, + }) + + const apis = createUserApis({ client }) + + await expect( + apis.loginByCode({ email: 'reader@example.com', code: '123456' }), + ).resolves.toEqual({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 37, + }, + }) + await expect(apis.me()).resolves.toEqual({ + id: '7', + email: 'reader@example.com', + nickname: null, + emailVerifiedAt: null, + statusCode: 37, + }) + expect(request).toHaveBeenLastCalledWith('/auth/me') + }) + + it.each([ + ['missing id', { ...tokenResponse, user: { ...tokenResponse.user, id: null } }], + ['missing email', { ...tokenResponse, user: { ...tokenResponse.user, email: null } }], + ])('rejects a successful token response with %s', async (_label, response) => { + request.mockResolvedValue(response) + const apis = createUserApis({ client }) + + await expect(apis.refresh('refresh-token')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('omits an empty optional nickname', async () => { + request.mockResolvedValue(tokenResponse) + const apis = createUserApis({ client }) + + await apis.register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: '', + }) + + expect(request).toHaveBeenCalledWith('/auth/register', { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }, + }) + }) + + it('disables global unauthorized recovery for authentication requests', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const apis = createUserApis({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'refresh rejected', data: null }), { + status: 200, + }), + }) + + await expect(apis.refresh('expired-refresh-token')).rejects.toMatchObject({ code: 401 }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) +}) diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts new file mode 100644 index 00000000..47c60af2 --- /dev/null +++ b/frontend/src/entities/user/api.ts @@ -0,0 +1,163 @@ +import type { AuthTokens, User, UserApis } from '.' + +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' + +interface UserDto { + id: number | null + email: string | null + nickname: string | null + email_verified_at: string | null + status: number + [key: string]: unknown +} + +interface AuthTokensDto { + access_token: string + refresh_token: string + user: UserDto + [key: string]: unknown +} + +export interface CreateUserApisOptions extends ApiClientOptions { + client?: ApiClient +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data }) +} + +function toUser(value: unknown): User { + if ( + !isRecord(value) || + !Number.isInteger(value.id) || + (value.id as number) <= 0 || + typeof value.email !== 'string' || + (typeof value.nickname !== 'string' && value.nickname !== null) || + (typeof value.email_verified_at !== 'string' && value.email_verified_at !== null) || + !Number.isInteger(value.status) + ) { + invalidResponse(value) + } + + const dto = value as unknown as UserDto + return { + id: String(dto.id), + email: dto.email as string, + nickname: dto.nickname, + emailVerifiedAt: dto.email_verified_at, + statusCode: dto.status, + } +} + +function toAuthTokens(value: unknown): AuthTokens { + if ( + !isRecord(value) || + typeof value.access_token !== 'string' || + typeof value.refresh_token !== 'string' || + !Object.hasOwn(value, 'user') + ) { + invalidResponse(value) + } + + const dto = value as unknown as AuthTokensDto + return { + accessToken: dto.access_token, + refreshToken: dto.refresh_token, + user: toUser(dto.user), + } +} + +export function createUserApis(options: CreateUserApisOptions = {}): UserApis { + const { client, ...clientOptions } = options + const apiClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + recoverUnauthorized: false, + }) + + return { + async sendCode(input) { + await apiClient.request('/auth/send-code', { method: 'POST', json: input }) + }, + + async register(input) { + const json = { + email: input.email, + password: input.password, + code: input.code, + ...(input.nickname ? { nickname: input.nickname } : {}), + } + return toAuthTokens( + await apiClient.request('/auth/register', { method: 'POST', json }), + ) + }, + + async login(input) { + return toAuthTokens( + await apiClient.request('/auth/login', { method: 'POST', json: input }), + ) + }, + + async loginByCode(input) { + return toAuthTokens( + await apiClient.request('/auth/login-by-code', { + method: 'POST', + json: input, + }), + ) + }, + + async refresh(refreshToken) { + return toAuthTokens( + await apiClient.request('/auth/refresh', { + method: 'POST', + json: { refresh_token: refreshToken }, + }), + ) + }, + + async logout(refreshToken) { + await apiClient.request('/auth/logout', { + method: 'POST', + json: { refresh_token: refreshToken }, + }) + }, + + async me() { + return toUser(await apiClient.request('/auth/me')) + }, + + async changePassword(input) { + await apiClient.request('/auth/change-password', { + method: 'POST', + json: { old_password: input.oldPassword, new_password: input.newPassword }, + }) + }, + } +} + +let defaultApis: UserApis | undefined + +function getDefaultApis(): UserApis { + defaultApis ??= createUserApis() + return defaultApis +} + +/** 延迟创建默认 client,避免仅导入 entities 时要求运行环境已经配置 API 地址。 */ +export const userApis: UserApis = { + sendCode: (input) => getDefaultApis().sendCode(input), + register: (input) => getDefaultApis().register(input), + login: (input) => getDefaultApis().login(input), + loginByCode: (input) => getDefaultApis().loginByCode(input), + refresh: (refreshToken) => getDefaultApis().refresh(refreshToken), + logout: (refreshToken) => getDefaultApis().logout(refreshToken), + me: () => getDefaultApis().me(), + changePassword: (input) => getDefaultApis().changePassword(input), +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 00000000..518f42fb --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,37 @@ +/** 已认证用户在前端领域层的稳定表示。 */ +export interface User { + id: string + email: string + nickname: string | null + emailVerifiedAt: string | null + statusCode: number +} + +/** 一次认证成功后由后端签发的完整会话材料。 */ +export interface AuthTokens { + accessToken: string + refreshToken: string + user: User +} + +export type SendCodePurpose = 'login' | 'register' | 'reset_password' + +/** 用户认证与账户设置的后端接口。 */ +export interface UserApis { + sendCode(input: { email: string; purpose: SendCodePurpose }): Promise + register(input: { + email: string + password: string + code: string + nickname?: string + }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} + +export { createUserApis, userApis } from './api' +export type { CreateUserApisOptions } from './api' diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx new file mode 100644 index 00000000..76d4ddd3 --- /dev/null +++ b/frontend/src/features/auth-session/index.test.tsx @@ -0,0 +1,558 @@ +// @vitest-environment jsdom +import { StrictMode, type ReactNode } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthTokens, UserApis } from '@/entities/user' +import { createApiClient, getApiAccessToken, registerApiUnauthorizedRecovery } from '@/shared/api' +import { AuthSessionProvider, type AuthSessionValue, useAuthSession } from './index' +import { REFRESH_TOKEN_STORAGE_KEY, clearRefreshToken } from './session-storage' + +const user = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 37, +} + +function jwt(exp: number): string { + const payload = btoa(JSON.stringify({ exp })) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + return `header.${payload}.signature` +} + +function tokens(refreshToken = 'refresh-token', accessToken = 'access-token'): AuthTokens { + return { accessToken, refreshToken, user } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function createApis(): UserApis & Record> { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens()), + login: vi.fn(async () => tokens()), + loginByCode: vi.fn(async () => tokens()), + refresh: vi.fn(async () => tokens()), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + } +} + +let currentSession: AuthSessionValue | null = null + +function SessionProbe() { + currentSession = useAuthSession() + return ( + + {currentSession.state.status}: + {currentSession.state.status === 'guest' ? currentSession.state.reason : ''}: + {currentSession.state.status === 'authenticated' ? currentSession.state.user.email : ''} + + ) +} + +function renderSession(apis: UserApis, wrapper?: (children: ReactNode) => ReactNode) { + const content = ( + + + + ) + return render(wrapper ? wrapper(content) : content) +} + +function session(): AuthSessionValue { + if (!currentSession) throw new Error('session is not mounted') + return currentSession +} + +async function expectState(value: string) { + await waitFor(() => + expect(document.querySelector('[data-testid="session"]')?.textContent).toBe(value), + ) +} + +afterEach(() => { + cleanup() + clearRefreshToken() + currentSession = null + vi.useRealTimers() +}) + +beforeEach(() => { + window.localStorage.clear() +}) + +describe('AuthSessionProvider', () => { + it('boots directly to a reasonless guest when there is no refresh token', async () => { + const apis = createApis() + + renderSession(apis) + + await expectState('guest::') + expect(apis.refresh).not.toHaveBeenCalled() + }) + + it('deduplicates StrictMode bootstrap and restores tokens from persisted refresh state', async () => { + const bootstrap = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(bootstrap.promise) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh-token') + + const view = renderSession(apis, (children) => {children}) + + await waitFor(() => expect(apis.refresh).toHaveBeenCalledTimes(1)) + expect(apis.refresh).toHaveBeenCalledWith('stored-refresh-token') + await act(async () => bootstrap.resolve(tokens('rotated-refresh-token', 'restored-access'))) + + await expectState('authenticated::reader@example.com') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh-token') + expect(getApiAccessToken()).toBe('restored-access') + + view.unmount() + expect(getApiAccessToken()).toBeUndefined() + }) + + it.each(['register', 'login', 'loginByCode'] as const)( + '%s applies the complete returned session', + async (method) => { + const apis = createApis() + apis[method].mockResolvedValue(tokens(`${method}-refresh`, `${method}-access`)) + renderSession(apis) + await expectState('guest::') + + await act(async () => { + if (method === 'register') { + await session().register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + } else if (method === 'login') { + await session().login({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + } else { + await session().loginByCode({ email: 'reader@example.com', code: '123456' }) + } + }) + + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe(`${method}-access`) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe(`${method}-refresh`) + }, + ) + + it('clears local state before best-effort logout finishes and never restores it on failure', async () => { + const logout = deferred() + const apis = createApis() + apis.logout.mockReturnValue(logout.promise) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + let logoutPromise!: Promise + act(() => { + logoutPromise = session().logout() + }) + await expectState('guest::') + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + expect(apis.logout).toHaveBeenCalledWith('refresh-token') + + const rejectedLogout = expect(logoutPromise).rejects.toThrow('network unavailable') + await act(async () => logout.reject(new Error('network unavailable'))) + await rejectedLogout + await expectState('guest::') + }) + + it('clears the session with a password-changed reason after changing the password', async () => { + const apis = createApis() + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + await act(async () => + session().changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' }), + ) + + await expectState('guest:password-changed:') + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('deduplicates concurrent 401 recovery and lets both requests replay with the rotated token', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + const authorizations: (string | null)[] = [] + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: getApiAccessToken, + fetchFn: async (input, init) => { + const authorization = new Request(input, init).headers.get('authorization') + authorizations.push(authorization) + const payload = + authorization === 'Bearer renewed-access' + ? { code: 200, message: 'success', data: { ok: true } } + : { code: 401, message: '登录状态已过期', data: null } + return new Response(JSON.stringify(payload), { status: 200 }) + }, + }) + + const first = client.request('/first') + const second = client.request('/second') + await waitFor(() => expect(apis.refresh).toHaveBeenCalledTimes(1)) + await act(async () => refresh.resolve(tokens('renewed-refresh', 'renewed-access'))) + + await expect(Promise.all([first, second])).resolves.toEqual([{ ok: true }, { ok: true }]) + expect(authorizations).toEqual([ + 'Bearer access-token', + 'Bearer access-token', + 'Bearer renewed-access', + 'Bearer renewed-access', + ]) + await expectState('authenticated::reader@example.com') + }) + + it('marks the session expired when reactive recovery cannot refresh', async () => { + const apis = createApis() + apis.refresh.mockRejectedValue(new Error('refresh rejected')) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ code: 401 }) + + await expectState('guest:session-expired:') + }) + + it('keeps the session it just obtained when the newer cross-tab token is rejected', async () => { + const rotation = deferred() + const apis = createApis() + apis.loginByCode.mockResolvedValue(tokens('original-refresh', 'original-access')) + apis.refresh.mockImplementation(async (refreshToken: string) => { + if (refreshToken === 'original-refresh') return rotation.promise + throw new Error(`refresh rejected for ${refreshToken}`) + }) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: getApiAccessToken, + fetchFn: async (input, init) => { + const authorization = new Request(input, init).headers.get('authorization') + const payload = + authorization === 'Bearer rotated-access' + ? { code: 200, message: 'success', data: { ok: true } } + : { code: 401, message: '登录状态已过期', data: null } + return new Response(JSON.stringify(payload), { status: 200 }) + }, + }) + const replayed = client.request('/resources') + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('original-refresh')) + + // 另一个标签页在本次续期途中写入了新的 refresh token,本页已登录,只吸收不重走认证。 + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'revoked-refresh', + oldValue: 'original-refresh', + }), + ) + }) + await act(async () => rotation.resolve(tokens('rotated-refresh', 'rotated-access'))) + + await expect(replayed).resolves.toEqual({ ok: true }) + // 更新的 token 只试一次,且失败不该牵连本次已经换到手的这套。 + expect(apis.refresh.mock.calls.flat()).toEqual(['original-refresh', 'revoked-refresh']) + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe('rotated-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh') + }) + + it('refreshes a JWT sixty seconds before expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(2_000_000_000_000) + const exp = Date.now() / 1_000 + 120 + const apis = createApis() + apis.refresh + .mockResolvedValueOnce(tokens('boot-rotated', jwt(exp))) + .mockResolvedValueOnce(tokens('scheduled-rotated', jwt(exp + 900))) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + + renderSession(apis) + await act(async () => Promise.resolve()) + expect(apis.refresh).toHaveBeenCalledTimes(1) + + await act(async () => vi.advanceTimersByTimeAsync(60_000)) + + expect(apis.refresh).toHaveBeenCalledTimes(2) + expect(apis.refresh).toHaveBeenLastCalledWith('boot-rotated') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('scheduled-rotated') + }) + + it('adopts cross-tab rotation without refreshing an already authenticated tab', async () => { + const apis = createApis() + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-token', + newValue: 'other-tab-refresh', + storageArea: window.localStorage, + }), + ), + ) + + expect(apis.refresh).not.toHaveBeenCalled() + await act(async () => session().logout()) + expect(apis.logout).toHaveBeenCalledWith('other-tab-refresh') + }) + + it('refreshes a cross-tab login for a guest and clears on cross-tab logout without rewriting storage', async () => { + const apis = createApis() + apis.refresh.mockResolvedValue(tokens('rotated-by-this-tab', 'cross-tab-access')) + const removeItem = vi.spyOn(Storage.prototype, 'removeItem') + renderSession(apis) + await expectState('guest::') + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-login') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'other-tab-login', + storageArea: window.localStorage, + }), + ), + ) + await expectState('authenticated::reader@example.com') + expect(apis.refresh).toHaveBeenCalledWith('other-tab-login') + + removeItem.mockClear() + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'rotated-by-this-tab', + newValue: null, + storageArea: window.localStorage, + }), + ), + ) + + await expectState('guest::') + expect(removeItem).not.toHaveBeenCalled() + removeItem.mockRestore() + }) + + it('adopts a newer cross-tab token when an old-token bootstrap loses the rotation race', async () => { + const oldRefresh = deferred() + const apis = createApis() + apis.refresh.mockImplementation((refreshToken: string) => { + if (refreshToken === 'old-refresh') return oldRefresh.promise + return Promise.resolve(tokens('winner-rotated', 'winner-access')) + }) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'old-refresh') + renderSession(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('old-refresh')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'winner-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'old-refresh', + newValue: 'winner-refresh', + storageArea: window.localStorage, + }), + ), + ) + await act(async () => oldRefresh.reject(new Error('old token revoked'))) + + await expectState('authenticated::reader@example.com') + expect(apis.refresh).toHaveBeenCalledWith('winner-refresh') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('winner-rotated') + expect(getApiAccessToken()).toBe('winner-access') + }) + + it('does not rotate a new local login token when an older recovery finishes late', async () => { + const oldRefresh = deferred() + const apis = createApis() + apis.loginByCode + .mockResolvedValueOnce(tokens('old-refresh', 'old-access')) + .mockResolvedValueOnce(tokens('new-login-refresh', 'new-login-access')) + apis.refresh.mockImplementation((refreshToken: string) => { + if (refreshToken === 'old-refresh') return oldRefresh.promise + return Promise.resolve(tokens('unexpected-rotation', 'unexpected-access')) + }) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + const request = client.request('/resources') + const rejectedRequest = expect(request).rejects.toMatchObject({ code: 401 }) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('old-refresh')) + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + await act(async () => oldRefresh.resolve(tokens('old-rotated', 'old-rotated-access'))) + + await rejectedRequest + expect(apis.refresh.mock.calls).toEqual([['old-refresh']]) + expect(getApiAccessToken()).toBe('new-login-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('new-login-refresh') + }) + + it('deduplicates the same token across an overlapping A-B-A refresh sequence', async () => { + const refreshA = deferred() + const refreshB = deferred() + const apis = createApis() + apis.refresh.mockImplementation((refreshToken: string) => + refreshToken === 'refresh-a' ? refreshA.promise : refreshB.promise, + ) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-a') + renderSession(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-a')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-b') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-a', + newValue: 'refresh-b', + storageArea: window.localStorage, + }), + ), + ) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-b')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-a') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-b', + newValue: 'refresh-a', + storageArea: window.localStorage, + }), + ), + ) + + expect(apis.refresh.mock.calls.filter(([value]) => value === 'refresh-a')).toHaveLength(1) + await act(async () => refreshA.resolve(tokens('refresh-a-rotated', 'access-a'))) + await act(async () => refreshB.resolve(tokens('refresh-b-rotated', 'access-b'))) + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe('access-a') + }) + + it('does not write tokens when a pending unauthorized recovery finishes after unmount', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + const view = renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + const request = client.request('/resources') + const rejectedRequest = expect(request).rejects.toMatchObject({ code: 401 }) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-token')) + view.unmount() + await act(async () => refresh.resolve(tokens('late-rotated', 'late-access'))) + + await rejectedRequest + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token') + }) + + it('does not write tokens when a pending storage refresh finishes after unmount', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + const view = renderSession(apis) + await expectState('guest::') + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'other-tab-refresh', + storageArea: window.localStorage, + }), + ), + ) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('other-tab-refresh')) + view.unmount() + await act(async () => refresh.resolve(tokens('late-rotated', 'late-access'))) + + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('other-tab-refresh') + }) + + it('unregisters unauthorized recovery when the provider unmounts', async () => { + const apis = createApis() + const view = renderSession(apis) + await expectState('guest::') + view.unmount() + const fallbackRecovery = vi.fn(async () => false) + const unregister = registerApiUnauthorizedRecovery(fallbackRecovery) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ code: 401 }) + expect(fallbackRecovery).toHaveBeenCalledTimes(1) + expect(apis.refresh).not.toHaveBeenCalled() + unregister() + }) +}) diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx new file mode 100644 index 00000000..b1c1d6f4 --- /dev/null +++ b/frontend/src/features/auth-session/index.tsx @@ -0,0 +1,365 @@ +/* oxlint-disable react/only-export-components -- Provider 与 hook 构成同一个公开会话边界。 */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +import type { AuthTokens, User, UserApis } from '@/entities' +import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api' +import { + REFRESH_TOKEN_STORAGE_KEY, + clearRefreshToken, + loadRefreshToken, + saveRefreshToken, +} from './session-storage' + +export type AuthGuestReason = null | 'session-expired' | 'password-changed' + +export type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null; reason: AuthGuestReason } + | { status: 'authenticated'; user: User } + +export interface AuthSessionValue { + state: AuthSessionState + sendCode(input: Parameters[0]): Promise + register(input: Parameters[0]): Promise + login(input: Parameters[0]): Promise + loginByCode(input: Parameters[0]): Promise + changePassword(input: Parameters[0]): Promise + logout(): Promise +} + +export interface AuthSessionProviderProps { + apis: UserApis + children: ReactNode +} + +const AuthSessionContext = createContext(null) + +export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) { + const initialState: AuthSessionState = { status: 'booting', user: null } + const [state, setState] = useState(initialState) + const [accessTokenVersion, setAccessTokenVersion] = useState(0) + const stateRef = useRef(initialState) + const accessTokenRef = useRef(null) + const refreshTokenRef = useRef(null) + const generationRef = useRef(0) + const mountedRef = useRef(true) + const refreshInFlightRef = useRef(new Map>()) + const recoveryInFlightRef = useRef | null>(null) + const bootstrapPromiseRef = useRef | null>(null) + + const updateState = useCallback((next: AuthSessionState) => { + stateRef.current = next + setState(next) + }, []) + + const storeTokens = useCallback((tokens: AuthTokens) => { + accessTokenRef.current = tokens.accessToken + refreshTokenRef.current = tokens.refreshToken + saveRefreshToken(tokens.refreshToken) + setAccessTokenVersion((version) => version + 1) + }, []) + + const commitRefresh = useCallback( + (tokens: AuthTokens, expectedGeneration: number): boolean => { + if (!mountedRef.current || generationRef.current !== expectedGeneration) return false + storeTokens(tokens) + updateState({ status: 'authenticated', user: tokens.user }) + return true + }, + [storeTokens, updateState], + ) + + const startSession = useCallback( + (tokens: AuthTokens) => { + generationRef.current += 1 + recoveryInFlightRef.current = null + storeTokens(tokens) + updateState({ status: 'authenticated', user: tokens.user }) + }, + [storeTokens, updateState], + ) + + const clearSession = useCallback( + (reason: AuthGuestReason, persist = true) => { + generationRef.current += 1 + recoveryInFlightRef.current = null + accessTokenRef.current = null + refreshTokenRef.current = null + if (persist) clearRefreshToken() + setAccessTokenVersion((version) => version + 1) + updateState({ status: 'guest', user: null, reason }) + }, + [updateState], + ) + + const rotateTokens = useCallback( + (refreshToken: string): Promise => { + const inFlight = refreshInFlightRef.current.get(refreshToken) + if (inFlight) return inFlight + + const promise = apis.refresh(refreshToken) + refreshInFlightRef.current.set(refreshToken, promise) + const release = () => { + if (refreshInFlightRef.current.get(refreshToken) === promise) + refreshInFlightRef.current.delete(refreshToken) + } + void promise.then(release, release) + return promise + }, + [apis], + ) + + /** 若旧 token 输掉跨标签轮换竞态,优先跟随胜出的 token,不能清掉新会话。 */ + const rotateLatestTokens = useCallback( + async (attemptedToken: string, expectedGeneration: number): Promise => { + /** 记下本次已经换过的 token,避免把刚失败的那个当成没试过的又试一遍。 */ + const attempted = new Set([attemptedToken]) + const isCurrent = () => mountedRef.current && generationRef.current === expectedGeneration + const takeUntriedToken = (): string | null => { + const memoryToken = refreshTokenRef.current + if (memoryToken && !attempted.has(memoryToken)) return memoryToken + const storedToken = loadRefreshToken() + if (storedToken && !attempted.has(storedToken)) return storedToken + return null + } + + try { + const tokens = await rotateTokens(attemptedToken) + if (!isCurrent()) return null + const newerToken = takeUntriedToken() + if (!newerToken) return tokens + attempted.add(newerToken) + try { + const newerTokens = await rotateTokens(newerToken) + return isCurrent() ? newerTokens : null + } catch { + // 更新的 token 用不了,不代表本次换到手的这套也用不了,退回它而不是一起丢掉。 + return isCurrent() ? tokens : null + } + } catch (error) { + if (!isCurrent()) return null + const newerToken = takeUntriedToken() + if (!newerToken) throw error + attempted.add(newerToken) + refreshTokenRef.current = newerToken + const newerTokens = await rotateTokens(newerToken) + return isCurrent() ? newerTokens : null + } + }, + [rotateTokens], + ) + + const recoverUnauthorized = useCallback((): Promise => { + if (recoveryInFlightRef.current) return recoveryInFlightRef.current + const refreshToken = refreshTokenRef.current + if (!refreshToken) return Promise.resolve(false) + const generation = generationRef.current + + const promise = rotateLatestTokens(refreshToken, generation).then( + (tokens) => (tokens ? commitRefresh(tokens, generation) : false), + () => { + if (mountedRef.current && generationRef.current === generation) + clearSession('session-expired') + return false + }, + ) + recoveryInFlightRef.current = promise + const release = () => { + if (recoveryInFlightRef.current === promise) recoveryInFlightRef.current = null + } + void promise.then(release, release) + return promise + }, [clearSession, commitRefresh, rotateLatestTokens]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), []) + useEffect(() => registerApiUnauthorizedRecovery(recoverUnauthorized), [recoverUnauthorized]) + + useEffect(() => { + let active = true + const generation = generationRef.current + + if (!bootstrapPromiseRef.current) { + const refreshToken = loadRefreshToken() + refreshTokenRef.current = refreshToken + bootstrapPromiseRef.current = refreshToken + ? rotateLatestTokens(refreshToken, generation) + : Promise.resolve(null) + } + + void bootstrapPromiseRef.current.then( + (tokens) => { + if (!active || generationRef.current !== generation) return + if (tokens) commitRefresh(tokens, generation) + else clearSession(null) + }, + () => { + if (active && generationRef.current === generation) clearSession('session-expired') + }, + ) + + return () => { + active = false + } + }, [clearSession, commitRefresh, rotateLatestTokens]) + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== REFRESH_TOKEN_STORAGE_KEY) return + if (event.newValue === null) { + clearSession(null, false) + return + } + if (event.newValue === refreshTokenRef.current) return + + refreshTokenRef.current = event.newValue + if (stateRef.current.status === 'authenticated') return + + const generation = ++generationRef.current + recoveryInFlightRef.current = null + accessTokenRef.current = null + updateState({ status: 'booting', user: null }) + void rotateLatestTokens(event.newValue, generation).then( + (tokens) => { + if (tokens) commitRefresh(tokens, generation) + }, + () => { + if (mountedRef.current && generationRef.current === generation) + clearSession('session-expired') + }, + ) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, [clearSession, commitRefresh, rotateLatestTokens, updateState]) + + useEffect(() => { + const refreshAt = getRefreshTime(accessTokenRef.current) + if (refreshAt === null) return + let cancelled = false + let timer: ReturnType | undefined + + const schedule = () => { + const delay = Math.max(0, refreshAt - Date.now()) + timer = setTimeout( + () => { + if (cancelled) return + if (Date.now() < refreshAt) { + schedule() + return + } + const refreshToken = refreshTokenRef.current + if (!refreshToken) return + const generation = generationRef.current + void rotateLatestTokens(refreshToken, generation).then( + (tokens) => { + if (!cancelled && tokens) commitRefresh(tokens, generation) + }, + () => { + if (!cancelled && generationRef.current === generation) + clearSession('session-expired') + }, + ) + }, + Math.min(delay, 2_147_483_647), + ) + } + + schedule() + return () => { + cancelled = true + if (timer !== undefined) clearTimeout(timer) + } + }, [accessTokenVersion, clearSession, commitRefresh, rotateLatestTokens]) + + const sendCode = useCallback( + (input: Parameters[0]) => apis.sendCode(input), + [apis], + ) + const register = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.register(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const login = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.login(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const loginByCode = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.loginByCode(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const changePassword = useCallback( + async (input: Parameters[0]) => { + await apis.changePassword(input) + clearSession('password-changed') + }, + [apis, clearSession], + ) + const logout = useCallback(async () => { + const refreshToken = refreshTokenRef.current + clearSession(null) + if (refreshToken) await apis.logout(refreshToken) + }, [apis, clearSession]) + + const value = useMemo( + () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }), + [changePassword, login, loginByCode, logout, register, sendCode, state], + ) + + return {children} +} + +export function useAuthSession(): AuthSessionValue { + const session = useContext(AuthSessionContext) + if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用') + return session +} + +function getRefreshTime(accessToken: string | null): number | null { + const payload = accessToken?.split('.')[1] + if (!payload) return null + try { + const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const value: unknown = JSON.parse(globalThis.atob(padded)) + if ( + typeof value !== 'object' || + value === null || + !('exp' in value) || + typeof value.exp !== 'number' || + !Number.isFinite(value.exp) + ) { + return null + } + return value.exp * 1_000 - 60_000 + } catch { + return null + } +} diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts new file mode 100644 index 00000000..8edf5824 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' + +import { REFRESH_TOKEN_STORAGE_KEY, createRefreshTokenStorage } from './session-storage' + +describe('refresh token storage', () => { + it('persists only the refresh token under the contracted key', () => { + const values = new Map() + const storage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), + removeItem: vi.fn((key: string) => values.delete(key)), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('refresh-token') + + expect(storage.setItem).toHaveBeenCalledWith('windup.auth.refresh-token', 'refresh-token') + expect(tokens.load()).toBe('refresh-token') + expect(REFRESH_TOKEN_STORAGE_KEY).toBe('windup.auth.refresh-token') + + tokens.clear() + expect(storage.removeItem).toHaveBeenCalledWith('windup.auth.refresh-token') + expect(tokens.load()).toBeNull() + }) + + it('retains the current-tab refresh token in memory when storage throws', () => { + const failure = new DOMException('Storage is disabled', 'SecurityError') + const storage = { + getItem: vi.fn(() => { + throw failure + }), + setItem: vi.fn(() => { + throw failure + }), + removeItem: vi.fn(() => { + throw failure + }), + } + const tokens = createRefreshTokenStorage(storage) + + expect(() => tokens.save('memory-refresh-token')).not.toThrow() + expect(tokens.load()).toBe('memory-refresh-token') + expect(() => tokens.clear()).not.toThrow() + expect(tokens.load()).toBeNull() + }) + + it('uses the memory value after a previously working storage becomes unavailable', () => { + let available = true + const values = new Map() + const storage = { + getItem: (key: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + return values.get(key) ?? null + }, + setItem: (key: string, value: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + values.set(key, value) + }, + removeItem: (key: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + values.delete(key) + }, + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('refresh-token') + available = false + + expect(tokens.load()).toBe('refresh-token') + }) + + it('does not let a readable stale value replace a token whose persistence failed', () => { + const storage = { + getItem: vi.fn(() => 'stale-refresh-token'), + setItem: vi.fn(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }), + removeItem: vi.fn(), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('memory-refresh-token') + + expect(tokens.load()).toBe('memory-refresh-token') + }) + + it('keeps an in-memory tombstone when persistent removal fails', () => { + const storage = { + getItem: vi.fn(() => 'stale-refresh-token'), + setItem: vi.fn(), + removeItem: vi.fn(() => { + throw new DOMException('Storage is disabled', 'SecurityError') + }), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.clear() + + expect(tokens.load()).toBeNull() + }) +}) diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts new file mode 100644 index 00000000..a79851a8 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.ts @@ -0,0 +1,71 @@ +export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' + +type RefreshTokenStorage = Pick + +export interface RefreshTokenStore { + load(): string | null + save(refreshToken: string): void + clear(): void +} + +function getLocalStorage(): RefreshTokenStorage | null { + try { + return globalThis.localStorage + } catch { + return null + } +} + +/** + * localStorage 是跨刷新、跨标签的增强能力,不是维持当前页面登录的前提。 + * 浏览器拒绝存储访问时,闭包中的副本继续支撑本标签页会话。 + */ +export function createRefreshTokenStorage(storage?: RefreshTokenStorage | null): RefreshTokenStore { + let memoryValue: string | null = null + let memoryOnly = false + const resolveStorage = () => (storage === undefined ? getLocalStorage() : storage) + + return { + load() { + if (memoryOnly) return memoryValue + const target = resolveStorage() + if (!target) return memoryValue + try { + memoryValue = target.getItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + memoryOnly = true + } + return memoryValue + }, + save(refreshToken) { + memoryValue = refreshToken + try { + resolveStorage()?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken) + } catch { + memoryOnly = true + } + }, + clear() { + memoryValue = null + try { + resolveStorage()?.removeItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + memoryOnly = true + } + }, + } +} + +const defaultRefreshTokenStorage = createRefreshTokenStorage() + +export function loadRefreshToken(): string | null { + return defaultRefreshTokenStorage.load() +} + +export function saveRefreshToken(refreshToken: string): void { + defaultRefreshTokenStorage.save(refreshToken) +} + +export function clearRefreshToken(): void { + defaultRefreshTokenStorage.clear() +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ff3b9104..57f7649c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,10 +2,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app' +import { userApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts index 2c2dfe4d..c210dd02 100644 --- a/frontend/src/shared/api/index.test.ts +++ b/frontend/src/shared/api/index.test.ts @@ -5,6 +5,7 @@ import { createApiClient, getApiAccessToken, registerApiAccessTokenProvider, + registerApiUnauthorizedRecovery, } from './index' afterEach(() => vi.unstubAllEnvs()) @@ -127,6 +128,128 @@ describe('createApiClient', () => { expect(authorization).toBe('Bearer access-token') }) + it('recovers a valid HTTP 200 business 401 and replays once with the new token', async () => { + let accessToken = 'expired-token' + const authorizations: (string | null)[] = [] + const unregister = registerApiUnauthorizedRecovery(async () => { + accessToken = 'renewed-token' + return true + }) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: () => accessToken, + fetchFn: async (input, init) => { + authorizations.push(new Request(input, init).headers.get('authorization')) + const payload = + authorizations.length === 1 + ? { code: 401, message: '登录状态已过期', data: null } + : { code: 200, message: 'success', data: { id: 7 } } + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + await expect(client.request('/resources/7')).resolves.toEqual({ id: 7 }) + expect(authorizations).toEqual(['Bearer expired-token', 'Bearer renewed-token']) + unregister() + }) + + it('never recovers or replays the same request twice', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ code: 401, message: '登录状态已过期', data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + const client = createApiClient({ baseUrl: 'https://api.windup.test', fetchFn }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'business', + code: 401, + }) + expect(recover).toHaveBeenCalledTimes(1) + expect(fetchFn).toHaveBeenCalledTimes(2) + unregister() + }) + + it('keeps the original 401 when recovery fails and lets clients opt out', async () => { + const recover = vi.fn(async () => { + throw new Error('refresh failed') + }) + const unregister = registerApiUnauthorizedRecovery(recover) + const response = () => + new Response(JSON.stringify({ code: 401, message: '原始登录错误', data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + const recoveringClient = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => response(), + }) + const authClient = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => response(), + recoverUnauthorized: false, + }) + + await expect(recoveringClient.request('/resources')).rejects.toMatchObject({ + code: 401, + message: '原始登录错误', + }) + await expect(authClient.request('/auth/refresh')).rejects.toMatchObject({ + code: 401, + message: '原始登录错误', + }) + expect(recover).toHaveBeenCalledTimes(1) + unregister() + }) + + it('does not recover a business 401 carried by a failed HTTP response', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'unauthorized', data: null }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'http', + status: 401, + }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) + + it('does not recover a business 401 carried by a non-200 successful HTTP response', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'unauthorized', data: null }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'business', + code: 401, + status: 201, + }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) + it('wraps a rejected fetch as a network ApiError', async () => { const connectionError = new TypeError('Failed to fetch') const client = createApiClient({ diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 06f8ad8c..9ef07dda 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -25,6 +25,8 @@ export interface ApiClientOptions { fetchFn?: typeof fetch /** 只在请求发出时读取;token 的取得、保存与刷新由调用方负责。 */ getAccessToken?: () => string | null | undefined + /** 认证端点关闭此项,避免 refresh 自身的 401 递归进入恢复流程。 */ + recoverUnauthorized?: boolean } export type ApiAccessTokenProvider = NonNullable @@ -48,6 +50,23 @@ export function getApiAccessToken(): string | null | undefined { return accessTokenProviders.at(-1)?.() } +export type ApiUnauthorizedRecovery = () => Promise + +const unauthorizedRecoveryProviders: ApiUnauthorizedRecovery[] = [] + +/** 注册会话层提供的 401 恢复函数;shared/api 不持有任何认证业务状态。 */ +export function registerApiUnauthorizedRecovery(recovery: ApiUnauthorizedRecovery): () => void { + unauthorizedRecoveryProviders.push(recovery) + return () => { + const index = unauthorizedRecoveryProviders.lastIndexOf(recovery) + if (index >= 0) unauthorizedRecoveryProviders.splice(index, 1) + } +} + +function getApiUnauthorizedRecovery(): ApiUnauthorizedRecovery | undefined { + return unauthorizedRecoveryProviders.at(-1) +} + export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' /** 后端业务错误与传输错误统一进入这一种前端错误。 */ @@ -181,6 +200,7 @@ export function createApiClient({ baseUrl, fetchFn = globalThis.fetch, getAccessToken, + recoverUnauthorized = true, }: ApiClientOptions): ApiClient { const normalizedBaseUrl = resolveApiBaseUrl(baseUrl) @@ -194,10 +214,43 @@ export function createApiClient({ } } + function canReplay(options: ApiRequestOptions | undefined): boolean { + const body = options?.body + return !(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + } + + async function receiveEnvelope( + path: string, + options: ApiRequestOptions | undefined, + replayed = false, + ): Promise<{ response: Response; envelope: ApiEnvelope }> { + const response = await send(path, options) + const envelope = await readEnvelope(response) + + const recovery = getApiUnauthorizedRecovery() + if ( + !replayed && + recoverUnauthorized && + response.status === 200 && + envelope.code === 401 && + recovery && + canReplay(options) + ) { + let recovered = false + try { + recovered = await recovery() + } catch { + // 恢复失败仍应向调用方交付原始 401,而不是泄漏 refresh 的错误。 + } + if (recovered) return receiveEnvelope(path, options, true) + } + + return { response, envelope } + } + return { async request(path: string, options?: ApiRequestOptions) { - const response = await send(path, options) - const envelope = await readEnvelope(response) + const { response, envelope } = await receiveEnvelope(path, options) assertSuccessfulEnvelope(response, envelope) return envelope.data as T @@ -207,8 +260,7 @@ export function createApiClient({ * 分页字段与 data 同级,不在 data 内再嵌套分页对象。 */ async requestList(path: string, options?: ApiRequestOptions) { - const response = await send(path, options) - const envelope = await readEnvelope(response) + const { response, envelope } = await receiveEnvelope(path, options) assertSuccessfulEnvelope(response, envelope) if (