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