329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
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<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));
|
|
}
|
|
|
|
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<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 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;
|
|
}
|
|
}
|