feat: harden sessions and track downstream acknowledgements

This commit is contained in:
hectorzhao
2026-07-14 14:18:43 +08:00
parent 3d37adcc9f
commit 8c03663f24
43 changed files with 1733 additions and 150 deletions
+202
View File
@@ -0,0 +1,202 @@
import { createHash, randomBytes } from 'node:crypto';
import { Injectable, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
import IORedis from 'ioredis';
export type SessionPortal = 'admin' | 'client';
export type AuthSessionRecord = {
userId: string;
portal: SessionPortal;
sessionVersion: number;
createdAt: number;
lastActivityAt: number;
lastAuthenticatedAt: number;
absoluteExpiresAt: number;
lockedAt?: number;
};
export type SessionValidationResult =
| { status: 'active'; record: AuthSessionRecord }
| { status: 'locked'; record: AuthSessionRecord; newlyLocked?: boolean }
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
const SESSION_PREFIX = 'cmpp:auth:session:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
@Injectable()
export class SessionService implements OnModuleDestroy {
private redis?: IORedis;
onModuleDestroy() {
this.redis?.disconnect();
}
async create(userId: string, portal: SessionPortal, sessionVersion: number) {
const now = Date.now();
const token = randomBytes(32).toString('base64url');
const record: AuthSessionRecord = {
userId,
portal,
sessionVersion,
createdAt: now,
lastActivityAt: now,
lastAuthenticatedAt: now,
absoluteExpiresAt: now + this.absoluteTimeoutMs,
};
await this.write(token, record);
return { token, record };
}
async validate(token: string, markActivity: boolean): Promise<SessionValidationResult> {
const record = await this.read(token);
if (!record) return { status: 'expired', code: 'SESSION_INVALID' };
const now = Date.now();
if (now >= record.absoluteExpiresAt) {
await this.remove(token);
return { status: 'expired', code: 'SESSION_ABSOLUTE_TIMEOUT' };
}
if (record.lockedAt && now - record.lockedAt >= this.lockRecoveryMs) {
await this.remove(token);
return { status: 'expired', code: 'SESSION_LOCK_TIMEOUT' };
}
if (!record.lockedAt && now - record.lastActivityAt >= this.idleTimeoutMs(record.portal)) {
record.lockedAt = now;
await this.write(token, record);
return { status: 'locked', record, newlyLocked: true };
}
if (record.lockedAt) return { status: 'locked', record, newlyLocked: false };
if (markActivity && now - record.lastActivityAt >= 30_000) {
record.lastActivityAt = now;
await this.write(token, record);
}
return { status: 'active', record };
}
async lock(token: string) {
const record = await this.read(token);
if (!record) return null;
record.lockedAt = record.lockedAt ?? Date.now();
await this.write(token, record);
return record;
}
async touch(token: string) {
const result = await this.validate(token, false);
if (result.status !== 'active') return result;
result.record.lastActivityAt = Date.now();
await this.write(token, result.record);
return result;
}
async unlock(token: string) {
const result = await this.validate(token, false);
if (result.status === 'expired') return result;
const now = Date.now();
const nextToken = randomBytes(32).toString('base64url');
const record = {
...result.record,
lockedAt: undefined,
lastActivityAt: now,
lastAuthenticatedAt: now,
};
await this.write(nextToken, record);
await this.remove(token);
return { status: 'active' as const, token: nextToken, record };
}
async markReauthenticated(token: string) {
const result = await this.validate(token, false);
if (result.status !== 'active') return result;
result.record.lastAuthenticatedAt = Date.now();
await this.write(token, result.record);
return result;
}
remove(token: string) {
return this.client.del(this.key(token));
}
isRecentlyAuthenticated(record: AuthSessionRecord) {
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
}
publicSession(record: AuthSessionRecord) {
return {
idleTimeoutSeconds: Math.floor(this.idleTimeoutMs(record.portal) / 1000),
lockRecoverySeconds: Math.floor(this.lockRecoveryMs / 1000),
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
lastActivityAt: new Date(record.lastActivityAt).toISOString(),
recentAuthenticationExpiresAt: new Date(record.lastAuthenticatedAt + this.recentAuthenticationMs).toISOString(),
};
}
get cookieSecure() {
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
}
get cookieName() {
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
}
get absoluteTimeoutMs() {
return this.duration('SESSION_ABSOLUTE_TIMEOUT_MS', 12 * 60 * 60 * 1000);
}
get lockRecoveryMs() {
return this.duration('SESSION_LOCK_RECOVERY_MS', 4 * 60 * 60 * 1000);
}
get recentAuthenticationMs() {
return this.duration('SESSION_RECENT_AUTH_MS', 30 * 60 * 1000);
}
idleTimeoutMs(portal: SessionPortal) {
return portal === 'admin'
? this.duration('ADMIN_SESSION_IDLE_TIMEOUT_MS', 60 * 60 * 1000)
: this.duration('CLIENT_SESSION_IDLE_TIMEOUT_MS', 120 * 60 * 1000);
}
private duration(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
private async read(token: string): Promise<AuthSessionRecord | null> {
try {
const value = await this.client.get(this.key(token));
return value ? JSON.parse(value) as AuthSessionRecord : null;
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
}
private async write(token: string, record: AuthSessionRecord) {
const ttl = record.absoluteExpiresAt - Date.now();
if (ttl <= 0) {
await this.remove(token);
return;
}
try {
await this.client.set(this.key(token), JSON.stringify(record), 'PX', ttl);
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
}
private key(token: string) {
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
}
private get client() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
enableReadyCheck: true,
maxRetriesPerRequest: 1,
});
}
return this.redis;
}
}