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