diff --git a/Makefile b/Makefile index c878526f96..a2a49abb2e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ .PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical FRONTEND_CRITICAL_VITEST := \ + src/api/__tests__/client.spec.ts \ + src/api/__tests__/tokenRefresh.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ src/views/auth/__tests__/WechatCallbackView.spec.ts \ src/views/user/__tests__/PaymentView.spec.ts \ diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index d1b619027e..b095c7f375 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -344,6 +344,94 @@ describe('API Client', () => { writable: true, }) }) + + it('有 refresh_token 时刷新并重试原请求', async () => { + localStorage.setItem('auth_token', 'expired-token') + localStorage.setItem('refresh_token', 'refresh-token') + localStorage.setItem('token_expires_at', String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + + const adapter = vi.fn() + .mockRejectedValueOnce({ + response: { + status: 401, + data: { code: 'TOKEN_EXPIRED', message: 'Token expired' }, + }, + config: { + url: '/test', + headers: { Authorization: 'Bearer expired-token' }, + }, + code: 'ERR_BAD_REQUEST', + }) + .mockResolvedValueOnce({ + status: 200, + data: { code: 0, data: { ok: true } }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + code: 0, + message: 'ok', + data: { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + }, + }, + }) + + await expect(apiClient.get('/test')).resolves.toMatchObject({ data: { ok: true } }) + + expect(adapter).toHaveBeenCalledTimes(2) + expect(localStorage.getItem('auth_token')).toBe('new-token') + expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token') + expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token') + }) + + it('刷新期间换号时旧请求不会清除新会话', async () => { + localStorage.setItem('auth_token', 'user-a-access') + localStorage.setItem('refresh_token', 'user-a-refresh') + localStorage.setItem('token_expires_at', String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + + apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({ + response: { + status: 401, + data: { code: 'TOKEN_EXPIRED', message: 'Token expired' }, + }, + config: { + url: '/test', + headers: { Authorization: 'Bearer user-a-access' }, + }, + code: 'ERR_BAD_REQUEST', + }) + + let rejectRefresh!: (reason: Error) => void + vi.spyOn(axios, 'post').mockImplementationOnce( + () => new Promise((_resolve, reject) => { + rejectRefresh = reject + }) + ) + + const staleRequest = apiClient.get('/test') + await vi.waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)) + + localStorage.setItem('auth_token', 'user-b-access') + localStorage.setItem('refresh_token', 'user-b-refresh') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) + rejectRefresh(new Error('stale refresh failed')) + + await expect(staleRequest).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' }) + expect(localStorage.getItem('auth_token')).toBe('user-b-access') + expect(localStorage.getItem('refresh_token')).toBe('user-b-refresh') + expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 8 })) + expect(window.location.pathname).toBe('/') + }) }) // --- 网络错误 --- diff --git a/frontend/src/api/__tests__/tokenRefresh.spec.ts b/frontend/src/api/__tests__/tokenRefresh.spec.ts new file mode 100644 index 0000000000..a7fee8c643 --- /dev/null +++ b/frontend/src/api/__tests__/tokenRefresh.spec.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import axios from 'axios' + +vi.mock('axios', () => ({ + default: { + post: vi.fn() + } +})) + +const mockedPost = vi.mocked(axios.post) + +function seedSession(overrides: Partial> = {}): void { + localStorage.setItem('auth_token', overrides.auth_token || 'old-access') + localStorage.setItem('refresh_token', overrides.refresh_token || 'old-refresh') + localStorage.setItem('token_expires_at', overrides.token_expires_at || String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7, email: 'admin@example.com' })) +} + +function refreshedResponse() { + return { + data: { + code: 0, + message: 'ok', + data: { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + token_type: 'Bearer' + } + } + } +} + +describe('refreshAuthTokens', () => { + beforeEach(() => { + localStorage.clear() + mockedPost.mockReset() + vi.resetModules() + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: undefined + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shares one refresh request between concurrent callers in the same document', async () => { + seedSession() + let resolveRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const first = refreshAuthTokens({ failedAccessToken: 'old-access' }) + const second = refreshAuthTokens({ failedAccessToken: 'old-access' }) + + expect(mockedPost).toHaveBeenCalledTimes(1) + resolveRequest(refreshedResponse()) + + await expect(first).resolves.toMatchObject({ access_token: 'new-access' }) + await expect(second).resolves.toMatchObject({ refresh_token: 'new-refresh' }) + expect(localStorage.getItem('refresh_token')).toBe('new-refresh') + }) + + it('adopts tokens refreshed by another tab after acquiring the Web Lock', async () => { + seedSession() + const request = vi.fn(async (_name: string, callback: () => Promise) => { + localStorage.setItem('auth_token', 'peer-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'peer-refresh') + return callback() + }) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request } + }) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const result = await refreshAuthTokens({ failedAccessToken: 'old-access' }) + + expect(request).toHaveBeenCalledTimes(1) + expect(mockedPost).not.toHaveBeenCalled() + expect(result).toMatchObject({ + access_token: 'peer-access', + refresh_token: 'peer-refresh' + }) + }) + + it('recovers when a peer publishes the rotated token just after this request fails', async () => { + seedSession() + mockedPost.mockRejectedValueOnce(new Error('refresh token already used')) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + window.setTimeout(() => { + localStorage.setItem('auth_token', 'peer-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'peer-refresh') + }, 10) + + await expect( + refreshAuthTokens({ failedAccessToken: 'old-access' }) + ).resolves.toMatchObject({ + access_token: 'peer-access', + refresh_token: 'peer-refresh' + }) + }) + + it('waits for a slow peer after losing a refresh-token race without Web Locks', async () => { + vi.useFakeTimers() + seedSession() + let resolveWinningRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveWinningRequest = resolve + }) + ) + const firstTab = await import('@/api/tokenRefresh') + vi.resetModules() + const secondTab = await import('@/api/tokenRefresh') + + const winner = firstTab.refreshAuthTokens({ failedAccessToken: 'old-access' }) + mockedPost.mockRejectedValueOnce({ response: { status: 401 } }) + const loser = secondTab.refreshAuthTokens({ failedAccessToken: 'old-access' }) + + window.setTimeout(() => resolveWinningRequest(refreshedResponse()), 1_500) + await vi.advanceTimersByTimeAsync(1_600) + + await expect(winner).resolves.toMatchObject({ access_token: 'new-access' }) + await expect(loser).resolves.toMatchObject({ refresh_token: 'new-refresh' }) + expect(mockedPost).toHaveBeenCalledTimes(2) + expect(localStorage.getItem('refresh_token')).toBe('new-refresh') + }) + + it('does not adopt a token from a different signed-in user', async () => { + vi.useFakeTimers() + seedSession() + mockedPost.mockRejectedValueOnce(new Error('refresh token already used')) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + window.setTimeout(() => { + localStorage.setItem('auth_user', JSON.stringify({ id: 8, email: 'other@example.com' })) + localStorage.setItem('auth_token', 'other-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'other-refresh') + }, 10) + + const rejection = expect( + refreshAuthTokens({ failedAccessToken: 'old-access' }) + ).rejects.toThrow('refresh token already used') + await vi.advanceTimersByTimeAsync(1_100) + await rejection + }) + + it('does not restore a session that was logged out while refresh was in flight', async () => { + vi.useFakeTimers() + seedSession() + let resolveRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const pending = refreshAuthTokens({ failedAccessToken: 'old-access' }) + localStorage.clear() + resolveRequest(refreshedResponse()) + + const rejection = expect(pending).rejects.toThrow('Session changed during token refresh') + await vi.advanceTimersByTimeAsync(1_100) + await rejection + expect(localStorage.getItem('auth_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) +}) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fd25923067..8b0a74cb43 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -4,6 +4,8 @@ */ import { apiClient } from './client' +import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh' +export type { RefreshTokenResponse } from './tokenRefresh' import type { LoginRequest, RegisterRequest, @@ -179,13 +181,6 @@ export async function logout(): Promise { /** * Refresh token response */ -export interface RefreshTokenResponse { - access_token: string - refresh_token: string - expires_in: number - token_type: string -} - export interface OAuthTokenResponse { access_token: string refresh_token?: string @@ -293,21 +288,7 @@ export async function prepareOAuthBindAccessTokenCookie(): Promise { * @returns New token pair */ export async function refreshToken(): Promise { - const currentRefreshToken = getRefreshToken() - if (!currentRefreshToken) { - throw new Error('No refresh token available') - } - - const { data } = await apiClient.post('/auth/refresh', { - refresh_token: currentRefreshToken - }) - - // Update tokens in localStorage - setAuthToken(data.access_token) - setRefreshToken(data.refresh_token) - setTokenExpiresAt(data.expires_in) - - return data + return refreshAuthTokens() } /** diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8375cf8763..1f4e4d6134 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,7 @@ import { shouldMarkAdminUIRequest, shouldMarkUserUIRequest, } from './adminUIRequest' +import { refreshAuthTokens } from './tokenRefresh' import { getAPIBaseURL } from './url' export { buildApiUrl, buildGatewayUrl } from './url' @@ -26,28 +27,6 @@ export const apiClient: AxiosInstance = axios.create({ } }) -// ==================== Token Refresh State ==================== - -// Track if a token refresh is in progress to prevent multiple simultaneous refresh requests -let isRefreshing = false -// Queue of requests waiting for token refresh -let refreshSubscribers: Array<(token: string) => void> = [] - -/** - * Subscribe to token refresh completion - */ -function subscribeTokenRefresh(callback: (token: string) => void): void { - refreshSubscribers.push(callback) -} - -/** - * Notify all subscribers that token has been refreshed - */ -function onTokenRefreshed(token: string): void { - refreshSubscribers.forEach((callback) => callback(token)) - refreshSubscribers = [] -} - // ==================== Request Interceptor ==================== // Get user's timezone @@ -190,74 +169,36 @@ apiClient.interceptors.response.use( // If we have a refresh token and this is not an auth endpoint, try to refresh if (refreshToken && !isAuthEndpoint) { - if (isRefreshing) { - // Wait for the ongoing refresh to complete - return new Promise((resolve, reject) => { - subscribeTokenRefresh((newToken: string) => { - if (newToken) { - // Mark as retried to prevent infinite loop if retry also returns 401 - originalRequest._retry = true - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${newToken}` - } - resolve(apiClient(originalRequest)) - } else { - // Refresh failed, reject with original error - reject({ - status, - code: apiData.code, - message: apiData.message || apiData.detail || error.message - }) - } - }) - }) - } - + const refreshSessionUser = localStorage.getItem('auth_user') originalRequest._retry = true - isRefreshing = true try { - // Call refresh endpoint directly to avoid circular dependency - const refreshResponse = await axios.post( - `${getAPIBaseURL()}/auth/refresh`, - { refresh_token: refreshToken }, - // 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing - // 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。 - { headers: { 'Content-Type': 'application/json' }, timeout: 30000 } - ) + const headers = originalRequest.headers as Record | undefined + const authHeader = headers?.Authorization ?? headers?.authorization + const failedAccessToken = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : null + const tokens = await refreshAuthTokens({ failedAccessToken }) - const refreshData = refreshResponse.data as ApiResponse<{ - access_token: string - refresh_token: string - expires_in: number - }> - - if (refreshData.code === 0 && refreshData.data) { - const { access_token, refresh_token: newRefreshToken, expires_in } = refreshData.data - - // Update tokens in localStorage (convert expires_in to timestamp) - localStorage.setItem('auth_token', access_token) - localStorage.setItem('refresh_token', newRefreshToken) - localStorage.setItem('token_expires_at', String(Date.now() + expires_in * 1000)) - - // Notify subscribers with new token - onTokenRefreshed(access_token) - - // Retry the original request with new token - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${access_token}` - } - - isRefreshing = false - return apiClient(originalRequest) + // Retry the original request with the refreshed token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${tokens.access_token}` + } + return apiClient(originalRequest) + } catch { + // A stale request must never destroy a session that was logged out or replaced while + // its refresh was in flight (for example, when another tab signs in as another user). + const sessionChanged = + localStorage.getItem('refresh_token') !== refreshToken || + localStorage.getItem('auth_user') !== refreshSessionUser + if (sessionChanged) { + return Promise.reject({ + status: 401, + code: 'AUTH_SESSION_CHANGED', + message: 'Authentication session changed while refreshing.' + }) } - - // Refresh response was not successful, fall through to clear auth - throw new Error('Token refresh failed') - } catch (refreshError) { - // Refresh failed - notify subscribers with empty token - onTokenRefreshed('') - isRefreshing = false // Clear tokens and redirect to login localStorage.removeItem('auth_token') diff --git a/frontend/src/api/tokenRefresh.ts b/frontend/src/api/tokenRefresh.ts new file mode 100644 index 0000000000..142dbba044 --- /dev/null +++ b/frontend/src/api/tokenRefresh.ts @@ -0,0 +1,241 @@ +import axios from 'axios' +import type { ApiResponse } from '@/types' +import { getAPIBaseURL } from './url' + +const AUTH_TOKEN_KEY = 'auth_token' +const AUTH_USER_KEY = 'auth_user' +const REFRESH_TOKEN_KEY = 'refresh_token' +const TOKEN_EXPIRES_AT_KEY = 'token_expires_at' +const TOKEN_REFRESH_LOCK_NAME = 'sub2api-auth-token-refresh' +const TOKEN_REFRESH_TIMEOUT_MS = 30_000 +const TOKEN_REFRESH_BUFFER_MS = 120_000 +const PEER_REFRESH_WAIT_MS = 1_000 +const PEER_REFRESH_GRACE_MS = 1_000 +const PEER_REFRESH_POLL_MS = 25 + +export interface RefreshTokenResponse { + access_token: string + refresh_token: string + expires_in: number + token_type: string +} + +export interface RefreshAuthTokensOptions { + /** Access token attached to the request that received a 401 response. */ + failedAccessToken?: string | null +} + +interface AuthSnapshot { + accessToken: string | null + refreshToken: string + expiresAt: number + userID: number | null +} + +let inFlightRefresh: Promise | null = null + +function getStoredUserID(): number | null { + const rawUser = localStorage.getItem(AUTH_USER_KEY) + if (!rawUser) { + return null + } + + try { + const id = Number((JSON.parse(rawUser) as { id?: unknown }).id) + return Number.isFinite(id) && id > 0 ? id : null + } catch { + return null + } +} + +function readAuthSnapshot(): AuthSnapshot { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) + if (!refreshToken) { + throw new Error('No refresh token available') + } + + return { + accessToken: localStorage.getItem(AUTH_TOKEN_KEY), + refreshToken, + expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)), + userID: getStoredUserID() + } +} + +function readStoredTokenPair(snapshot: AuthSnapshot): RefreshTokenResponse | null { + const accessToken = localStorage.getItem(AUTH_TOKEN_KEY) + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) + const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)) + + if ( + !accessToken || + !refreshToken || + !Number.isFinite(expiresAt) || + expiresAt <= Date.now() || + getStoredUserID() !== snapshot.userID + ) { + return null + } + + return { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)), + token_type: 'Bearer' + } +} + +function readPeerRefreshResult( + snapshot: AuthSnapshot, + failedAccessToken?: string | null +): RefreshTokenResponse | null { + const storedPair = readStoredTokenPair(snapshot) + if (!storedPair) { + return null + } + + if (storedPair.refresh_token !== snapshot.refreshToken) { + return storedPair + } + + if ( + failedAccessToken && + snapshot.accessToken !== failedAccessToken && + storedPair.access_token === snapshot.accessToken + ) { + return storedPair + } + + if (!failedAccessToken) { + const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)) + if ( + expiresAt === snapshot.expiresAt && + storedPair.access_token === snapshot.accessToken && + expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS + ) { + return storedPair + } + } + + return null +} + +async function waitForPeerRefresh( + snapshot: AuthSnapshot, + failedAccessToken?: string | null, + deadline = Date.now() + PEER_REFRESH_WAIT_MS +): Promise { + while (Date.now() < deadline) { + const peerResult = readPeerRefreshResult(snapshot, failedAccessToken) + if (peerResult) { + return peerResult + } + await new Promise((resolve) => window.setTimeout(resolve, PEER_REFRESH_POLL_MS)) + } + + return readPeerRefreshResult(snapshot, failedAccessToken) +} + +function persistTokenPair(tokens: RefreshTokenResponse): void { + localStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token) + localStorage.setItem(TOKEN_EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000)) + // The rotating refresh token is written last so other tabs can treat its change as a commit marker. + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token) +} + +async function requestTokenPair( + snapshot: AuthSnapshot, + failedAccessToken?: string | null, + mayHaveUncoordinatedPeer = false +): Promise { + // If this request loses a one-time-token race, the winning peer can legitimately take as long + // as our own HTTP timeout to publish its replacement token. Keep the recovery window tied to + // that timeout instead of an arbitrary short delay. + const peerRefreshDeadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS + PEER_REFRESH_GRACE_MS + + try { + const response = await axios.post>( + `${getAPIBaseURL()}/auth/refresh`, + { refresh_token: snapshot.refreshToken }, + { headers: { 'Content-Type': 'application/json' }, timeout: TOKEN_REFRESH_TIMEOUT_MS } + ) + const payload = response.data + if (payload.code !== 0 || !payload.data) { + throw new Error(payload.message || 'Token refresh failed') + } + + if ( + localStorage.getItem(REFRESH_TOKEN_KEY) !== snapshot.refreshToken || + getStoredUserID() !== snapshot.userID + ) { + const peerResult = readPeerRefreshResult(snapshot, failedAccessToken) + if (peerResult) { + return peerResult + } + throw new Error('Session changed during token refresh') + } + + persistTokenPair(payload.data) + return payload.data + } catch (error) { + // A peer tab may have rotated the one-time refresh token while this request was in flight. + // A 4xx response can arrive quickly while the winning peer's response is still in flight, so + // wait through the shared request deadline before treating the session as expired. Transient + // non-4xx failures retain the short reconciliation window. + const responseStatus = (error as { response?: { status?: unknown } }).response?.status + const isTokenRejection = + typeof responseStatus === 'number' && responseStatus >= 400 && responseStatus < 500 + const peerResult = await waitForPeerRefresh( + snapshot, + failedAccessToken, + isTokenRejection && mayHaveUncoordinatedPeer + ? peerRefreshDeadline + : Date.now() + PEER_REFRESH_WAIT_MS + ) + if (peerResult) { + return peerResult + } + throw error + } +} + +async function runRefresh(options: RefreshAuthTokensOptions): Promise { + const snapshot = readAuthSnapshot() + const refresh = async (mayHaveUncoordinatedPeer = false): Promise => { + const peerResult = readPeerRefreshResult(snapshot, options.failedAccessToken) + if (peerResult) { + return peerResult + } + return requestTokenPair(snapshot, options.failedAccessToken, mayHaveUncoordinatedPeer) + } + + if (typeof navigator !== 'undefined' && navigator.locks) { + return navigator.locks.request(TOKEN_REFRESH_LOCK_NAME, () => refresh(false)) + } + + return refresh(true) +} + +/** + * Refresh and persist the browser session. + * + * Calls in the same document share one promise. Web Locks serialize refreshes across tabs, while + * the token snapshot check adopts a peer's newly rotated token instead of logging the user out. + */ +export function refreshAuthTokens( + options: RefreshAuthTokensOptions = {} +): Promise { + if (inFlightRefresh) { + return inFlightRefresh + } + + const pending = runRefresh(options) + inFlightRefresh = pending + const clearPending = (): void => { + if (inFlightRefresh === pending) { + inFlightRefresh = null + } + } + void pending.then(clearPending, clearPending) + return pending +}