fix(auth): restore cross-tab permissions and bind retries to session identity

This commit is contained in:
2026-10-02 23:29:20 +08:00
parent e564353a80
commit 5f963f326e
7 changed files with 386 additions and 109 deletions
+34 -3
View File
@@ -1,6 +1,6 @@
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { parseApiJsonResponse } from '@chongchong/shared'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
@@ -13,6 +13,7 @@ const transport = axios.create({
interface RetryableRequest extends InternalAxiosRequestConfig {
_retriedAfterRefresh?: boolean
_sessionIdentity?: string | null
}
function unwrap<T>(envelope: ApiEnvelope<T>) {
@@ -52,6 +53,7 @@ function mapError(error: unknown) {
}
let refreshPromise: Promise<string> | null = null
let refreshIdentity: string | null = null
let redirectingToLogin = false
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
@@ -70,29 +72,44 @@ function redirectToLoginAfterSessionExpiry() {
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
}
function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) {
if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
}
export async function refreshAuthenticatedSession() {
const observedIdentity = accessTokenIdentity(getAccessToken())
requireCurrentIdentity(observedIdentity)
const observedRefreshToken = getRefreshToken()
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
if (!refreshPromise) {
refreshIdentity = observedIdentity
refreshPromise = coordinateCrossTabRefresh({
lockName: 'cc-client-auth-refresh',
observedRefreshToken,
currentRefreshToken: getRefreshToken,
reuseCurrentSession: async () => {
requireCurrentIdentity(observedIdentity)
const currentAccessToken = getAccessToken()
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
return currentAccessToken
},
refresh: async (currentRefreshToken) => {
requireCurrentIdentity(observedIdentity)
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
const tokens = unwrap(response.data)
requireCurrentIdentity(observedIdentity)
requireCurrentIdentity(observedIdentity, tokens.accessToken)
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
persistTokenPair(tokens)
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated'))
return tokens.accessToken
},
})
.catch((error) => {
requireCurrentIdentity(observedIdentity)
if (error instanceof CrossTabRefreshTimeoutError) {
if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
const expiredError = new ApiError(error.message, 'A0401', 401)
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
@@ -103,14 +120,23 @@ export async function refreshAuthenticatedSession() {
})
.finally(() => {
refreshPromise = null
refreshIdentity = null
})
}
return refreshPromise
if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
const refreshed = await refreshPromise
requireCurrentIdentity(observedIdentity)
return refreshed
}
transport.interceptors.request.use((config) => {
const token = getAccessToken()
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
const request = config as RetryableRequest
if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
if (token && !isPublicAuthRequest(config.url)) {
if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token)
config.headers.Authorization = `Bearer ${token}`
}
return config
})
@@ -122,14 +148,19 @@ transport.interceptors.response.use(
throw mapError(error)
}
requireCurrentIdentity(request._sessionIdentity)
request._retriedAfterRefresh = true
const refreshTokenBeforeRetry = getRefreshToken()
try {
const accessToken = await refreshAuthenticatedSession()
requireCurrentIdentity(request._sessionIdentity)
request.headers.Authorization = `Bearer ${accessToken}`
return await transport.request(request)
} catch (refreshError) {
requireCurrentIdentity(request._sessionIdentity)
const mappedError = mapError(refreshError)
if (mappedError.status === 401 || mappedError.code === 'A0401') {
if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()