优化前端认证与类型管理
This commit is contained in:
+98
-87
@@ -1,131 +1,142 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import {
|
||||
clearAuthStorage,
|
||||
getAccessToken,
|
||||
getLoginPath,
|
||||
getRefreshToken,
|
||||
setAuthTokens,
|
||||
type AuthScope,
|
||||
} from '@/utils/authStorage'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
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
|
||||
export async function unwrapData<T>(request: Promise<AxiosResponse<ApiResponse<T>>>) {
|
||||
const { data } = await request
|
||||
return data.data
|
||||
}
|
||||
|
||||
function addPendingRequest(callback: (token: string) => void) {
|
||||
pendingRequests.push(callback)
|
||||
type RetryRequest = {
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
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 ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||
// use raw axios (not apiClient) to avoid interceptor recursion
|
||||
type RefreshState = {
|
||||
refreshing: boolean
|
||||
pendingRequests: RetryRequest[]
|
||||
}
|
||||
|
||||
type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
const refreshStates: Record<AuthScope, RefreshState> = {
|
||||
user: {
|
||||
refreshing: false,
|
||||
pendingRequests: [],
|
||||
},
|
||||
admin: {
|
||||
refreshing: false,
|
||||
pendingRequests: [],
|
||||
},
|
||||
}
|
||||
|
||||
function resolvePendingRequests(scope: AuthScope, token: string) {
|
||||
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||
pendingRequests.forEach(({ resolve }) => resolve(token))
|
||||
}
|
||||
|
||||
function rejectPendingRequests(scope: AuthScope, error: unknown) {
|
||||
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
|
||||
pendingRequests.forEach(({ reject }) => reject(error))
|
||||
}
|
||||
|
||||
async function refreshToken(scope: AuthScope): Promise<string> {
|
||||
const refreshToken = getRefreshToken(scope)
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
|
||||
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||
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
|
||||
const tokens = {
|
||||
access_token: data.data.access_token,
|
||||
refresh_token: data.data.refresh_token,
|
||||
}
|
||||
setAuthTokens(scope, tokens)
|
||||
return tokens.access_token
|
||||
}
|
||||
|
||||
// clear user tokens
|
||||
function clearUserTokens() {
|
||||
;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
function getRequestScope(url = ''): AuthScope {
|
||||
return url.startsWith('/admin') ? 'admin' : 'user'
|
||||
}
|
||||
|
||||
// clear admin tokens
|
||||
function clearAdminTokens() {
|
||||
;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username']
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
function redirectToLogin(scope: AuthScope) {
|
||||
clearAuthStorage(scope)
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const loginPath = getLoginPath(scope, currentPath)
|
||||
if (currentPath.startsWith(loginPath)) return
|
||||
|
||||
if (scope === 'admin') {
|
||||
window.location.assign(loginPath)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`)
|
||||
}
|
||||
|
||||
function isRefreshRequest(url = '') {
|
||||
return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh')
|
||||
}
|
||||
|
||||
// ---- Request interceptor ----
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const url = config.url || ''
|
||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||
const token = localStorage.getItem(tokenKey)
|
||||
const scope = getRequestScope(config.url || '')
|
||||
const token = getAccessToken(scope)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// ---- Response interceptor ----
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error?.response?.status !== 401 || originalRequest._retry) {
|
||||
const originalRequest = error.config as RetriableRequestConfig | undefined
|
||||
if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const requestUrl = originalRequest.url || ''
|
||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
}
|
||||
const scope = getRequestScope(requestUrl)
|
||||
if (isRefreshRequest(requestUrl)) {
|
||||
redirectToLogin(scope)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// if already refreshing, queue up
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
addPendingRequest((newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(apiClient(originalRequest))
|
||||
})
|
||||
const state = refreshStates[scope]
|
||||
if (state.refreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
state.pendingRequests.push({ resolve, reject })
|
||||
}).then((newToken) => {
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// attempt refresh
|
||||
isRefreshing = true
|
||||
state.refreshing = true
|
||||
try {
|
||||
const newToken = await refreshTokenAndRetry(isAdminRequest)
|
||||
subscribePendingRequests(newToken)
|
||||
const newToken = await refreshToken(scope)
|
||||
resolvePendingRequests(scope, 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)
|
||||
} catch (refreshError) {
|
||||
rejectPendingRequests(scope, refreshError)
|
||||
redirectToLogin(scope)
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
state.refreshing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user