fix(auth):修复身份认证失效后跳回登录页面又回到系统内的问题
This commit is contained in:
1 parent
336ed835f1
commit
6aed01fba8
6 files changed
+135
-35
No files matched your search
@@ -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 } }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user