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:
@@ -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);
|
||||
}
|
||||
}
|
||||
73
project/src/modules/identity/application/login.ts
Normal file
73
project/src/modules/identity/application/login.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
project/src/modules/identity/application/logout.ts
Normal file
19
project/src/modules/identity/application/logout.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
24
project/src/modules/identity/application/register-user.ts
Normal file
24
project/src/modules/identity/application/register-user.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user