feat(identity): F-005 register/login/logout with argon2 sessions and rate limiting

- Hexagonal identity module: domain ports, use cases, argon2id hasher, pg repos
- Migration 002_identity: identity_users + identity_sessions (token hash only)
- Opaque 512-bit session tokens; DB stores SHA-256 hash; 7-day TTL in SQL
- Cookie HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax
- LoginRateLimiter: 10 failures -> 429 + Retry-After, 15-min cooldown
- Anti-enumeration: identical generic 401 + dummy-hash timing equalization
- buildApp gains optional pool/cookieSecure; foundation-only app preserved
- 47 unit + 14 integration tests; live smoke covers all acceptance criteria
This commit is contained in:
rikrdo
2026-08-14 22:58:32 +02:00
parent 4851692031
commit 75293f39bc
46 changed files with 1734 additions and 54 deletions

View File

@@ -0,0 +1,125 @@
/**
* Identity API adapters. Thin HTTP layer: validates input (parseJson hook),
* calls use cases, maps domain errors to the shared error envelope.
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { z } from 'zod';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
import type pg from 'pg';
import { RegisterUser } from '../application/register-user.js';
import { Login } from '../application/login.js';
import { Logout } from '../application/logout.js';
import {
InMemoryLoginRateLimiter,
type LoginRateLimiter,
} from '../application/login-rate-limiter.js';
import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.js';
import { PgUserRepository } from '../infrastructure/pg-user-repository.js';
import { PgSessionRepository } from '../infrastructure/pg-session-repository.js';
import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js';
import type { PasswordHasher } from '../domain/ports.js';
import {
EmailAlreadyRegisteredError,
InvalidCredentialsError,
RateLimitedError,
} from '../domain/errors.js';
import { SESSION_TTL_MS } from '../domain/session.js';
export const SESSION_COOKIE_NAME = 'mdv_session';
export interface IdentityRoutesDeps {
pool: pg.Pool;
/** Secure cookie flag (config-driven; default true). */
cookieSecure?: boolean;
/** Test seams; production uses defaults. */
hasher?: PasswordHasher;
rateLimiter?: LoginRateLimiter;
}
const credentialsSchema = z.object({
email: z.email(),
password: z.string().min(8).max(128),
});
export async function registerIdentityRoutes(
app: FastifyInstance,
deps: IdentityRoutesDeps,
): Promise<void> {
await app.register(fastifyCookie);
const cookieSecure = deps.cookieSecure ?? true;
const hasher = deps.hasher ?? new Argon2PasswordHasher();
const users = new PgUserRepository(deps.pool);
const sessions = new PgSessionRepository(deps.pool);
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
const registerUser = new RegisterUser(users, hasher);
const login = new Login({
users,
sessions,
hasher,
rateLimiter,
generateToken: generateSessionToken,
hashToken: hashSessionToken,
});
const logout = new Logout(sessions, hashSessionToken);
app.post('/auth/register', async (request, reply) => {
const input = parseJson(credentialsSchema, request.body);
try {
const user = await registerUser.execute(input);
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
} catch (error) {
if (error instanceof EmailAlreadyRegisteredError) {
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
}
throw error;
}
});
app.post('/auth/login', async (request, reply) => {
const input = parseJson(credentialsSchema, request.body);
try {
const result = await login.execute(input);
setSessionCookie(reply, result.token, cookieSecure);
return reply.code(200).send({ id: result.user.id, email: result.user.email });
} catch (error) {
if (error instanceof RateLimitedError) {
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
}
if (error instanceof InvalidCredentialsError) {
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
}
throw error;
}
});
app.post('/auth/logout', async (request, reply) => {
const token = request.cookies[SESSION_COOKIE_NAME];
await logout.execute(token);
clearSessionCookie(reply, cookieSecure);
return reply.code(204).send();
});
}
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {
void reply.setCookie(SESSION_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure,
maxAge: Math.floor(SESSION_TTL_MS / 1000),
});
}
function clearSessionCookie(reply: FastifyReply, secure: boolean): void {
void reply.clearCookie(SESSION_COOKIE_NAME, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure,
});
}