feat(F-083): completed feature
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user