/** * 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 type { FastifySchema } from 'fastify'; import { errorSchema } from '../../../shared/swagger.js'; import { z } from 'zod'; import type { Authenticate } from '../../../shared/auth.js'; 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, PasswordResetMailer, PasswordResetTokenRepository, ResetRateLimiter, } from '../domain/ports.js'; import { EmailAlreadyRegisteredError, InvalidCredentialsError, RateLimitedError, } from '../domain/errors.js'; import { SESSION_TTL_MS } from '../domain/session.js'; import { PgPasswordResetTokenRepository } from '../infrastructure/pg-password-reset-token-repository.js'; import { ConfirmPasswordReset, RequestPasswordReset, } from '../application/password-reset.js'; import { InvalidResetTokenError } from '../domain/password-reset.js'; import { createPasswordResetMailer } from '../infrastructure/smtp-password-reset-mailer.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; /** Session authenticator. Created by createSessionAuthenticator in build-app.ts. */ authenticate?: Authenticate; /** Optional: enables POST /auth/password-reset/request and /confirm. */ passwordReset?: { tokens?: PasswordResetTokenRepository; rateLimiter?: ResetRateLimiter; mailer?: PasswordResetMailer; /** Build absolute reset URL from raw token. Defaults to storefront path. */ buildResetUrl?: (rawToken: string) => string; /** Audit sink (defaults to no-op). */ audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void; }; } const credentialsSchema = z.object({ email: z.email(), password: z.string().min(8).max(128), }); export async function registerIdentityRoutes( app: FastifyInstance, deps: IdentityRoutesDeps, ): Promise { 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); const registerSchema: FastifySchema = { tags: ['Auth'], summary: 'Register new user', description: 'Crea una cuenta de usuario. Por defecto el rol es `customer`.', body: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', format: 'email', maxLength: 255, description: 'Email único' }, password: { type: 'string', minLength: 8, maxLength: 128, description: 'Contraseña (mín. 8 caracteres)', }, }, }, response: { 201: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['customer', 'editor', 'admin'] }, createdAt: { type: 'string', format: 'date-time' }, }, }, 409: errorSchema, }, }; const loginSchema: FastifySchema = { tags: ['Auth'], summary: 'Login', description: 'Inicia sesión y devuelve una cookie de sesión `mdv_session` (válida 7 días).', body: { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', format: 'email', description: 'Email' }, password: { type: 'string', description: 'Contraseña' }, }, }, response: { 200: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['customer', 'editor', 'admin'] }, }, }, 401: errorSchema, 429: errorSchema, }, }; const logoutSchema: FastifySchema = { tags: ['Auth'], summary: 'Logout', description: 'Invalida la sesión actual y elimina la cookie.', response: { 204: { type: 'null' } }, }; const meSchema: FastifySchema = { tags: ['Auth'], summary: 'Current user', description: 'Devuelve el usuario autenticado (cookie de sesión) o `{user: null}` si no hay sesión.', response: { 200: { oneOf: [ { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['customer', 'editor', 'admin'] }, }, }, { type: 'object', properties: { user: { type: 'null' }, }, }, ], }, }, }; app.post('/auth/register', { schema: registerSchema }, 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, role: user.role, 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', { schema: loginSchema }, 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, role: result.user.role }); } 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', { schema: logoutSchema }, async (request, reply) => { const token = request.cookies[SESSION_COOKIE_NAME]; await logout.execute(token); clearSessionCookie(reply, cookieSecure); return reply.code(204).send(); }); app.get('/auth/me', { schema: meSchema }, async (request, reply) => { try { const user = await deps.authenticate!(request); return reply.send({ id: user.id, email: user.email, role: user.role }); } catch (error) { if (error instanceof AppError && error.statusCode === 401) { return reply.send({ user: null }); } throw error; } }); app.patch('/auth/me/password', async (request, reply) => { const user = await deps.authenticate!(request); const input = parseJson( z.object({ currentPassword: z.string().min(1).max(128), newPassword: z.string().min(8).max(128) }), request.body, ); const record = await users.findByEmail(user.email); if (!record || !(await hasher.verify(record.passwordHash, input.currentPassword))) { throw new AppError(400, 'INVALID_CURRENT_PASSWORD', 'La contraseña actual no es válida'); } await users.updateUser(user.id, { passwordHash: await hasher.hash(input.newPassword) }); return reply.send({ ok: true }); }); if (deps.passwordReset) { const pr = deps.passwordReset; const tokens = pr.tokens ?? new PgPasswordResetTokenRepository(deps.pool); const usersRepo = new PgUserRepository(deps.pool); const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter(); const mailer = pr.mailer ?? createPasswordResetMailer(); const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(/\/$/, ''); const buildResetUrl = pr.buildResetUrl ?? ((token: string) => `${publicAppUrl}/cuenta/restablecer?token=${encodeURIComponent(token)}`); const requestReset = new RequestPasswordReset({ users: usersRepo, tokens, rateLimiter, mailer, buildResetUrl, audit: pr.audit, }); const confirmReset = new ConfirmPasswordReset({ tokens, users: usersRepo, hasher, audit: pr.audit, }); const requestSchema: FastifySchema = { tags: ['Auth'], summary: 'Request password reset', description: 'Solicita un enlace de recuperación por email. Siempre responde 200 para evitar enumeración de usuarios.', body: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email', maxLength: 255 }, }, }, response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } }, }; const confirmSchema: FastifySchema = { tags: ['Auth'], summary: 'Confirm password reset', description: 'Acepta un nuevo password usando el token recibido por email.', body: { type: 'object', required: ['token', 'password'], properties: { token: { type: 'string', minLength: 16, maxLength: 128 }, password: { type: 'string', minLength: 8, maxLength: 128 }, }, }, response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } }, 400: errorSchema }, }; app.post( '/auth/password-reset/request', { schema: requestSchema }, async (request, reply) => { if (mailer.assertReady) { try { await mailer.assertReady(); } catch { throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email'); } } else if (mailer.isConfigured && !mailer.isConfigured()) { throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email'); } const input = parseJson( z.object({ email: z.email().max(255) }), request.body, ); const ip = (request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ?? request.ip; const userAgent = request.headers['user-agent']; await requestReset.execute({ email: input.email, ip, userAgent }); return reply.send({ ok: true }); }, ); app.post( '/auth/password-reset/confirm', { schema: confirmSchema }, async (request, reply) => { const input = parseJson( z.object({ token: z.string().min(16).max(128), password: z.string().min(8).max(128), }), request.body, ); try { await confirmReset.execute({ token: input.token, newPassword: input.password }); return reply.send({ ok: true }); } catch (error) { if (error instanceof InvalidResetTokenError) { throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token'); } throw error; } }, ); } } /** Simple in-memory token-bucket rate limiter for the password reset endpoints. */ export class InMemoryResetRateLimiter implements ResetRateLimiter { private buckets = new Map(); constructor( private readonly windowMs = 60 * 60 * 1000, private readonly limit = 5, ) {} async allow(key: string): Promise { const now = Date.now(); const cur = this.buckets.get(key); if (!cur || cur.resetAt <= now) { this.buckets.set(key, { count: 1, resetAt: now + this.windowMs }); return true; } if (cur.count >= this.limit) return false; cur.count += 1; return true; } } 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, }); }