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:
125
project/src/modules/identity/api/identity.routes.ts
Normal file
125
project/src/modules/identity/api/identity.routes.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Identity API adapters. Thin HTTP layer: validates input (parseJson hook),
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { z } from 'zod';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import type pg from 'pg';
|
||||
import { RegisterUser } from '../application/register-user.js';
|
||||
import { Login } from '../application/login.js';
|
||||
import { Logout } from '../application/logout.js';
|
||||
import {
|
||||
InMemoryLoginRateLimiter,
|
||||
type LoginRateLimiter,
|
||||
} from '../application/login-rate-limiter.js';
|
||||
import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.js';
|
||||
import { PgUserRepository } from '../infrastructure/pg-user-repository.js';
|
||||
import { PgSessionRepository } from '../infrastructure/pg-session-repository.js';
|
||||
import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js';
|
||||
import type { PasswordHasher } from '../domain/ports.js';
|
||||
import {
|
||||
EmailAlreadyRegisteredError,
|
||||
InvalidCredentialsError,
|
||||
RateLimitedError,
|
||||
} from '../domain/errors.js';
|
||||
import { SESSION_TTL_MS } from '../domain/session.js';
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'mdv_session';
|
||||
|
||||
export interface IdentityRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
/** Secure cookie flag (config-driven; default true). */
|
||||
cookieSecure?: boolean;
|
||||
/** Test seams; production uses defaults. */
|
||||
hasher?: PasswordHasher;
|
||||
rateLimiter?: LoginRateLimiter;
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function registerIdentityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: IdentityRoutesDeps,
|
||||
): Promise<void> {
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const cookieSecure = deps.cookieSecure ?? true;
|
||||
const hasher = deps.hasher ?? new Argon2PasswordHasher();
|
||||
const users = new PgUserRepository(deps.pool);
|
||||
const sessions = new PgSessionRepository(deps.pool);
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
||||
|
||||
const registerUser = new RegisterUser(users, hasher);
|
||||
const login = new Login({
|
||||
users,
|
||||
sessions,
|
||||
hasher,
|
||||
rateLimiter,
|
||||
generateToken: generateSessionToken,
|
||||
hashToken: hashSessionToken,
|
||||
});
|
||||
const logout = new Logout(sessions, hashSessionToken);
|
||||
|
||||
app.post('/auth/register', async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
return reply.code(201).send({ id: user.id, email: user.email, createdAt: user.createdAt });
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyRegisteredError) {
|
||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setSessionCookie(reply, result.token, cookieSecure);
|
||||
return reply.code(200).send({ id: result.user.id, email: result.user.email });
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
||||
}
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
await logout.execute(token);
|
||||
clearSessionCookie(reply, cookieSecure);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {
|
||||
void reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
maxAge: Math.floor(SESSION_TTL_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookie(reply: FastifyReply, secure: boolean): void {
|
||||
void reply.clearCookie(SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user