feat: 重构鉴权系统为双Token无感刷新,并增加前端Token过期安全预判机制

This commit is contained in:
DXC
2026-03-10 09:45:41 +08:00
parent 6fc6851e57
commit e4632086a1
6 changed files with 321 additions and 35 deletions

View File

@ -1,6 +1,6 @@
import axios from 'axios'
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user' // 引入 Store 获取 Token
import { useUserStore } from '@/stores/user'
// 1. 创建 axios 实例
const service = axios.create({
@ -11,13 +11,54 @@ const service = axios.create({
timeout: 5000
})
// 2. 请求拦截器
// ============================================================
// 2. 无感刷新 Token 核心逻辑
// ============================================================
// 标记是否正在刷新 Token
let isRefreshing = false
// 重试队列:存储失败的请求,刷新成功后重新发送
const retryQueue: {
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
config: InternalAxiosRequestConfig
}[] = []
// 处理队列中的请求
const processQueue = (newToken: string | null, error?: Error) => {
retryQueue.forEach(({ resolve, reject, config }) => {
if (newToken) {
// 更新 Authorization 头
config.headers['Authorization'] = 'Bearer ' + newToken
// 重新发送请求
service(config).then(resolve).catch(reject)
} else {
reject(error)
}
})
// 清空队列
retryQueue.length = 0
}
// 刷新 Token 接口
const refreshToken = async (refreshTokenValue: string): Promise<string> => {
const res = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshTokenValue
})
const data = res.data
if (data.access_token) {
return data.access_token
}
throw new Error('Token 刷新失败')
}
// ============================================================
// 3. 请求拦截器
// ============================================================
service.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 注意:这里需要确保 Pinia 已经初始化,但在拦截器运行时组件早已加载,通常没问题
// 为了安全起见,也可以直接读 localStorage或者在函数内调用 store
const token = localStorage.getItem('token')
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
if (token && config.headers) {
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
@ -30,7 +71,9 @@ service.interceptors.request.use(
}
)
// 3. 响应拦截器
// ============================================================
// 4. 响应拦截器(核心:无感刷新)
// ============================================================
service.interceptors.response.use(
(response) => {
// Axios 默认包了一层 data所以这里取 response.data
@ -47,35 +90,132 @@ service.interceptors.response.use(
return res // 返回解包后的数据
}
},
(error) => {
async (error: AxiosError) => {
console.log('err: ' + error) // for debug
// 如果不是 axios 错误,直接抛出
if (!error.response) {
return Promise.reject(error)
}
const originalConfig = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
let message = error.message || '请求失败'
// 处理 HTTP 状态码错误
const isLoginEndpoint = error.config && error.config.url.includes('/login')
const isLoginEndpoint = error.config && error.config.url && error.config.url.includes('/login')
const isRefreshEndpoint = error.config && error.config.url && error.config.url.includes('/refresh')
if (error.response) {
const status = error.response.status
const data = error.response.data
const status = error.response.status
const data = error.response.data as any
if (status === 401) {
// 对于登录接口的401错误不执行登出重定向仅提示错误
if (!isLoginEndpoint) {
message = '登录已过期,请重新登录'
// ============================================================
// 核心401 错误处理 + 无感刷新
// ============================================================
if (status === 401) {
// 1. 如果是登录接口的 401不执行刷新
if (isLoginEndpoint) {
message = data?.msg || '用户名或密码错误'
ElMessage.error(message)
return Promise.reject(error)
}
// 2. 如果是刷新接口的 401说明 refresh_token 也过期了
if (isRefreshEndpoint) {
message = '登录已彻底过期,请重新登录'
ElMessage.error(message)
// 清空所有 Token跳转登录页
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 3. 业务接口返回 401尝试无感刷新
if (!originalConfig) {
return Promise.reject(error)
}
// 如果已经重试过(防止无限循环)
if (originalConfig._retry) {
message = '登录已过期,请重新登录'
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 标记为已重试
originalConfig._retry = true
// 获取 refresh_token
const refreshTokenValue = localStorage.getItem('refresh_token')
if (!refreshTokenValue) {
// 没有 refresh_token直接跳转登录
message = '登录已过期,请重新登录'
ElMessage.error(message)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 4. 尝试刷新 Token
if (!isRefreshing) {
isRefreshing = true
try {
console.log('正在刷新 Token...')
const newAccessToken = await refreshToken(refreshTokenValue)
// 刷新成功,更新本地 Token
const userStore = useUserStore()
userStore.setToken(newAccessToken)
// 更新当前请求的 Authorization
originalConfig.headers['Authorization'] = 'Bearer ' + newAccessToken
console.log('Token 刷新成功,重发队列中的请求')
// 处理队列中的请求
processQueue(newAccessToken)
// 重发当前请求
return service(originalConfig)
} catch (refreshError) {
console.error('Token 刷新失败:', refreshError)
// 刷新失败,清空队列并跳转登录
processQueue(null, refreshError as Error)
message = '登录已彻底过期,请重新登录'
ElMessage.error(message)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
// 如果是登录接口message会被后面的data.msg覆盖
} else if (status === 403) {
message = '权限不足'
} else if (status === 404) {
message = '请求的资源不存在'
} else if (status === 500) {
message = '服务器内部错误'
} else if (data && data.msg) {
// 优先显示后端返回的错误信息
message = data.msg
} else {
// 5. 正在刷新中,将当前请求加入队列等待
return new Promise((resolve, reject) => {
retryQueue.push({
resolve,
reject,
config: originalConfig
})
})
}
} else if (status === 403) {
message = '权限不足'
} else if (status === 404) {
message = '请求的资源不存在'
} else if (status === 500) {
message = '服务器内部错误'
} else if (data && data.msg) {
// 优先显示后端返回的错误信息
message = data.msg
}
// 登录接口的错误由调用方单独处理,不再显示全局提示