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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-07 03:10:50 +08:00
co-authored by Codex
parent 128ced4093
commit 3d1dc79f5d
6 changed files with 35 additions and 45 deletions
+2 -1
View File
@@ -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/*` 鉴权和限额
+3 -2
View File
@@ -560,8 +560,9 @@ AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;K
成功登录替换当前浏览器的旧会话,登出必须确认服务端撤销。
管理员的不活动期限和最长有效期由 AuthService 持有;显式续期验证当前身份并限制写入频率,Store 原子比较会话后延长期限,
不重新创建已退出或过期的键,API 将返回的有效期同步到 Cookie。Key 与缺少最长有效期记录的会话保持固定期限。
前端在首次恢复及可见页面的使用行为中请求续期,后台轮询不续期;认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行,
其他环境在当前页面串行。请求层对 `40101` 共用一次会话恢复,恢复期间的新请求等待同一结果;
前端首次进入受保护页面只读取登录状态,点击、滚动、聚焦等页面活动不触发续期;
认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行,其他环境在当前页面串行。
请求层仅在业务接口返回 `40101` 时共用一次会话续期,失败请求及恢复期间的新请求等待同一结果;
成功后原请求最多重放一次,取消或身份已切换的请求不重放,确认失效才退出。恢复接口跳过该协调以避免循环等待。
读取请求和明确幂等的续期请求遇到临时网络或服务故障时有限重试,写操作不默认执行此类重试。
首次恢复由路由守卫等待校验后放行;临时故障阻止导航,恢复联网或重新聚焦时再次校验,不增加恢复页面。
+3
View File
@@ -43,6 +43,9 @@ export function getAuthStatus() {
return request<AuthStatusResponse>({
url: '/api/auth/status',
method: 'GET',
skipSessionRecovery: true,
silent: true,
timeout: 10_000,
})
}
+2 -2
View File
@@ -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 {
+7 -30
View File
@@ -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)
+18 -10
View File
@@ -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<boolean> {
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<boolean> {
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<typeof apiLogin>[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 }
})