dorfteich/apps/api/src/auth/auth-tokens.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

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');
}