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:
@@ -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;
|
||||
}
|
||||
|
||||
235
project/src/app/tests/identity.itest.ts
Normal file
235
project/src/app/tests/identity.itest.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user