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

@@ -7,7 +7,9 @@ import { performance } from 'node:perf_hooks';
import Fastify, { type FastifyInstance } from 'fastify';
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type pg from 'pg';
import { registerHealthRoutes } from '../modules/health/index.js';
import { registerIdentityRoutes } from '../modules/identity/index.js';
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
import { AppError, errorEnvelope } from '../shared/errors.js';
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
@@ -26,6 +28,10 @@ export interface BuildAppDeps {
logger?: Logger;
/** Feature flags. Default: empty store, every flag OFF (fail-safe). */
flags?: FeatureFlagProvider;
/** Database pool. When present, DB-backed modules (identity) are wired. */
pool?: pg.Pool;
/** Secure cookie flag forwarded to identity routes. */
cookieSecure?: boolean;
}
function generateRequestId(raw: IncomingMessage): string {
@@ -107,5 +113,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
await registerHealthRoutes(instance);
});
if (deps.pool) {
await app.register(async (instance) => {
await registerIdentityRoutes(instance, {
pool: deps.pool as pg.Pool,
cookieSecure: deps.cookieSecure,
});
});
}
return app;
}

View File

@@ -0,0 +1,235 @@
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
interface ErrorBody {
error: { statusCode: number; code: string; message: string };
requestId: string;
}
function parseSetCookie(header: string | string[] | undefined): {
value: string;
attrs: string;
} {
const raw = Array.isArray(header) ? header[0] : header;
expect(raw).toBeDefined();
const [pair, ...rest] = (raw as string).split(';');
const eq = (pair as string).indexOf('=');
return { value: (pair as string).slice(eq + 1), attrs: rest.join(';').toLowerCase() };
}
describe.skipIf(!hasDb)('identity flows (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
const credentials = {
email: 'ana@example.com',
password: 'correct horse battery staple',
};
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('register stores an argon2 hash, never the plaintext (AC3)', async () => {
const response = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: credentials,
});
expect(response.statusCode).toBe(201);
const body = response.json() as { id: string; email: string; createdAt: string };
expect(body.id).toMatch(/^[0-9a-f-]{36}$/);
expect(body.email).toBe(credentials.email);
const row = await pool.query('SELECT email, password_hash FROM identity_users');
expect(row.rowCount).toBe(1);
const stored = row.rows[0] as { email: string; password_hash: string };
expect(stored.password_hash.startsWith('$argon2id$')).toBe(true);
expect(stored.password_hash).not.toContain(credentials.password);
expect(JSON.stringify(stored)).not.toContain(credentials.password);
});
it('register duplicate email -> 409', async () => {
const response = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: { ...credentials, password: 'another-valid-password' },
});
expect(response.statusCode).toBe(409);
const body = response.json() as ErrorBody;
expect(body.error.code).toBe('EMAIL_ALREADY_REGISTERED');
});
it('register invalid payload -> 400 VALIDATION_ERROR', async () => {
const response = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: { email: 'not-an-email', password: 'short' },
});
expect(response.statusCode).toBe(400);
const body = response.json() as ErrorBody;
expect(body.error.code).toBe('VALIDATION_ERROR');
});
it('login with valid credentials -> 200 + secure HttpOnly SameSite cookie (AC1, AC5)', async () => {
const response = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: credentials,
});
expect(response.statusCode).toBe(200);
const body = response.json() as { id: string; email: string };
expect(body.email).toBe(credentials.email);
const cookieHeader = response.headers['set-cookie'];
const raw = Array.isArray(cookieHeader) ? cookieHeader[0] : cookieHeader;
expect(typeof raw).toBe('string');
expect(raw).toContain(`${SESSION_COOKIE_NAME}=`);
expect(raw?.toLowerCase()).toContain('httponly');
expect(raw?.toLowerCase()).toContain('secure');
expect(raw?.toLowerCase()).toContain('samesite=lax');
expect(raw?.toLowerCase()).toContain('max-age=');
const sessionRow = await pool.query(
'SELECT token_hash, expires_at, revoked_at FROM identity_sessions',
);
expect(sessionRow.rowCount).toBe(1);
const session = sessionRow.rows[0] as { token_hash: string; revoked_at: Date | null };
expect(session.token_hash).toMatch(/^[0-9a-f]{64}$/);
expect(session.revoked_at).toBeNull();
});
it('wrong password and unknown email return identical 401 (AC2, no enumeration)', async () => {
const wrongPassword = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: { email: credentials.email, password: 'definitely-wrong-password' },
});
const unknownEmail = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: { email: 'ghost@example.com', password: 'definitely-wrong-password' },
});
expect(wrongPassword.statusCode).toBe(401);
expect(unknownEmail.statusCode).toBe(401);
const bodyA = wrongPassword.json() as ErrorBody;
const bodyB = unknownEmail.json() as ErrorBody;
expect(bodyA.error).toEqual(bodyB.error);
expect(bodyA.error.code).toBe('INVALID_CREDENTIALS');
expect(bodyA.error.message).toBe('Invalid credentials');
});
it('logout revokes the session and is idempotent', async () => {
const loginResponse = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: credentials,
});
expect(loginResponse.statusCode).toBe(200);
const cookie = parseSetCookie(loginResponse.headers['set-cookie']);
const logoutResponse = await app.inject({
method: 'POST',
url: '/auth/logout',
cookies: { [SESSION_COOKIE_NAME]: cookie.value },
});
expect(logoutResponse.statusCode).toBe(204);
const cleared = logoutResponse.headers['set-cookie'];
const clearedRaw = Array.isArray(cleared) ? cleared[0] : cleared;
expect(clearedRaw?.toLowerCase()).toContain('expires=');
const revoked = await pool.query(
'SELECT count(*)::int AS n FROM identity_sessions WHERE revoked_at IS NOT NULL',
);
expect(revoked.rows[0]?.n).toBeGreaterThanOrEqual(1);
const secondLogout = await app.inject({
method: 'POST',
url: '/auth/logout',
cookies: { [SESSION_COOKIE_NAME]: cookie.value },
});
expect(secondLogout.statusCode).toBe(204);
});
it('10 failed logins in a row -> next attempt gets 429 with Retry-After (AC4)', async () => {
const target = { email: 'locked@example.com', password: 'some-valid-password-1' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: target,
});
expect(registered.statusCode).toBe(201);
for (let i = 0; i < 10; i += 1) {
const attempt = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: { email: target.email, password: 'wrong-password-attempt' },
});
expect(attempt.statusCode).toBe(401);
}
const blocked = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: target,
});
expect(blocked.statusCode).toBe(429);
const body = blocked.json() as ErrorBody;
expect(body.error.code).toBe('TOO_MANY_ATTEMPTS');
const retryAfter = Number(blocked.headers['retry-after']);
expect(retryAfter).toBeGreaterThan(0);
});
it('foundation-only app (no pool) keeps /auth routes unregistered', async () => {
const bare = await buildApp({ logger: silentLogger() });
const response = await bare.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: credentials,
});
expect(response.statusCode).toBe(404);
await bare.close();
});
});