fix(auth): restore cross-tab permissions and bind retries to session identity
This commit is contained in:
+34
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user