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>
57 lines
1.9 KiB
TypeScript
57 lines
1.9 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto';
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import { AuthTokenPurpose } from '@prisma/client';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
/**
|
|
* Single-use tokens for e-mail flows (ADR 0007). Only the SHA-256 hash is
|
|
* stored; consuming marks the row instead of deleting it so replay
|
|
* attempts remain visible in the data.
|
|
*/
|
|
@Injectable()
|
|
export class AuthTokensService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async issue(userId: string, purpose: AuthTokenPurpose, ttlSeconds: number): Promise<string> {
|
|
const raw = randomBytes(32).toString('base64url');
|
|
// Previous unconsumed tokens for the same purpose die with the new
|
|
// one — only the latest link in the inbox works.
|
|
await this.prisma.authToken.updateMany({
|
|
where: { userId, purpose, consumedAt: null },
|
|
data: { consumedAt: new Date() },
|
|
});
|
|
await this.prisma.authToken.create({
|
|
data: {
|
|
tokenHash: hashToken(raw),
|
|
userId,
|
|
purpose,
|
|
expiresAt: new Date(Date.now() + ttlSeconds * 1000),
|
|
},
|
|
});
|
|
return raw;
|
|
}
|
|
|
|
/** Returns the owning user id, or null for unknown/expired/reused tokens. */
|
|
async consume(raw: string, purpose: AuthTokenPurpose): Promise<string | null> {
|
|
// Atomic claim: only one request can flip consumedAt from null.
|
|
const result = await this.prisma.authToken.updateMany({
|
|
where: {
|
|
tokenHash: hashToken(raw),
|
|
purpose,
|
|
consumedAt: null,
|
|
expiresAt: { gt: new Date() },
|
|
},
|
|
data: { consumedAt: new Date() },
|
|
});
|
|
if (result.count === 0) return null;
|
|
const row = await this.prisma.authToken.findUnique({ where: { tokenHash: hashToken(raw) } });
|
|
return row?.userId ?? null;
|
|
}
|
|
}
|
|
|
|
function hashToken(raw: string): string {
|
|
return createHash('sha256').update(raw).digest('hex');
|
|
}
|