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

View File

@@ -0,0 +1,70 @@
/**
* Login rate limiting. Deployment and activation of this guard are separate
* from the auth logic itself: the use case only knows the interface.
*/
export type RateLimitDecision = { allowed: true } | { allowed: false; retryAfterMs: number };
export interface LoginRateLimiter {
/** Called before attempting authentication. */
consume(key: string): RateLimitDecision;
/** Called after a failed authentication attempt. */
recordFailure(key: string): void;
/** Called after a successful login. */
reset(key: string): void;
}
interface Entry {
failures: number;
lockedUntil: number;
}
export interface InMemoryRateLimiterOptions {
maxFailures?: number;
cooldownMs?: number;
now?: () => number;
}
/**
* Per-key consecutive-failure limiter. In-memory per instance: acceptable for
* the single-process monolith; swap for a Redis-backed impl behind the same
* interface when horizontal scaling arrives.
*/
export class InMemoryLoginRateLimiter implements LoginRateLimiter {
private readonly maxFailures: number;
private readonly cooldownMs: number;
private readonly now: () => number;
private readonly entries = new Map<string, Entry>();
constructor(options: InMemoryRateLimiterOptions = {}) {
this.maxFailures = options.maxFailures ?? 10;
this.cooldownMs = options.cooldownMs ?? 15 * 60 * 1000;
this.now = options.now ?? Date.now;
}
consume(key: string): RateLimitDecision {
const entry = this.entries.get(key);
if (!entry) {
return { allowed: true };
}
if (entry.lockedUntil > this.now()) {
return { allowed: false, retryAfterMs: entry.lockedUntil - this.now() };
}
if (entry.lockedUntil !== 0 && entry.lockedUntil <= this.now()) {
// Cooldown expired: start clean.
this.entries.delete(key);
}
return { allowed: true };
}
recordFailure(key: string): void {
const entry = this.entries.get(key);
const failures = (entry?.failures ?? 0) + 1;
const lockedUntil = failures >= this.maxFailures ? this.now() + this.cooldownMs : 0;
this.entries.set(key, { failures, lockedUntil });
}
reset(key: string): void {
this.entries.delete(key);
}
}

View File

@@ -0,0 +1,73 @@
/**
* Login use case. Rate limit first, then credential check with timing
* equalization, then session creation. Server is the only authority.
*/
import type { PasswordHasher, SessionRepository, UserRepository } from '../domain/ports.js';
import type { User } from '../domain/user.js';
import { normalizeEmail } from '../domain/user.js';
import { sessionExpiry } from '../domain/session.js';
import { InvalidCredentialsError, RateLimitedError } from '../domain/errors.js';
import type { LoginRateLimiter } from './login-rate-limiter.js';
export interface LoginInput {
email: string;
password: string;
}
export interface LoginResult {
user: User;
/** Opaque raw token; only its hash is persisted. */
token: string;
tokenHash: string;
expiresAt: Date;
}
export interface LoginDeps {
users: UserRepository;
sessions: SessionRepository;
hasher: PasswordHasher;
rateLimiter: LoginRateLimiter;
generateToken: () => string;
hashToken: (token: string) => string;
}
export class Login {
private dummyHashPromise: Promise<string> | undefined;
constructor(private readonly deps: LoginDeps) {}
async execute(input: LoginInput): Promise<LoginResult> {
const email = normalizeEmail(input.email);
const decision = this.deps.rateLimiter.consume(email);
if (!decision.allowed) {
throw new RateLimitedError(decision.retryAfterMs);
}
const record = await this.deps.users.findByEmail(email);
// Timing equalization: unknown email still pays one argon2 verify so
// response timing does not reveal whether the account exists.
const hashToCheck = record?.passwordHash ?? (await this.dummyHash());
const valid = await this.deps.hasher.verify(hashToCheck, input.password);
if (!record || !valid) {
this.deps.rateLimiter.recordFailure(email);
throw new InvalidCredentialsError();
}
this.deps.rateLimiter.reset(email);
const token = this.deps.generateToken();
const tokenHash = this.deps.hashToken(token);
const expiresAt = sessionExpiry();
await this.deps.sessions.create(record.id, tokenHash, expiresAt);
return { user: record, token, tokenHash, expiresAt };
}
private async dummyHash(): Promise<string> {
this.dummyHashPromise ??= this.deps.hasher.hash('identity-dummy-password');
return this.dummyHashPromise;
}
}

View File

@@ -0,0 +1,19 @@
/**
* Logout use case: revoke the session identified by the token hash.
* Idempotent by design.
*/
import type { SessionRepository } from '../domain/ports.js';
export class Logout {
constructor(
private readonly sessions: SessionRepository,
private readonly hashToken: (token: string) => string,
) {}
async execute(rawToken: string | undefined): Promise<void> {
if (!rawToken) {
return;
}
await this.sessions.revokeByTokenHash(this.hashToken(rawToken));
}
}

View File

@@ -0,0 +1,24 @@
/**
* RegisterUser use case. Orchestrates domain + ports; knows no HTTP.
*/
import type { PasswordHasher, UserRepository } from '../domain/ports.js';
import type { User } from '../domain/user.js';
import { normalizeEmail } from '../domain/user.js';
export interface RegisterInput {
email: string;
password: string;
}
export class RegisterUser {
constructor(
private readonly users: UserRepository,
private readonly hasher: PasswordHasher,
) {}
async execute(input: RegisterInput): Promise<User> {
const email = normalizeEmail(input.email);
const passwordHash = await this.hasher.hash(input.password);
return this.users.create({ email, passwordHash });
}
}

View File

@@ -0,0 +1,25 @@
/**
* Identity domain errors. The API layer maps these to HTTP; messages are
* deliberately generic where leakage matters.
*/
export class InvalidCredentialsError extends Error {
constructor() {
super('Invalid credentials');
this.name = 'InvalidCredentialsError';
}
}
export class EmailAlreadyRegisteredError extends Error {
constructor() {
super('Email already registered');
this.name = 'EmailAlreadyRegisteredError';
}
}
export class RateLimitedError extends Error {
constructor(public readonly retryAfterMs: number) {
super('Too many attempts');
this.name = 'RateLimitedError';
}
}

View File

@@ -0,0 +1,22 @@
/**
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
*/
import type { NewUser, User } from './user.js';
import type { Session } from './session.js';
export interface PasswordHasher {
hash(plain: string): Promise<string>;
verify(hash: string, plain: string): Promise<boolean>;
}
export interface UserRepository {
create(user: NewUser): Promise<User>;
findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>;
}
export interface SessionRepository {
/** Stores only the token hash, never the raw token. */
create(userId: string, tokenHash: string, expiresAt: Date): Promise<Session>;
/** Revokes by token hash. Returns true when a live session was revoked. */
revokeByTokenHash(tokenHash: string): Promise<boolean>;
}

View File

@@ -0,0 +1,19 @@
/**
* Session domain model. The raw token never touches persistence:
* only its hash is stored.
*/
export interface Session {
id: string;
userId: string;
createdAt: Date;
expiresAt: Date;
revokedAt: Date | null;
}
/** Session lifetime: 7 days. */
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export function sessionExpiry(now: Date = new Date()): Date {
return new Date(now.getTime() + SESSION_TTL_MS);
}

View File

@@ -0,0 +1,19 @@
/**
* Identity domain. Pure types and rules: no framework, no infrastructure.
*/
export interface User {
id: string;
email: string;
createdAt: Date;
}
export interface NewUser {
email: string;
passwordHash: string;
}
/** Canonical form: trimmed + lowercased. citext backs uniqueness in the DB. */
export function normalizeEmail(raw: string): string {
return raw.trim().toLowerCase();
}

View File

@@ -0,0 +1,9 @@
/**
* Public API of the identity module. Everything the module exposes to the
* outside world goes through this file.
*/
export {
registerIdentityRoutes,
SESSION_COOKIE_NAME,
type IdentityRoutesDeps,
} from './api/identity.routes.js';

View File

@@ -0,0 +1,28 @@
/**
* Argon2id password hasher. OWASP 2024 baseline parameters.
* Output is a self-describing PHC string; verification is constant-config.
*/
import argon2 from 'argon2';
import type { PasswordHasher } from '../domain/ports.js';
const OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
} as const;
export class Argon2PasswordHasher implements PasswordHasher {
async hash(plain: string): Promise<string> {
return argon2.hash(plain, OPTIONS);
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
// Malformed hash or verification failure: never throw upward.
return false;
}
}
}

View File

@@ -0,0 +1,53 @@
/**
* PostgreSQL SessionRepository. Stores token hashes only.
* Validity (not expired, not revoked) is enforced in the SQL itself.
*/
import type pg from 'pg';
import type { SessionRepository } from '../domain/ports.js';
import type { Session } from '../domain/session.js';
interface SessionRow {
id: string;
user_id: string;
created_at: Date;
expires_at: Date;
revoked_at: Date | null;
}
export class PgSessionRepository implements SessionRepository {
constructor(private readonly pool: pg.Pool) {}
async create(userId: string, tokenHash: string, expiresAt: Date): Promise<Session> {
const result = await this.pool.query<SessionRow>(
`INSERT INTO identity_sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
RETURNING id, user_id, created_at, expires_at, revoked_at`,
[userId, tokenHash, expiresAt],
);
const row = result.rows[0];
if (!row) {
throw new Error('identity_sessions INSERT returned no row');
}
return toSession(row);
}
async revokeByTokenHash(tokenHash: string): Promise<boolean> {
const result = await this.pool.query(
`UPDATE identity_sessions
SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`,
[tokenHash],
);
return (result.rowCount ?? 0) > 0;
}
}
function toSession(row: SessionRow): Session {
return {
id: row.id,
userId: row.user_id,
createdAt: row.created_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
};
}

View File

@@ -0,0 +1,65 @@
/**
* PostgreSQL UserRepository. Parameterized queries only.
* Unique email is enforced by the DB (citext UNIQUE) — race-safe.
*/
import type pg from 'pg';
import type { UserRepository } from '../domain/ports.js';
import type { NewUser, User } from '../domain/user.js';
import { EmailAlreadyRegisteredError } from '../domain/errors.js';
interface UserRow {
id: string;
email: string;
password_hash: string;
created_at: Date;
}
const UNIQUE_VIOLATION = '23505';
export class PgUserRepository implements UserRepository {
constructor(private readonly pool: pg.Pool) {}
async create(user: NewUser): Promise<User> {
try {
const result = await this.pool.query<UserRow>(
`INSERT INTO identity_users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, created_at`,
[user.email, user.passwordHash],
);
const row = result.rows[0];
if (!row) {
throw new Error('identity_users INSERT returned no row');
}
return { id: row.id, email: row.email, createdAt: row.created_at };
} catch (error) {
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
throw new EmailAlreadyRegisteredError();
}
throw error;
}
}
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
const result = await this.pool.query<UserRow>(
`SELECT id, email, password_hash, created_at
FROM identity_users
WHERE email = $1`,
[email],
);
const row = result.rows[0];
if (!row) {
return undefined;
}
return {
id: row.id,
email: row.email,
createdAt: row.created_at,
passwordHash: row.password_hash,
};
}
}
function isPgError(error: unknown): error is { code: string } {
return typeof error === 'object' && error !== null && 'code' in error;
}

View File

@@ -0,0 +1,13 @@
/**
* Opaque session tokens. Raw token goes to the cookie; only the SHA-256
* hash is persisted, so a DB leak yields no usable sessions.
*/
import { createHash, randomBytes } from 'node:crypto';
export function generateSessionToken(): string {
return randomBytes(64).toString('base64url');
}
export function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { InMemoryLoginRateLimiter } from '../application/login-rate-limiter.js';
describe('InMemoryLoginRateLimiter', () => {
it('allows attempts below the failure threshold', () => {
const limiter = new InMemoryLoginRateLimiter({ maxFailures: 3 });
expect(limiter.consume('a@b.c')).toEqual({ allowed: true });
limiter.recordFailure('a@b.c');
limiter.recordFailure('a@b.c');
expect(limiter.consume('a@b.c')).toEqual({ allowed: true });
});
it('blocks with retryAfterMs after reaching the threshold', () => {
let now = 1_000_000;
const limiter = new InMemoryLoginRateLimiter({
maxFailures: 10,
cooldownMs: 900_000,
now: () => now,
});
for (let i = 0; i < 10; i += 1) {
expect(limiter.consume('a@b.c').allowed).toBe(true);
limiter.recordFailure('a@b.c');
}
const blocked = limiter.consume('a@b.c');
expect(blocked.allowed).toBe(false);
if (!blocked.allowed) {
expect(blocked.retryAfterMs).toBe(900_000);
}
// Time passes but not enough.
now += 60_000;
expect(limiter.consume('a@b.c').allowed).toBe(false);
});
it('re-allows attempts once the cooldown expires', () => {
let now = 1_000_000;
const limiter = new InMemoryLoginRateLimiter({
maxFailures: 2,
cooldownMs: 1_000,
now: () => now,
});
limiter.recordFailure('a@b.c');
limiter.recordFailure('a@b.c');
expect(limiter.consume('a@b.c').allowed).toBe(false);
now += 1_001;
expect(limiter.consume('a@b.c')).toEqual({ allowed: true });
});
it('resets the counter on successful login', () => {
const limiter = new InMemoryLoginRateLimiter({ maxFailures: 2 });
limiter.recordFailure('a@b.c');
limiter.reset('a@b.c');
limiter.recordFailure('a@b.c');
// Only one failure since reset: still allowed.
expect(limiter.consume('a@b.c')).toEqual({ allowed: true });
});
it('tracks keys independently', () => {
const limiter = new InMemoryLoginRateLimiter({ maxFailures: 1 });
limiter.recordFailure('a@b.c');
expect(limiter.consume('a@b.c').allowed).toBe(false);
expect(limiter.consume('other@b.c')).toEqual({ allowed: true });
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js';
describe('session tokens', () => {
it('generates unique URL-safe tokens', () => {
const tokens = new Set<string>();
for (let i = 0; i < 100; i += 1) {
tokens.add(generateSessionToken());
}
expect(tokens.size).toBe(100);
for (const token of tokens) {
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(token.length).toBeGreaterThanOrEqual(80);
}
});
it('hashes deterministically to hex sha256, never the raw token', () => {
const token = generateSessionToken();
const hash1 = hashSessionToken(token);
const hash2 = hashSessionToken(token);
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
expect(hash1).not.toBe(token);
});
it('different tokens produce different hashes', () => {
const a = generateSessionToken();
const b = generateSessionToken();
expect(hashSessionToken(a)).not.toBe(hashSessionToken(b));
});
});