diff --git a/backend/config/authRateLimit.js b/backend/config/authRateLimit.js new file mode 100644 index 0000000..4dc990a --- /dev/null +++ b/backend/config/authRateLimit.js @@ -0,0 +1,25 @@ +const rateLimit = require('express-rate-limit'); + +const AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; +const AUTH_RATE_LIMIT_MAX = 10; + +function createAuthRateLimiter(options = {}) { + return rateLimit({ + windowMs: AUTH_RATE_LIMIT_WINDOW_MS, + max: AUTH_RATE_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false, + ...options, + }); +} + +const loginLimiter = createAuthRateLimiter(); +const signupLimiter = createAuthRateLimiter(); + +module.exports = { + AUTH_RATE_LIMIT_MAX, + AUTH_RATE_LIMIT_WINDOW_MS, + createAuthRateLimiter, + loginLimiter, + signupLimiter, +}; diff --git a/backend/package.json b/backend/package.json index af00fdb..8a71712 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", "mongoose": "^8.8.2", "passport": "^0.7.0", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 6395116..fec627b 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -7,6 +7,7 @@ const crypto = require("crypto"); const User = require("../models/User"); const { signupSchema, loginSchema } = require("../validators/authValidator"); const { validateRequest } = require("../validators/validationRequest"); +const { loginLimiter, signupLimiter } = require("../config/authRateLimit"); const router = express.Router(); const getUseMongoAuth = () => typeof global.mongooseConnected !== "undefined" ? global.mongooseConnected : Boolean(process.env.MONGO_URI); @@ -104,7 +105,7 @@ const createSessionUser = (req, user) => { }; // Signup route -router.post("/signup", validateRequest(signupSchema), async (req, res) => { +router.post("/signup", signupLimiter, validateRequest(signupSchema), async (req, res) => { const { username, email, password } = req.body; @@ -164,7 +165,7 @@ router.get("/me", (req, res) => { }); // Login route -router.post("/login", validateRequest(loginSchema), async (req, res, next) => { +router.post("/login", loginLimiter, validateRequest(loginSchema), async (req, res, next) => { if (!getUseMongoAuth()) { try { const { email, password } = req.body; diff --git a/package.json b/package.json index feefc46..9fc4f9a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^1.7.7", "connect-mongo": "^5.1.0", "express": "^5.2.1", + "express-rate-limit": "^7.5.0", "framer-motion": "^12.23.12", "lucide-react": "^0.525.0", "mongoose": "^9.6.2", diff --git a/spec/auth.rate-limit.spec.cjs b/spec/auth.rate-limit.spec.cjs new file mode 100644 index 0000000..84f4321 --- /dev/null +++ b/spec/auth.rate-limit.spec.cjs @@ -0,0 +1,73 @@ +const express = require('express'); +const request = require('supertest'); + +const { + AUTH_RATE_LIMIT_MAX, + AUTH_RATE_LIMIT_WINDOW_MS, + createAuthRateLimiter, +} = require('../backend/config/authRateLimit'); + +function createRateLimitedAuthApp(max = 2) { + const app = express(); + + app.use(express.json()); + app.post('/api/auth/login', createAuthRateLimiter({ max }), (req, res) => { + res.status(200).json({ message: 'Login successful' }); + }); + app.post('/api/auth/signup', createAuthRateLimiter({ max }), (req, res) => { + res.status(201).json({ message: 'User created successfully' }); + }); + + return app; +} + +describe('Auth rate limiting', () => { + it('uses the configured production auth limiter baseline', () => { + expect(AUTH_RATE_LIMIT_MAX).toBe(10); + expect(AUTH_RATE_LIMIT_WINDOW_MS).toBe(15 * 60 * 1000); + }); + + it('allows legitimate login requests under the threshold', async () => { + const app = createRateLimitedAuthApp(); + + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'user@example.com', password: 'password123' }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Login successful'); + }); + + it('blocks excessive login requests with HTTP 429', async () => { + const app = createRateLimitedAuthApp(); + + await request(app).post('/api/auth/login').send({}); + await request(app).post('/api/auth/login').send({}); + const res = await request(app).post('/api/auth/login').send({}); + + expect(res.status).toBe(429); + }); + + it('blocks excessive signup requests with HTTP 429', async () => { + const app = createRateLimitedAuthApp(); + + await request(app).post('/api/auth/signup').send({}); + await request(app).post('/api/auth/signup').send({}); + const res = await request(app).post('/api/auth/signup').send({}); + + expect(res.status).toBe(429); + }); + + it('returns standard rate-limit headers without legacy headers', async () => { + const app = createRateLimitedAuthApp(); + + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'user@example.com', password: 'password123' }); + + expect(res.headers['ratelimit-limit']).toBeDefined(); + expect(res.headers['ratelimit-remaining']).toBeDefined(); + expect(res.headers['ratelimit-reset']).toBeDefined(); + expect(res.headers['x-ratelimit-limit']).toBeUndefined(); + }); +});