A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过
This commit is contained in:
+136
-12
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user