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>
99 lines
3.3 KiB
TypeScript
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;
|
|
}
|