diff --git a/backend/package-lock.json b/backend/package-lock.json index 6b3601b..ff7faf6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", "jsonwebtoken": "^9.0.2", "mongodb": "^6.20.0", "mongoose": "^8.19.4", @@ -1086,6 +1087,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -1123,6 +1125,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -1394,6 +1414,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 8a541fc..cfb7dd7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", "jsonwebtoken": "^9.0.2", "mongodb": "^6.20.0", "mongoose": "^8.19.4", diff --git a/backend/src/app.ts b/backend/src/app.ts index b23c71c..f8b07cb 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,9 @@ import path from "path"; dotenv.config(); const app = express(); +if (process.env.TRUST_PROXY === "true") { + app.set("trust proxy", true); +} // Required for __dirname in ES modules / TS // (TS compiles to CJS so this works fine) const __dirnameLocal = path.resolve(); diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..3e85518 --- /dev/null +++ b/backend/src/middleware/rateLimiter.ts @@ -0,0 +1,30 @@ +import rateLimit from "express-rate-limit"; +import type { Request, Response } from "express"; +import logger from "../utils/logger.js"; + +export const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + handler: (req: Request, res: Response) => { + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + logger.warn( + `Rate limit exceeded for IP: ${clientIP} on ${req.method} ${req.originalUrl}` + ); + + const retryAfter = req.rateLimit?.resetTime + ? Math.round((req.rateLimit.resetTime - Date.now()) / 1000) + : 900; + + res.status(429).json({ + success: false, + message: "Too many authentication attempts. Please try again after 15 minutes.", + retryAfter, + }); + }, + keyGenerator: (req: Request) => { + return req.ip || req.socket.remoteAddress || "unknown"; + }, +}); + diff --git a/backend/src/models/sessionModel.ts b/backend/src/models/sessionModel.ts index b908954..cd660d4 100644 --- a/backend/src/models/sessionModel.ts +++ b/backend/src/models/sessionModel.ts @@ -7,5 +7,7 @@ const sessionSchema = new mongoose.Schema({ createdAt: { type: Date, default: Date.now }, }); +sessionSchema.index({ expiresAt: 1 }); +sessionSchema.index({ token: 1 }); export const Session = mongoose.model("Session", sessionSchema); diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 1a72441..c23b49d 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -9,6 +9,7 @@ import { import passport from "passport"; import { Session } from "../models/sessionModel.js"; import { protect } from "../middleware/authMiddleware.js"; +import { authRateLimiter } from "../middleware/rateLimiter.js"; import { generateToken, generateRefreshToken } from "../utils/generateToken.js"; import jwt from "jsonwebtoken"; import dotenv from "dotenv"; @@ -20,9 +21,8 @@ dotenv.config(); const router = express.Router(); const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; -// REST endpoints -router.post("/signup", registerUser); -router.post("/signin", loginUser); +router.post("/signup", authRateLimiter, registerUser); +router.post("/signin", authRateLimiter, loginUser); router.post("/logout", logoutUser); router.get("/refresh", handleRefreshToken); router.get("/me", protect, getUserProfile); diff --git a/backend/src/server.ts b/backend/src/server.ts index dcb4ddc..b4edf24 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { ChatMessage } from "./models/chatMessageModel.js"; import Room from "./models/roomModel.js"; // โœ… Import for room events import app from "./app.js"; import logger from "./utils/logger.js"; +import { initializeScheduler } from "./utils/scheduler.js"; dotenv.config(); @@ -166,6 +167,7 @@ mongoose .connect(MONGO_URI) .then(() => { logger.info("๐Ÿ—„๏ธ MongoDB connected successfully!"); + initializeScheduler(); httpServer.listen(PORT, () => { logger.info(`๐Ÿš€ Server running on port ${PORT}`); logger.info(`๐Ÿ“ก Socket.io real-time chat ready`); diff --git a/backend/src/utils/scheduler.ts b/backend/src/utils/scheduler.ts new file mode 100644 index 0000000..f3948a8 --- /dev/null +++ b/backend/src/utils/scheduler.ts @@ -0,0 +1,33 @@ +import cron from "node-cron"; +import { cleanupExpiredSessions } from "./sessionCleanup.js"; +import logger from "./logger.js"; + +export const initializeScheduler = () => { + const sessionCleanupCron = process.env.SESSION_CLEANUP_CRON || "0 * * * *"; + const sessionCleanupJob = cron.schedule(sessionCleanupCron, async () => { + try { + logger.info("๐Ÿ”„ Running scheduled session cleanup..."); + const result = await cleanupExpiredSessions(); + logger.info(`โœ… Session cleanup completed: ${result.deletedCount} expired session(s) removed`); + } catch (error: any) { + logger.error("Scheduled session cleanup failed:", error); + } + }, { + timezone: "UTC", + }); + cleanupExpiredSessions() + .then((result) => { + logger.info(`โœ… Initial session cleanup completed on startup: ${result.deletedCount} expired session(s) removed`); + }) + .catch((error) => { + logger.error(" Initial session cleanup failed:", error); + }); + + logger.info("๐Ÿ“… Scheduled tasks initialized:"); + logger.info(` - Session cleanup: Cron schedule "${sessionCleanupCron}" (every hour by default)`); + + return { + sessionCleanupJob, + }; +}; + diff --git a/backend/src/utils/sessionCleanup.ts b/backend/src/utils/sessionCleanup.ts new file mode 100644 index 0000000..b0ff079 --- /dev/null +++ b/backend/src/utils/sessionCleanup.ts @@ -0,0 +1,17 @@ +import { Session } from "../models/sessionModel.js"; +import logger from "./logger.js"; +export const cleanupExpiredSessions = async (): Promise<{ deletedCount: number }> => { + try { + const now = new Date(); + const result = await Session.deleteMany({ + expiresAt: { $lt: now }, + }); + if (result.deletedCount > 0) { + logger.info(`๐Ÿงน Cleaned up ${result.deletedCount} expired session(s)`); + } + return { deletedCount: result.deletedCount || 0 }; + } catch (error: any) { + logger.error(" Error cleaning up expired sessions:", error); + throw error; + } +}; diff --git a/frontend/src/pages/Signin.tsx b/frontend/src/pages/Signin.tsx index 4d0107f..9cc3813 100644 --- a/frontend/src/pages/Signin.tsx +++ b/frontend/src/pages/Signin.tsx @@ -54,12 +54,23 @@ const SignIn = () => { reset(); navigate("/"); // redirect to home/dashboard } catch (error: any) { - toast({ - title: "Error", - description: - error.response?.data?.message || "Login failed. Please try again.", - variant: "destructive", - }); + // Handle rate limiting errors + if (error.response?.status === 429) { + const retryAfter = error.response?.data?.retryAfter || 15; + const minutes = Math.ceil(retryAfter / 60); + toast({ + title: "Too Many Attempts", + description: `Too many login attempts. Please try again after ${minutes} minute${minutes > 1 ? 's' : ''}.`, + variant: "destructive", + }); + } else { + toast({ + title: "Error", + description: + error.response?.data?.message || "Login failed. Please try again.", + variant: "destructive", + }); + } } finally { setIsLoading(false); } diff --git a/frontend/src/pages/Signup.tsx b/frontend/src/pages/Signup.tsx index 9d51ad9..ae80184 100644 --- a/frontend/src/pages/Signup.tsx +++ b/frontend/src/pages/Signup.tsx @@ -83,16 +83,30 @@ const SignUp = () => { reset(); setTimeout(() => navigate("/signin"), 1500); } catch (error: any) { - const msg = - error.response?.data?.message || - "Registration failed. Please try again."; - toast({ - title: "Error", - description: msg, - variant: "destructive", - }); - setServerMessage(msg); - setIsError(true); + // Handle rate limiting errors + if (error.response?.status === 429) { + const retryAfter = error.response?.data?.retryAfter || 15; + const minutes = Math.ceil(retryAfter / 60); + const msg = `Too many registration attempts. Please try again after ${minutes} minute${minutes > 1 ? 's' : ''}.`; + toast({ + title: "Too Many Attempts", + description: msg, + variant: "destructive", + }); + setServerMessage(msg); + setIsError(true); + } else { + const msg = + error.response?.data?.message || + "Registration failed. Please try again."; + toast({ + title: "Error", + description: msg, + variant: "destructive", + }); + setServerMessage(msg); + setIsError(true); + } } finally { setIsLoading(false); }