fix(auth):修复身份认证失效后跳回登录页面又回到系统内的问题

This commit is contained in:
qian committed 2026-08-13 22:47:52 +08:00
1 parent 336ed835f1
commit 6aed01fba8
6 files changed
+135 -35

No files matched your search

+2 -1
View File
@@ -79,7 +79,8 @@ router.beforeEach(async (to) => {
// Token expired and no refresh token available — force login
if (shouldRefresh() && !getRefreshToken()) {
authStore.logout()
// 同步清空登录数据后再跳转,避免 async 登出未完成时登录页把用户又跳回系统
authStore.logoutLocal()
return { name: 'AdminLogin', query: { redirect: to.fullPath } }
}
+28 -10
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import request, { setTokens, clearTokens, onTokenRefreshed, setRememberMe, getRememberMe } from '@/utils/request'
import request, { setTokens, clearTokens, onTokenRefreshed, onAuthExpired, setRememberMe, getRememberMe } from '@/utils/request'
import { setAdminSession, clearAdminSession, ADMIN_ROLES } from '@/utils/permission'
export interface AdminUser {
@@ -113,21 +113,32 @@ export const useAuthStore = defineStore('admin-auth', () => {
applySession(loginData)
}
/** 同步清空全部登录数据(响应式状态 + token storage + session 持久化),不调用后端 */
function clearAuthState(): void {
token.value = null
refreshToken.value = null
expiresAt.value = null
user.value = null
permissions.value = []
pendingAgreements.value = []
clearTokens()
clearAdminSession()
localStorage.removeItem(STORE_KEY)
}
/** 同步本地登出(仅清前端,不通知后端),用于路由守卫等需要立即生效的场景 */
function logoutLocal(): void {
clearAuthState()
}
async function logout(): Promise<void> {
try {
await request.post('/admin/auth/logout')
} catch {
// best-effort
}
token.value = null
refreshToken.value = null
expiresAt.value = null
user.value = null
permissions.value = []
clearTokens()
clearAdminSession()
localStorage.removeItem(STORE_KEY)
clearAuthState()
}
async function refreshTokens(): Promise<void> {
@@ -149,6 +160,12 @@ export const useAuthStore = defineStore('admin-auth', () => {
persist()
})
// Axios 拦截器在 401 认证彻底失效时回调,由 store 清空全部登录数据
// (必须先清后跳,否则路由守卫会从持久化数据恢复登录态,造成登录页↔系统死循环)
onAuthExpired(() => {
clearAuthState()
})
function clearPendingAgreements(): void {
pendingAgreements.value = []
}
@@ -166,6 +183,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
login,
applyTokensFrom2FA,
logout,
logoutLocal,
refreshTokens,
loadFromStorage,
clearPendingAgreements,
+39 -8
View File
@@ -27,6 +27,9 @@ const EXPIRES_AT_KEY = 'admin_token_expires_at'
const REMEMBER_ME_KEY = 'admin_remember_me'
const TOKEN_BUFFER_SECONDS = 60
// 并发 401 时只清理并跳转一次;登录/刷新成功后由 setTokens 复位
let isLoggingOut = false
// Dynamic storage selection based on "remember me" preference
function getStorage(): Storage {
// Check user's "remember me" preference (defaults to true for backward compatibility)
@@ -45,6 +48,8 @@ export function getRememberMe(): boolean {
}
export function setTokens(tokens: TokenPair): void {
// 登录/刷新成功,复位登出节流标志,保证下次失效仍可跳转登录页
isLoggingOut = false
const storage = getStorage()
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
@@ -94,6 +99,38 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void {
tokenRefreshedCallback = cb
}
// 认证彻底失效时由 auth store 注册的清理回调(清空响应式状态与持久化登录数据)
let authExpiredCallback: (() => void) | null = null
export function onAuthExpired(cb: () => void): void {
authExpiredCallback = cb
}
/** 取登录失效后应回跳的路径(排除登录页自身) */
function getLoginRedirectPath(): string {
const hash = window.location.hash || ''
const path = hash.startsWith('#') ? hash.slice(1) : hash
const clean = path.split('?')[0]
if (!clean || clean.startsWith('/admin/login')) return '/admin'
return clean
}
/**
* 认证彻底失效(401 且无法刷新):先清空全部登录数据,再跳转登录页。
* 并发 401 只执行一次清理与跳转,避免重复弹窗。
*/
function handleAuthExpired(): void {
if (isLoggingOut) return
isLoggingOut = true
rejectPending(new Error('authentication expired'))
// 由 auth store 清空响应式状态与持久化数据(store 自身负责全部清理)
authExpiredCallback?.()
// 兜底清除 token storage
clearTokens()
Message.warning('登录状态已失效,请重新登录')
window.location.hash = `#/admin/login?redirect=${encodeURIComponent(getLoginRedirectPath())}`
}
function replayPending(token: string): void {
pendingRequests.forEach(({ resolve }) => resolve(token))
pendingRequests = []
@@ -151,10 +188,7 @@ request.interceptors.request.use(
const newTokens = await doRefresh()
replayPending(newTokens.accessToken)
} catch (error) {
rejectPending(error)
clearTokens()
showRequestError(error, config)
window.location.hash = '#/admin/login'
handleAuthExpired()
return Promise.reject(error)
} finally {
isRefreshing = false
@@ -263,10 +297,7 @@ request.interceptors.response.use(
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`
return request(originalRequest)
} catch (refreshError) {
rejectPending(refreshError)
clearTokens()
showRequestError(refreshError, originalRequest)
window.location.hash = '#/admin/login'
handleAuthExpired()
return Promise.reject(refreshError)
} finally {
isRefreshing = false
+2 -1
View File
@@ -62,7 +62,8 @@ router.beforeEach(async (to) => {
// Token expired and no refresh token available — force login
if (shouldRefresh() && !getRefreshToken()) {
tenantAuthStore.logout()
// 同步清空登录数据后再跳转,避免 async 登出未完成时登录页把用户又跳回系统
tenantAuthStore.logoutLocal()
return { name: 'TenantLogin', query: { redirect: to.fullPath } }
}
+25 -7
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import request, { setTokens, clearTokens, onTokenRefreshed, setRememberMe, getRememberMe } from '@/utils/request'
import request, { setTokens, clearTokens, onTokenRefreshed, onAuthExpired, setRememberMe, getRememberMe } from '@/utils/request'
import { setTenantSession, clearTenantSession, TENANT_ROLES } from '@/utils/permission'
export interface TenantInfo {
@@ -164,24 +164,35 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
}
}
async function logout(): Promise<void> {
try {
await request.post('/tenant/auth/logout')
} catch {
// best-effort
}
/** 同步清空全部登录数据(响应式状态 + token storage + session 持久化),不调用后端 */
function clearAuthState(): void {
token.value = null
refreshToken.value = null
expiresAt.value = null
tenant.value = null
user.value = null
permissions.value = []
pendingAgreements.value = []
clearTokens()
clearTenantSession()
localStorage.removeItem(STORE_KEY)
}
/** 同步本地登出(仅清前端,不通知后端),用于路由守卫等需要立即生效的场景 */
function logoutLocal(): void {
clearAuthState()
}
async function logout(): Promise<void> {
try {
await request.post('/tenant/auth/logout')
} catch {
// best-effort
}
clearAuthState()
}
async function refreshTokens(): Promise<void> {
const { data } = await request.post<{ data: AuthResponse }>('/tenant/auth/refresh', {
refresh_token: refreshToken.value,
@@ -201,6 +212,12 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
persist()
})
// Axios 拦截器在 401 认证彻底失效时回调,由 store 清空全部登录数据
// (必须先清后跳,否则路由守卫会从持久化数据恢复登录态,造成登录页↔系统死循环)
onAuthExpired(() => {
clearAuthState()
})
function clearPendingAgreements(): void {
pendingAgreements.value = []
}
@@ -221,6 +238,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
register,
refreshOrgInfo,
logout,
logoutLocal,
refreshTokens,
loadFromStorage,
clearPendingAgreements,
+39 -8
View File
@@ -27,6 +27,9 @@ const EXPIRES_AT_KEY = 'tenant_token_expires_at'
const REMEMBER_ME_KEY = 'tenant_remember_me'
const TOKEN_BUFFER_SECONDS = 60
// 并发 401 时只清理并跳转一次;登录/刷新成功后由 setTokens 复位
let isLoggingOut = false
// Dynamic storage selection based on "remember me" preference
function getStorage(): Storage {
// Check user's "remember me" preference (defaults to true for backward compatibility)
@@ -45,6 +48,8 @@ export function getRememberMe(): boolean {
}
export function setTokens(tokens: TokenPair): void {
// 登录/刷新成功,复位登出节流标志,保证下次失效仍可跳转登录页
isLoggingOut = false
const storage = getStorage()
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
@@ -94,6 +99,38 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void {
tokenRefreshedCallback = cb
}
// 认证彻底失效时由 tenant auth store 注册的清理回调(清空响应式状态与持久化登录数据)
let authExpiredCallback: (() => void) | null = null
export function onAuthExpired(cb: () => void): void {
authExpiredCallback = cb
}
/** 取登录失效后应回跳的路径(排除登录页自身) */
function getLoginRedirectPath(): string {
const hash = window.location.hash || ''
const path = hash.startsWith('#') ? hash.slice(1) : hash
const clean = path.split('?')[0]
if (!clean || clean.startsWith('/tenant/login')) return '/tenant/dashboard'
return clean
}
/**
* 认证彻底失效(401 且无法刷新):先清空全部登录数据,再跳转登录页。
* 并发 401 只执行一次清理与跳转,避免重复弹窗。
*/
function handleAuthExpired(): void {
if (isLoggingOut) return
isLoggingOut = true
rejectPending(new Error('authentication expired'))
// 由 tenant auth store 清空响应式状态与持久化数据(store 自身负责全部清理)
authExpiredCallback?.()
// 兜底清除 token storage
clearTokens()
toast.warning('登录状态已失效,请重新登录')
window.location.hash = `#/tenant/login?redirect=${encodeURIComponent(getLoginRedirectPath())}`
}
function replayPending(token: string): void {
pendingRequests.forEach(({ resolve }) => resolve(token))
pendingRequests = []
@@ -170,10 +207,7 @@ request.interceptors.request.use(
const newTokens = await doRefresh()
replayPending(newTokens.accessToken)
} catch (error) {
rejectPending(error)
clearTokens()
showRequestError(error, config)
window.location.hash = '#/tenant/login'
handleAuthExpired()
return Promise.reject(error)
} finally {
isRefreshing = false
@@ -291,10 +325,7 @@ request.interceptors.response.use(
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`
return request(originalRequest)
} catch (refreshError) {
rejectPending(refreshError)
clearTokens()
showRequestError(refreshError, originalRequest)
window.location.hash = '#/tenant/login'
handleAuthExpired()
return Promise.reject(refreshError)
} finally {
isRefreshing = false