396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<string, { count: number; resetAt: number }>();
|
|
constructor(
|
|
private readonly windowMs = 60 * 60 * 1000,
|
|
private readonly limit = 5,
|
|
) {}
|
|
async allow(key: string): Promise<boolean> {
|
|
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,
|
|
});
|
|
}
|