feat(auth): 登录态改为滑动续期(后台 24h/7d,打手 7d/30d)

This commit is contained in:
yml2213
2026-10-25 13:28:58 +08:00
parent 81edf530db
commit 2fb7aae13f
31 changed files with 510 additions and 64 deletions
+21
View File
@@ -0,0 +1,21 @@
type HeaderWriter = {
setHeader(name: string, value: string): unknown
}
type SessionExpiryLike = {
expiresAt?: string | null
absoluteExpiresAt?: string | null
}
/**
* 把滑动续期后的空闲到期时间回传给前端。
* Token 本身不重签(exp 是绝对上限),前端只更新本地空闲计时器。
*/
export function setSessionExpiryHeaders(res: HeaderWriter, session: SessionExpiryLike): void {
if (session.expiresAt) {
res.setHeader('X-Session-Expires-At', session.expiresAt)
}
if (session.absoluteExpiresAt) {
res.setHeader('X-Session-Absolute-Expires-At', session.absoluteExpiresAt)
}
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveSessionExpiryWindow, resolveSlidingSessionExpiresAt } from './session-window.js'
import { setSessionExpiryHeaders } from './session-headers.js'
test('登录时同时给出空闲到期与绝对到期(后台 24h / 7d)', () => {
const window = resolveSessionExpiryWindow({
now: '2026-10-25T00:00:00.000Z',
idleHours: 24,
absoluteHours: 168,
})
assert.equal(window.idleExpiresAt, '2026-10-26T00:00:00.000Z')
assert.equal(window.absoluteExpiresAt, '2026-11-01T00:00:00.000Z')
})
test('登录时同时给出空闲到期与绝对到期(打手 7d / 30d)', () => {
const window = resolveSessionExpiryWindow({
now: '2026-10-25T00:00:00.000Z',
idleHours: 168,
absoluteHours: 720,
})
assert.equal(window.idleExpiresAt, '2026-11-01T00:00:00.000Z')
assert.equal(window.absoluteExpiresAt, '2026-11-24T00:00:00.000Z')
})
test('绝对上限小于空闲窗口时以绝对上限为准', () => {
const window = resolveSessionExpiryWindow({
now: '2026-10-25T00:00:00.000Z',
idleHours: 24,
absoluteHours: 12,
})
assert.equal(window.idleExpiresAt, '2026-10-25T12:00:00.000Z')
assert.equal(window.absoluteExpiresAt, '2026-10-25T12:00:00.000Z')
})
test('滑动续期目标不得超过绝对上限', () => {
assert.equal(
resolveSlidingSessionExpiresAt({
now: '2026-10-25T00:00:00.000Z',
idleHours: 24,
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
}),
'2026-10-26T00:00:00.000Z',
)
assert.equal(
resolveSlidingSessionExpiresAt({
now: '2026-10-31T18:00:00.000Z',
idleHours: 24,
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
}),
'2026-11-01T00:00:00.000Z',
)
})
test('绝对上限缺失或非法时仍返回 now + 空闲窗口', () => {
assert.equal(
resolveSlidingSessionExpiresAt({
now: '2026-10-25T00:00:00.000Z',
idleHours: 24,
absoluteExpiresAt: 'invalid',
}),
'2026-10-26T00:00:00.000Z',
)
})
test('会话续期响应头同时回传空闲到期与绝对到期', () => {
const headers = new Map<string, string>()
setSessionExpiryHeaders(
{
setHeader(name, value) {
headers.set(name, value)
},
},
{
expiresAt: '2026-10-26T00:00:00.000Z',
absoluteExpiresAt: '2026-11-01T00:00:00.000Z',
},
)
assert.equal(headers.get('X-Session-Expires-At'), '2026-10-26T00:00:00.000Z')
assert.equal(headers.get('X-Session-Absolute-Expires-At'), '2026-11-01T00:00:00.000Z')
})
+41
View File
@@ -0,0 +1,41 @@
import { addHours } from './time.js'
export type SessionExpiryWindow = {
/** 空闲窗口:每次访问可滑动续期到的目标时间。 */
idleExpiresAt: string
/** 绝对上限:从登录起算,任何续期都不能突破。 */
absoluteExpiresAt: string
}
function normalizeHours(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback
}
/** 登录时计算空闲到期与绝对到期;绝对上限小于空闲窗口时以绝对上限为准。 */
export function resolveSessionExpiryWindow(input: {
now: string
idleHours: number
absoluteHours: number
}): SessionExpiryWindow {
const requestedIdleHours = normalizeHours(input.idleHours, 24)
const absoluteHours = normalizeHours(input.absoluteHours, requestedIdleHours)
const idleHours = Math.min(requestedIdleHours, absoluteHours)
return {
idleExpiresAt: addHours(input.now, idleHours),
absoluteExpiresAt: addHours(input.now, absoluteHours),
}
}
/** 滑动续期目标:now + 空闲窗口,但不得超过 Token 里的绝对上限。 */
export function resolveSlidingSessionExpiresAt(input: {
now: string
idleHours: number
absoluteExpiresAt: string
}): string {
const idleHours = normalizeHours(input.idleHours, 24)
const target = addHours(input.now, idleHours)
const absoluteMs = Date.parse(String(input.absoluteExpiresAt || ''))
if (!Number.isFinite(absoluteMs)) return target
return Date.parse(target) <= absoluteMs ? target : new Date(absoluteMs).toISOString()
}