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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user