feat(F-083): completed feature

This commit is contained in:
chattie
2026-08-20 06:08:32 +02:00
parent 23e84efe80
commit 22ad15325f
20 changed files with 1041 additions and 25 deletions

View File

@@ -21,13 +21,24 @@ import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.j
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 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';
export const SESSION_COOKIE_NAME = 'mdv_session';
@@ -40,6 +51,16 @@ export interface IdentityRoutesDeps {
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({
@@ -212,6 +233,137 @@ export async function registerIdentityRoutes(
throw error;
}
});
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 ?? new LoggingPasswordResetMailer();
const buildResetUrl =
pr.buildResetUrl ??
((token: string) => `/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) => {
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;
}
}
/** Logs the reset email to stdout; production should replace with a real provider. */
export class LoggingPasswordResetMailer implements PasswordResetMailer {
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
level: 'info',
msg: 'password_reset_email',
to: input.email,
url: input.resetUrl,
locale: input.locale ?? 'es',
}),
);
}
}
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {