Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 78 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1",
"nodemailer": "^8.0.7",
"nodemon": "^3.1.14",
"pg": "^8.19.0",
"resend": "^6.12.4",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^4.3.6"
Expand Down
23 changes: 9 additions & 14 deletions src/lib/email.service.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT || "587"),
secure: process.env.EMAIL_SECURE === "true",
family: 4, // fuerza IPv4, evita ENETUNREACH en entornos sin IPv6 (Render)
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
import { Resend } from "resend";

export const sendPasswordResetEmail = async (email, token) => {
const resend = new Resend(process.env.RESEND_API_KEY);
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const resetUrl = `${frontendUrl}/restablecer-contrasena/${token}`;
const from = process.env.RESEND_FROM_EMAIL || "OpenMarket <onboarding@resend.dev>";

await transporter.sendMail({
from: `"OpenMarket" <${process.env.EMAIL_USER}>`,
const { error } = await resend.emails.send({
from,
to: email,
subject: "Recuperación de contraseña",
html: `
Expand All @@ -35,4 +26,8 @@ export const sendPasswordResetEmail = async (email, token) => {
</div>
`,
});

if (error) {
throw new Error(`Error al enviar el correo de recuperación: ${error.message}`);
}
};
30 changes: 30 additions & 0 deletions src/middlewares/rateLimiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { rateLimit } from 'express-rate-limit';

const jsonResponse = (message) => ({
success: false,
message,
});

// Login: 5 intentos por IP cada 15 minutos
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: () => process.env.NODE_ENV === 'test',
message: jsonResponse(
'Demasiados intentos de inicio de sesión. Esperá 15 minutos e intentá de nuevo.'
),
});

// Recuperar contraseña: 3 solicitudes por IP cada hora
export const passwordResetRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: () => process.env.NODE_ENV === 'test',
message: jsonResponse(
'Demasiadas solicitudes de recuperación de contraseña. Esperá 1 hora e intentá de nuevo.'
),
});
3 changes: 2 additions & 1 deletion src/modules/session/routes/session.routes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//session.routes.js
import Router from "express";
import { login, logout, userSession } from "../controllers/session.controllers.js";
import { loginRateLimiter } from "../../../middlewares/rateLimiter.js";

const router = Router();

Expand Down Expand Up @@ -36,7 +37,7 @@ const router = Router();
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post("/", login);
router.post("/", loginRateLimiter, login);

/**
* @swagger
Expand Down
3 changes: 2 additions & 1 deletion src/modules/users/users/routes/users.routes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//users.routes.js
import { Router } from "express";
import authenticate from "../../../../config/jwt.config.js";
import { passwordResetRateLimiter } from "../../../../middlewares/rateLimiter.js";
import {
registerUser,
updateUser,
Expand All @@ -16,7 +17,7 @@ const router = Router();
router.post("/register", registerUser);

// Password reset (sin autenticación)
router.post("/forgot-password", requestPasswordReset);
router.post("/forgot-password", passwordResetRateLimiter, requestPasswordReset);
router.post("/validate-reset-token", validateResetToken);
router.post("/reset-password", resetPassword);

Expand Down
81 changes: 81 additions & 0 deletions tests/unit/session/rate-limit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { rateLimit } from "express-rate-limit";

function buildApp(limiter) {
const app = express();
app.use(express.json());
app.post("/test", limiter, (_req, res) => res.status(200).json({ ok: true }));
return app;
}

function makeTestLimiter(limit, message) {
return rateLimit({
windowMs: 60 * 1000,
limit,
standardHeaders: "draft-7",
legacyHeaders: false,
message: { success: false, message },
});
Comment thread
SebaKisser marked this conversation as resolved.
}

describe("loginRateLimiter — lógica de rate limit", () => {
let app;

beforeEach(() => {
app = buildApp(makeTestLimiter(5, "Demasiados intentos de inicio de sesión. Esperá 15 minutos e intentá de nuevo."));
});

it("permite hasta 5 intentos", async () => {
for (let i = 0; i < 5; i++) {
const res = await request(app).post("/test").send({});
expect(res.status).toBe(200);
}
});

it("bloquea el 6° intento con 429", async () => {
for (let i = 0; i < 5; i++) {
await request(app).post("/test").send({});
}
const res = await request(app).post("/test").send({});
expect(res.status).toBe(429);
expect(res.body.success).toBe(false);
expect(res.body.message).toMatch(/demasiados intentos/i);
});

it("incluye el header RateLimit en la respuesta", async () => {
const res = await request(app).post("/test").send({});
expect(res.headers).toHaveProperty("ratelimit");
});
});

describe("passwordResetRateLimiter — lógica de rate limit", () => {
let app;

beforeEach(() => {
app = buildApp(makeTestLimiter(3, "Demasiadas solicitudes de recuperación de contraseña. Esperá 1 hora e intentá de nuevo."));
});

it("permite hasta 3 solicitudes", async () => {
for (let i = 0; i < 3; i++) {
const res = await request(app).post("/test").send({});
expect(res.status).toBe(200);
}
});

it("bloquea la 4° solicitud con 429", async () => {
for (let i = 0; i < 3; i++) {
await request(app).post("/test").send({});
}
const res = await request(app).post("/test").send({});
expect(res.status).toBe(429);
expect(res.body.success).toBe(false);
expect(res.body.message).toMatch(/demasiadas solicitudes/i);
});

it("incluye el header RateLimit en la respuesta", async () => {
const res = await request(app).post("/test").send({});
expect(res.headers).toHaveProperty("ratelimit");
});
});
Loading