dorfteich/apps/api/src/auth/sessions.service.ts
Claude Fable 5 bed9fc9307 Add authentication: signup, verification, sessions, password reset
AuthModule implements the M1 core as one coherent unit:

Signup (#13): POST signup/verify-email/resend-verification with shared
Zod validation (field-level error details), double opt-in via hashed
single-use tokens (24h, superseding reissue), registration_mode
enforcement, and per-IP rate limits.

Sessions (#14): opaque 32-byte cookie tokens stored as SHA-256 row
ids, sliding 30-day expiry (refresh at most hourly), global AuthGuard
with @Public() opt-out attaching the user to every request, CSRF
origin check on mutating requests, per-account login backoff (5/15min,
reset on success), generic 401 for wrong-vs-unknown credentials,
logout with immediate invalidation, GET /auth/me.

Reset (#15): forgot-password without account enumeration, one-hour
single-use tokens, reset destroys all existing sessions.

A 14-case supertest e2e suite drives every flow against the test
database, reading verification/reset links from the mail outbox.

Closes #13
Closes #14
Closes #15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 05:22:31 +02:00

99 lines
3.3 KiB
TypeScript

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<string> {
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<ValidatedSession | null> {
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<void> {
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
}
async destroyById(sessionId: string, userId: string): Promise<boolean> {
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<void> {
await this.prisma.session.deleteMany({
where: { userId, ...(exceptSessionId ? { id: { not: exceptSessionId } } : {}) },
});
}
listForUser(userId: string): Promise<Session[]> {
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;
}