修复鉴权:401拦截器加refresh重试 + admin refresh接口 + 路由守卫完善
根因:access_token每2小时过期,前端收到401直接清token跳登录,没有用refresh_token续期 后端修复: - adminauth模块新增 POST /admin/auth/refresh 接口 - Service 注入 JWTManager,支持 admin refresh token 换新 token pair - Refresh 方法验证 subjectType=admin + tokenType=refresh 前端修复: - 401 拦截器核心改造:收到401先调 refresh 接口续期 - 加 isRefreshing 锁 + pendingRequests 队列防止并发刷新 - refresh 用原生 axios.post 避免拦截器递归 - 成功则更新 localStorage + 重试原请求,失败才清 token 跳登录 - 排除 /auth/refresh 自身避免死循环 - 支持 /admin/ 请求独立 token 管理 - auth.ts/adminAuth.ts 新增手动 refreshUserToken/refreshAdminSession - 路由守卫给所有需登录路由添加 meta.requiresAuth - 守卫同时支持 PC 端 /login 和移动端 /m/login
This commit is contained in:
+101
-24
@@ -1,10 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// ---- refresh retry lock ----
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<(token: string) => void> = []
|
||||
|
||||
function subscribePendingRequests(token: string) {
|
||||
pendingRequests.forEach((cb) => cb(token))
|
||||
pendingRequests.length = 0
|
||||
}
|
||||
|
||||
function addPendingRequest(callback: (token: string) => void) {
|
||||
pendingRequests.push(callback)
|
||||
}
|
||||
|
||||
async function refreshTokenAndRetry(isAdmin = false): Promise<string> {
|
||||
const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token'
|
||||
const refreshToken = localStorage.getItem(refreshTokenKey)
|
||||
if (!refreshToken) {
|
||||
throw new Error('no refresh token')
|
||||
}
|
||||
const endpoint = isAdmin ? '/admin/auth/refresh' : '/auth/refresh'
|
||||
// use raw axios (not apiClient) to avoid interceptor recursion
|
||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken })
|
||||
const newAccessToken = data.data.access_token
|
||||
const newRefreshToken = data.data.refresh_token
|
||||
const accessKey = isAdmin ? 'admin_access_token' : 'access_token'
|
||||
localStorage.setItem(accessKey, newAccessToken)
|
||||
localStorage.setItem(refreshTokenKey, newRefreshToken)
|
||||
return newAccessToken
|
||||
}
|
||||
|
||||
// clear user tokens
|
||||
function clearUserTokens() {
|
||||
;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
// clear admin tokens
|
||||
function clearAdminTokens() {
|
||||
;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
// ---- Request interceptor ----
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const url = config.url || ''
|
||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||
@@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// ---- Response interceptor ----
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status !== 401) {
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error?.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const requestUrl = error.config?.url || ''
|
||||
const requestUrl = originalRequest.url || ''
|
||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
|
||||
if (isAdminRequest) {
|
||||
localStorage.removeItem('admin_access_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
// exclude refresh endpoints themselves to avoid dead loop
|
||||
if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) {
|
||||
if (isAdminRequest) clearAdminTokens()
|
||||
else clearUserTokens()
|
||||
if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.assign('/admin/login')
|
||||
} else if (!isAdminRequest) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_id')
|
||||
localStorage.removeItem('phone')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('avatar_url')
|
||||
localStorage.removeItem('realname_status')
|
||||
|
||||
if (!window.location.pathname.startsWith('/m/login') && !window.location.pathname.startsWith('/m/register')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
// if already refreshing, queue up
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
addPendingRequest((newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(apiClient(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
// attempt refresh
|
||||
isRefreshing = true
|
||||
try {
|
||||
const newToken = await refreshTokenAndRetry(isAdminRequest)
|
||||
subscribePendingRequests(newToken)
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
} catch {
|
||||
// refresh failed, clear tokens and redirect to login
|
||||
subscribePendingRequests('') // let queued requests fail
|
||||
if (isAdminRequest) {
|
||||
clearAdminTokens()
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.assign('/admin/login')
|
||||
}
|
||||
} else {
|
||||
clearUserTokens()
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user