fix(auth): revoke captured client sessions safely on logout
This commit is contained in:
+49
-4
@@ -14,6 +14,7 @@ const transport = axios.create({
|
||||
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||
_retriedAfterRefresh?: boolean
|
||||
_sessionIdentity?: string | null
|
||||
_logoutGeneration?: number
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||
@@ -52,6 +53,8 @@ function mapError(error: unknown) {
|
||||
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
|
||||
}
|
||||
|
||||
let refreshExchange: { identity: string; refreshToken: string; promise: Promise<TokenPair> } | null = null
|
||||
let logoutGeneration = 0
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
let refreshIdentity: string | null = null
|
||||
let redirectingToLogin = false
|
||||
@@ -95,8 +98,10 @@ export async function refreshAuthenticatedSession() {
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
|
||||
const tokens = unwrap(response.data)
|
||||
const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) }
|
||||
refreshExchange = exchange
|
||||
let tokens: TokenPair
|
||||
try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null }
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
requireCurrentIdentity(observedIdentity, tokens.accessToken)
|
||||
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
|
||||
@@ -134,29 +139,42 @@ transport.interceptors.request.use((config) => {
|
||||
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)
|
||||
if (!request._retriedAfterRefresh) {
|
||||
request._sessionIdentity = accessTokenIdentity(token)
|
||||
request._logoutGeneration = logoutGeneration
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
transport.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
const request = response.config as RetryableRequest
|
||||
if (request._sessionIdentity) {
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const request = error.config as RetryableRequest | undefined
|
||||
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
|
||||
throw mapError(error)
|
||||
}
|
||||
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request._retriedAfterRefresh = true
|
||||
const refreshTokenBeforeRetry = getRefreshToken()
|
||||
try {
|
||||
const accessToken = await refreshAuthenticatedSession()
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request.headers.Authorization = `Bearer ${accessToken}`
|
||||
return await transport.request(request)
|
||||
} catch (refreshError) {
|
||||
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
const mappedError = mapError(refreshError)
|
||||
if (mappedError.status === 401 || mappedError.code === 'A0401') {
|
||||
@@ -170,6 +188,33 @@ transport.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
// Captured revocation never persists credentials or restores local login state.
|
||||
export async function revokeCapturedSession(accessToken: string, refreshToken: string) {
|
||||
logoutGeneration++
|
||||
const identity = accessTokenIdentity(accessToken)
|
||||
if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED')
|
||||
const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null
|
||||
const revoke = (pair: Pick<TokenPair, 'accessToken' | 'refreshToken'>) => apiRequest<void>({
|
||||
method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken },
|
||||
headers: { Authorization: `Bearer ${pair.accessToken}` },
|
||||
})
|
||||
const validate = (pair: TokenPair) => {
|
||||
if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED')
|
||||
return pair
|
||||
}
|
||||
// An already sent refresh can rotate the pair. Consume its actual result
|
||||
// without issuing a competing refresh or restoring it to local storage.
|
||||
if (pending) {
|
||||
await revoke(validate(await pending))
|
||||
return
|
||||
}
|
||||
try { await revoke({ accessToken, refreshToken }) } catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) throw error
|
||||
const pair = await apiRequest<TokenPair>({ method: 'POST', url: '/auth/refresh', data: { refreshToken } })
|
||||
await revoke(validate(pair))
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<ApiEnvelope<T>>(config)
|
||||
|
||||
Reference in New Issue
Block a user