From 3d1dc79f5d50e41a3db572d988896c1f963db994 Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 7 Oct 2026 03:10:50 +0800 Subject: [PATCH] fix(auth): refresh sessions only after authentication failures Use read-only status checks for initial authentication and remove activity-driven renewal. Keep failed and newly queued requests waiting for the current refresh before replaying with the updated cookie. Co-authored-by: Codex --- deploy/README.md | 3 +- docs/architecture.md | 5 +-- frontend/src/api/modules/login.ts | 3 ++ frontend/src/api/request/plugins/session.ts | 4 +-- frontend/src/plugins/auth.ts | 37 ++++----------------- frontend/src/stores/modules/auth.ts | 28 ++++++++++------ 6 files changed, 35 insertions(+), 45 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index b237db3f5..6c12f9b24 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -146,7 +146,8 @@ openssl rand -hex 24 另行设置 `admin.default_password`。它至少需要 12 个字符,不能是常见弱口令,也不能包含 `$`。 管理员的 `admin.session_ttl_minutes` 控制不活动期限,`admin.session_absolute_ttl_minutes` 控制从登录起计算的最长有效期, 后者省略时为 43200 分钟(30 天)。配置模板分别为 10080 分钟(7 天)和 43200 分钟;已有配置保留自己的不活动期限。 -使用管理页面时会自动续期,最终到期时间不超过最长有效期;修改配置需重启,已有会话的最长有效期不会因此延长。 +页面仅在接口报告会话失效时尝试续期,已过期或撤销的会话需要重新登录;续期不能超过最长有效期。 +修改配置需重启,已有会话的最长有效期不会因此延长。 缺少最长有效期记录的会话保持原有固定期限,重新登录后应用续期策略。 `client.session_ttl_minutes` 控制密钥身份的固定有效期,默认 1440 分钟,不随活动续期。 两种身份共用一个 Cookie,成功登录替换当前浏览器的旧会话;不改变 `/v1/*` 鉴权和限额 diff --git a/docs/architecture.md b/docs/architecture.md index 038ea87c4..204d59737 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -560,8 +560,9 @@ AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;K 成功登录替换当前浏览器的旧会话,登出必须确认服务端撤销。 管理员的不活动期限和最长有效期由 AuthService 持有;显式续期验证当前身份并限制写入频率,Store 原子比较会话后延长期限, 不重新创建已退出或过期的键,API 将返回的有效期同步到 Cookie。Key 与缺少最长有效期记录的会话保持固定期限。 -前端在首次恢复及可见页面的使用行为中请求续期,后台轮询不续期;认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行, -其他环境在当前页面串行。请求层对 `40101` 共用一次会话恢复,恢复期间的新请求等待同一结果; +前端首次进入受保护页面只读取登录状态,点击、滚动、聚焦等页面活动不触发续期; +认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行,其他环境在当前页面串行。 +请求层仅在业务接口返回 `40101` 时共用一次会话续期,失败请求及恢复期间的新请求等待同一结果; 成功后原请求最多重放一次,取消或身份已切换的请求不重放,确认失效才退出。恢复接口跳过该协调以避免循环等待。 读取请求和明确幂等的续期请求遇到临时网络或服务故障时有限重试,写操作不默认执行此类重试。 首次恢复由路由守卫等待校验后放行;临时故障阻止导航,恢复联网或重新聚焦时再次校验,不增加恢复页面。 diff --git a/frontend/src/api/modules/login.ts b/frontend/src/api/modules/login.ts index cf9070519..8b4868346 100644 --- a/frontend/src/api/modules/login.ts +++ b/frontend/src/api/modules/login.ts @@ -43,6 +43,9 @@ export function getAuthStatus() { return request({ url: '/api/auth/status', method: 'GET', + skipSessionRecovery: true, + silent: true, + timeout: 10_000, }) } diff --git a/frontend/src/api/request/plugins/session.ts b/frontend/src/api/request/plugins/session.ts index 47b44c062..4a534787f 100644 --- a/frontend/src/api/request/plugins/session.ts +++ b/frontend/src/api/request/plugins/session.ts @@ -76,8 +76,8 @@ export async function sessionPlugin(context: RequestContext, send: RequestNext) if (!isSessionRequired(error) || config.signal?.aborted || generation !== sessionGeneration) throw error if (!config.skipSessionRecovery && sessionRecoveryHandler - && (successfulRecoveries !== recovered || await recoverSession())) { - // 40101 表示认证阶段拒绝;恢复成功后原请求最多重放一次 + && await (sessionRecovery ?? (successfulRecoveries !== recovered ? true : recoverSession()))) { + // 迟到的 40101 也须等待当前续期;Cookie 更新完成后原请求最多重放一次 if (config.signal?.aborted || generation !== sessionGeneration) throw error try { diff --git a/frontend/src/plugins/auth.ts b/frontend/src/plugins/auth.ts index f890a8628..ad69957b0 100644 --- a/frontend/src/plugins/auth.ts +++ b/frontend/src/plugins/auth.ts @@ -8,7 +8,6 @@ import { useAuthStore } from '@/stores/modules/auth' export const authPlugin: Plugin = { install(app) { const authStore = useAuthStore(pinia) - let lastAttempt = 0 async function redirectToLogin(loginMode = authStore.session?.role ?? 'admin') { authStore.invalidateSession() @@ -17,49 +16,27 @@ export const authPlugin: Plugin = { } setUnauthorizedHandler(() => redirectToLogin()) - setSessionRecoveryHandler(() => authStore.checkAuth()) + setSessionRecoveryHandler(() => authStore.refreshSession()) - async function restore(force = false) { - if (document.visibilityState !== 'visible' || authStore.loading || authStore.checking) + async function restoreInitialNavigation() { + if (document.visibilityState !== 'visible' || authStore.loading || authStore.checking || authStore.sessionChecked) return - if (!authStore.sessionChecked) { - if (force) - await router.replace(`${window.location.pathname}${window.location.search}${window.location.hash}`) - return - } - if (!authStore.isAdmin) - return - // 只由可见页面的使用行为触发,后台轮询不会无限延长不活动期限 - const remaining = Date.parse(authStore.session!.expiresAt) - Date.now() - const interval = Math.max(0, Math.min(60_000, remaining / 4)) - if (!force && Date.now() - lastAttempt < interval) - return - lastAttempt = Date.now() try { - if (!await authStore.checkAuth()) - await redirectToLogin('admin') + // 首次导航被临时故障中止时,只重试路由守卫的只读校验 + await router.replace(`${window.location.pathname}${window.location.search}${window.location.hash}`) } catch { - // 服务不可用不代表退出,后续操作或恢复联网后再次确认 + // 服务不可用不代表退出,恢复联网后仍可再次校验 } } - const activity = () => { - void restore() - } const resume = () => { - void restore(true) + void restoreInitialNavigation() } - document.addEventListener('pointerdown', activity, { passive: true }) - document.addEventListener('keydown', activity) - document.addEventListener('scroll', activity, { passive: true, capture: true }) document.addEventListener('visibilitychange', resume) window.addEventListener('focus', resume) window.addEventListener('online', resume) app.onUnmount(() => { - document.removeEventListener('pointerdown', activity) - document.removeEventListener('keydown', activity) - document.removeEventListener('scroll', activity, true) document.removeEventListener('visibilitychange', resume) window.removeEventListener('focus', resume) window.removeEventListener('online', resume) diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index 371764683..91cb93024 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -3,7 +3,7 @@ import type { AuthSession } from '@/api' import { defineStore } from 'pinia' import { computed, shallowRef } from 'vue' -import { login as apiLogin, logout as apiLogout, refreshAuthSession } from '@/api' +import { login as apiLogin, logout as apiLogout, getAuthStatus, refreshAuthSession } from '@/api' import { resetUnauthorizedHandling } from '@/api/request' export const useAuthStore = defineStore('auth', () => { @@ -19,8 +19,22 @@ export const useAuthStore = defineStore('auth', () => { function checkAuth(): Promise { if (pendingCheck.value) return pendingCheck.value - const currentRevision = revision - const check = refreshAuthSession().then((status) => { + const check = loadSession(getAuthStatus).finally(() => { + if (pendingCheck.value === check) + pendingCheck.value = undefined + }) + pendingCheck.value = check + return check + } + + function refreshSession() { + // 并发续期由请求层协调,不能复用只读状态检查来释放失败请求 + return loadSession(refreshAuthSession) + } + + function loadSession(load: typeof getAuthStatus): Promise { + const currentRevision = ++revision + return load().then((status) => { // 登录或退出之后到达的旧状态响应,不覆盖新会话。 if (currentRevision === revision) { const wasAuthenticated = isAuthenticated.value @@ -30,13 +44,7 @@ export const useAuthStore = defineStore('auth', () => { resetUnauthorizedHandling() } return isAuthenticated.value - }).finally(() => { - if (pendingCheck.value === check) { - pendingCheck.value = undefined - } }) - pendingCheck.value = check - return check } async function login(payload: Parameters[0]) { @@ -90,5 +98,5 @@ export const useAuthStore = defineStore('auth', () => { resetUnauthorizedHandling() } - return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, login, logout, invalidateSession } + return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession } })