import { createHash, randomBytes } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { Session, User } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms export interface ValidatedSession { session: Session; user: User; } /** * Opaque server-side sessions (ADR 0007). The cookie value is 32 random * bytes; the database stores only its SHA-256 hash as the row id, so a * database leak cannot be replayed as cookies. */ @Injectable() export class SessionsService { constructor(private readonly prisma: PrismaService) {} async create(userId: string, userAgent: string | undefined): Promise { const raw = randomBytes(32).toString('base64url'); await this.prisma.session.create({ data: { id: hashSessionToken(raw), userId, expiresAt: new Date(Date.now() + SESSION_TTL_MS), userAgent: summarizeUserAgent(userAgent), }, }); return raw; } async validate(raw: string): Promise { const session = await this.prisma.session.findUnique({ where: { id: hashSessionToken(raw) }, include: { user: true }, }); if (!session || session.expiresAt <= new Date()) return null; if (session.user.status === 'DISABLED') return null; // Sliding expiration, refreshed at most once per hour. if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) { await this.prisma.session.update({ where: { id: session.id }, data: { lastSeenAt: new Date(), expiresAt: new Date(Date.now() + SESSION_TTL_MS) }, }); } const { user, ...bare } = session; return { session: bare as Session, user }; } async destroyByRawToken(raw: string): Promise { await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } }); } async destroyById(sessionId: string, userId: string): Promise { const result = await this.prisma.session.deleteMany({ where: { id: sessionId, userId }, }); return result.count > 0; } /** Logs the user out everywhere, optionally keeping one session alive. */ async destroyAllForUser(userId: string, exceptSessionId?: string): Promise { await this.prisma.session.deleteMany({ where: { userId, ...(exceptSessionId ? { id: { not: exceptSessionId } } : {}) }, }); } listForUser(userId: string): Promise { return this.prisma.session.findMany({ where: { userId, expiresAt: { gt: new Date() } }, orderBy: { lastSeenAt: 'desc' }, }); } } export function hashSessionToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); } /** Browser + OS, never the raw string (fingerprinting hygiene). */ function summarizeUserAgent(ua: string | undefined): string | null { if (!ua) return null; const browser = ua.match(/(Firefox|Edg|OPR|Chrome|Safari)\/[\d.]+/)?.[1]?.replace('Edg', 'Edge') ?? 'Browser'; const os = ua.match(/\((Windows|Macintosh|X11; Linux|Android|iPhone|iPad)[^)]*\)/)?.[1] ?? ''; const osName = os .replace('Macintosh', 'macOS') .replace('X11; Linux', 'Linux') .replace(/iPhone|iPad/, 'iOS'); return osName ? `${browser} ยท ${osName}` : browser; }