A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过

This commit is contained in:
Simon
2026-07-02 19:06:12 +08:00
parent 78f60c6857
commit fc22de7f4d
12 changed files with 1813 additions and 106 deletions
+136 -12
View File
@@ -4,7 +4,7 @@
// 说明:创建 Axios 实例,配置:
// 1. 请求基础 URL
// 2. 请求拦截器(添加认证头等)
// 3. 响应拦截器(统一错误处理)
// 3. 响应拦截器(统一错误处理 + Token 静默刷新
// =============================================================================
import axios from 'axios'
@@ -29,6 +29,129 @@ const apiClient: AxiosInstance = axios.create({
},
})
// --------------------------------------------------------------------------
// AUTH-P0-02: Token 静默刷新机制
// --------------------------------------------------------------------------
// Token 存储键名
const TOKEN_KEY = 'agent_token'
// OAuth2 重定向计数器 key
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
// 最大允许重定向次数
const OAUTH_MAX_REDIRECT_COUNT = 3
// 401 处理锁(防止并发请求同时触发多次认证过期处理)
let _authExpiredPromise: Promise<void> | null = null
// Token 刷新锁(防止多个并发请求同时触发刷新)
let _refreshPromise: Promise<boolean> | null = null
/**
* 静默刷新 Token
* 在 Token 过期前 5 分钟自动调用,成功后返回 true
*/
async function silentRefreshToken(): Promise<boolean> {
// 如果已有刷新在进行中,等待同一个 Promise
if (_refreshPromise) {
return _refreshPromise
}
const token = localStorage.getItem(TOKEN_KEY)
if (!token) {
return false
}
console.log('[API] 尝试静默刷新 Token...')
// 创建刷新 Promise 并缓存
_refreshPromise = (async () => {
try {
const response = await axios.post('/api/auth/refresh', null, {
params: { token },
timeout: 5000, // 刷新接口 5 秒超时
})
if (response.data.code === 0) {
// 刷新成功,Token TTL 已延长
console.log('[API] Token 静默刷新成功')
return true
}
console.warn('[API] Token 刷新失败:', response.data.message)
return false
} catch (e) {
console.warn('[API] Token 刷新异常:', e)
return false
} finally {
_refreshPromise = null
}
})()
return _refreshPromise
}
/**
* 处理认证过期/未授权
* AUTH-P0-02: 增强 401 处理,先尝试刷新 Token,成功则重放请求
*/
async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
const label = source === 'http401' ? 'HTTP 401' : '业务码 1002'
// 如果已有 401 正在处理中,复用同一个 Promise,避免多次处理
if (_authExpiredPromise) {
console.warn(`[API] ${label} 未授权 — 已有处理进行中,等待完成`)
return _authExpiredPromise
}
console.warn(`[API] ${label} 未授权,尝试刷新 Token...`)
// 创建处理 Promise 并缓存(去重用)
_authExpiredPromise = (async () => {
try {
// 第一步:尝试静默刷新 Token
const refreshSuccess = await silentRefreshToken()
if (refreshSuccess) {
console.log('[API] Token 刷新成功,不需要跳转')
_authExpiredPromise = null
return
}
// 第二步:刷新失败,清除凭证并跳转登录
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
// 清除本地 token
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('portal_token')
// 跳转登录页
ElMessage.warning('登录已过期,请重新登录')
// 防循环检测:超过最大重定向次数时停止跳转
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
console.error('[API] 登录重定向次数超限,疑似无限循环,停止重定向')
ElMessage.error('登录状态异常,请刷新页面重试')
return
}
console.warn(`[API] 登录重定向计数: ${currentCount}/${OAUTH_MAX_REDIRECT_COUNT}`)
// 动态导入避免循环依赖
import('@/router').then(router => {
router.default.push('/login')
})
} catch (e) {
console.warn('[API] 401 处理失败:', e)
} finally {
// 处理完成后清除锁,允许未来的 401 重新触发
_authExpiredPromise = null
}
})()
return _authExpiredPromise
}
// --------------------------------------------------------------------------
// 请求拦截器
// --------------------------------------------------------------------------
@@ -36,7 +159,7 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取坐席 token,添加到请求头
const token = localStorage.getItem('agent_token')
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -63,13 +186,10 @@ apiClient.interceptors.response.use(
// 业务错误:显示错误消息
ElMessage.error(res.message || '请求失败')
// 特殊错误码处理
// AUTH-P0-02: 特殊错误码处理 - 尝试刷新 Token
if (res.code === 1002) {
// 未授权:跳转到登录页
// 动态导入避免循环依赖
import('@/router').then(router => {
router.default.push('/login')
})
// 未授权:先尝试刷新 Token
handleAuthExpired('biz1002')
}
// 返回 rejected Promise,让调用方的 catch 能捕获
@@ -79,7 +199,7 @@ apiClient.interceptors.response.use(
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
return response
},
(error) => {
async (error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)
let message = '网络异常,请稍后重试'
@@ -87,7 +207,9 @@ apiClient.interceptors.response.use(
// 服务器返回了错误状态码
switch (error.response.status) {
case 401:
message = '未授权,请重新登录'
// AUTH-P0-02: 先尝试静默刷新 Token,成功则重放请求
await handleAuthExpired('http401')
// 不显示通用提示,因为会自动处理
break
case 403:
message = '拒绝访问'
@@ -106,8 +228,10 @@ apiClient.interceptors.response.use(
message = '请求超时,请稍后重试'
}
// 显示错误提示
ElMessage.error(message)
// 显示错误提示(401 时不显示通用提示,因为会自动处理)
if (!error.response || error.response.status !== 401) {
ElMessage.error(message)
}
return Promise.reject(error)
}