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
@@ -3,12 +3,18 @@ import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import type { WorkerUserRow } from '../../repositories/worker-platform/index.js'
import { createHttpError } from '../../utils/http.js'
import { addHours, nowIso } from '../../utils/time.js'
import { nowIso } from '../../utils/time.js'
import { resolveSessionExpiryWindow } from '../../utils/session-window.js'
import { normalizeSessionVersion } from './worker-normalizers.js'
export function createWorkerSessionToken(worker: WorkerUserRow) {
const issuedAt = nowIso()
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12))
// Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。
const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({
now: issuedAt,
idleHours: Number(runtimeConfig.worker?.sessionIdleTtlHours || 168),
absoluteHours: Number(runtimeConfig.worker?.sessionAbsoluteTtlHours || 720),
})
const payload = {
typ: 'worker',
sid: crypto.randomBytes(12).toString('hex'),
@@ -17,13 +23,14 @@ export function createWorkerSessionToken(worker: WorkerUserRow) {
status: worker.status,
ver: normalizeSessionVersion(worker.session_version),
iat: issuedAt,
exp: expiresAt,
exp: absoluteExpiresAt,
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
return {
sessionId: payload.sid,
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
expiresAt,
expiresAt: idleExpiresAt,
absoluteExpiresAt,
}
}
@@ -186,6 +186,7 @@ function createCredentialSession(worker: WorkerUserRow): WorkerSession {
username: worker.username,
status: worker.status,
expiresAt: '',
absoluteExpiresAt: '',
sessionVersion: normalizeSessionVersion(worker.session_version),
}
}
@@ -2828,8 +2828,12 @@ test('会话 touch 必须同时保留鉴权语义与写入节流', async () => {
src.indexOf('export async function touchWorkerSession'),
src.indexOf('export async function revokeWorkerSession'),
)
// 返回值是鉴权有效性:上层 !(await touch) 会直接 401,不能因为"不写"就返回 false
assert.ok(body.includes('EXISTS (SELECT 1 FROM target) AS valid'), '必须仍然返回会话是否有效')
// 返回值是续期后的空闲到期时间:null 才会被上层判成 401
assert.ok(body.includes('RETURNING ws.expires_at'), '必须返回滑动续期后的 expires_at')
assert.ok(
body.includes('LEAST($3::timestamptz, $4::timestamptz)'),
'滑动续期不能突破 Token 里的绝对上限',
)
assert.ok(body.includes("status = 'active'"), '有效性判定不能丢')
// 写操作必须节流:32 天里被写了 3300 万次
assert.ok(body.includes('WORKER_SESSION_TOUCH_MIN_INTERVAL'), '缺少节流间隔常量')
@@ -5,9 +5,12 @@ import {
revokeWorkerSession,
touchWorkerSession,
} from '../../repositories/worker-platform/index.js'
import { runtimeConfig } from '../../config/runtime.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { resolveSlidingSessionExpiresAt } from '../../utils/session-window.js'
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
import {
ensureWorkerAuthConfigured,
isIgnorableWorkerAuthError,
@@ -102,8 +105,8 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
})
}
const expiresAt = String(payload.exp || '').trim()
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
const absoluteExpiresAt = String(payload.exp || '').trim()
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
throw createHttpError('登录已过期,请重新登录', {
statusCode: 401,
errorCode: 'worker_auth_expired',
@@ -133,8 +136,7 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
!sessionRecord ||
Number(sessionRecord.worker_id) !== Number(worker.id) ||
sessionRecord.status !== 'active' ||
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now() ||
!(await touchWorkerSession(sessionId, nowIso()))
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now()
) {
throw createHttpError('登录设备已被删除,请重新登录', {
statusCode: 401,
@@ -142,12 +144,31 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
})
}
const nowAt = nowIso()
const touchedExpiresAt = await touchWorkerSession({
sessionId,
now: nowAt,
idleExpiresAt: resolveSlidingSessionExpiresAt({
now: nowAt,
idleHours: runtimeConfig.worker?.sessionIdleTtlHours || 168,
absoluteExpiresAt,
}),
absoluteExpiresAt,
})
if (!touchedExpiresAt) {
throw createHttpError('登录设备已被删除,请重新登录', {
statusCode: 401,
errorCode: 'worker_auth_device_revoked',
})
}
const verifiedSession: WorkerSession = {
sessionId,
workerId: Number(worker.id),
username: worker.username,
status: worker.status,
expiresAt,
expiresAt: touchedExpiresAt,
absoluteExpiresAt,
sessionVersion: currentSessionVersion,
}
@@ -200,12 +221,17 @@ export async function deleteWorkerSession(session: WorkerSession, targetSessionI
}
}
export async function getWorkerSessionSummary(token: unknown) {
export async function getWorkerSessionSummary(
token: unknown,
res?: { setHeader(name: string, value: string): unknown },
) {
const session = await verifyWorkerSessionToken(token)
if (res) setSessionExpiryHeaders(res, session)
const worker = await getRequiredWorker(session.workerId)
return {
authenticated: true,
expiresAt: session.expiresAt,
absoluteExpiresAt: session.absoluteExpiresAt,
worker: mapWorkerUser(worker),
}
}
@@ -12,6 +12,7 @@ export type WorkerSession = {
username: string
status: string
expiresAt: string
absoluteExpiresAt: string
sessionVersion: number
}