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:'; const CAPTCHA_PREFIX = 'cmpp:auth:captcha:'; const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:'; const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:'; const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:'; const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:'; const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:'; const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:'; const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:'; export const SESSION_COOKIE_NAME = '__Host-cmpp_session'; export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session'; export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session'; export const CLIENT_SESSION_COOKIE_NAME = '__Host-cmpp_client_session'; export const DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME = 'cmpp_admin_session'; export const DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME = 'cmpp_client_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 { 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)); } async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) { try { await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds); } catch { throw new ServiceUnavailableException('验证码服务暂不可用'); } } async consumeCaptcha(captchaId: string) { try { return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`); } catch { throw new ServiceUnavailableException('验证码服务暂不可用'); } } async assertCaptchaRequestAllowed(sourceIp: string) { const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`; try { const count = Number( await this.client.eval( `local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return count`, 1, key, 5 * 60, ), ); return count <= 30; } catch { throw new ServiceUnavailableException('验证码服务暂不可用'); } } async anonymousLoginLockScope(login: string, sourceIp: string) { const accountDigest = this.loginDigest(login); const ipDigest = this.valueDigest(sourceIp); const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`); try { const locks = await this.client.mget( `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`, `${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`, `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`, ); if (locks[0]) return 'account' as const; if (locks[1]) return 'ip' as const; if (locks[2]) return 'pair' as const; return null; } catch { throw new ServiceUnavailableException('登录保护服务暂不可用'); } } async recordAnonymousLoginFailure(login: string, sourceIp: string) { const accountDigest = this.loginDigest(login); const ipDigest = this.valueDigest(sourceIp); const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`); try { const result = await this.client.eval( `local counts = {} for i = 1, 3 do counts[i] = redis.call('INCR', KEYS[i]) if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end end return counts`, 6, `${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`, `${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`, `${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`, `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`, `${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`, `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`, 24 * 60 * 60, 15 * 60, 24 * 60 * 60, 5, 30, 5, ); return (result as number[]).map(Number); } catch { throw new ServiceUnavailableException('登录保护服务暂不可用'); } } async clearAnonymousLoginFailures(login: string, sourceIp: string) { const accountDigest = this.loginDigest(login); const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`); try { await this.client.del( `${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`, `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`, `${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`, `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`, ); } catch { throw new ServiceUnavailableException('登录保护服务暂不可用'); } } 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') ); } cookieName(portal: SessionPortal) { if (this.cookieSecure) return portal === 'admin' ? ADMIN_SESSION_COOKIE_NAME : CLIENT_SESSION_COOKIE_NAME; return portal === 'admin' ? DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME : DEVELOPMENT_CLIENT_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 { 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 loginDigest(login: string) { return this.valueDigest(login.trim().toLocaleLowerCase('en-US')); } private valueDigest(value: string) { return createHash('sha256').update(value.trim()).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; } }