diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index caffbaf..7c8f2cf 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -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 } } } diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index 5e44033..87d9318 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -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 { 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 { @@ -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, diff --git a/web/admin/src/utils/request.ts b/web/admin/src/utils/request.ts index 112a7ba..d84bc98 100644 --- a/web/admin/src/utils/request.ts +++ b/web/admin/src/utils/request.ts @@ -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 diff --git a/web/tenant/src/router/index.ts b/web/tenant/src/router/index.ts index c2a7b82..d0c54fe 100644 --- a/web/tenant/src/router/index.ts +++ b/web/tenant/src/router/index.ts @@ -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 } } } diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index d086b8f..146e24a 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -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 { - 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 { + try { + await request.post('/tenant/auth/logout') + } catch { + // best-effort + } + clearAuthState() + } + async function refreshTokens(): Promise { 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, diff --git a/web/tenant/src/utils/request.ts b/web/tenant/src/utils/request.ts index c585d6e..0f046fd 100644 --- a/web/tenant/src/utils/request.ts +++ b/web/tenant/src/utils/request.ts @@ -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