From d5d098d16bb4d3b9c300dca8e156d64539669da0 Mon Sep 17 00:00:00 2001 From: Suyash-ka-github <158561331+Suyash-ka-github@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:45:13 +0530 Subject: [PATCH 1/4] feat: Add login rate limiter middleware for brute-force protection --- src/controllers/userController.ts | 17 +++++++++ src/middleware/rateLimiter.ts | 63 +++++++++++++++++++++++++++++++ src/routes/userRoutes.ts | 3 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/middleware/rateLimiter.ts diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index e13972f..e52bdd4 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -76,6 +76,10 @@ export const login = async (req: Request, res: Response) => { try { const { identifier, password } = req.body; if (!identifier || !password) { + // Increment failed attempt on missing credentials + if ((res as any).incrementLoginAttempt) { + await (res as any).incrementLoginAttempt(); + } return res.status(400).json({ success: false, message: 'Missing credentials', @@ -92,6 +96,10 @@ export const login = async (req: Request, res: Response) => { }); if (!user) { + // Increment failed attempt on invalid user + if ((res as any).incrementLoginAttempt) { + await (res as any).incrementLoginAttempt(); + } return res.status(401).json({ success: false, message: 'Authentication failed', @@ -102,6 +110,10 @@ export const login = async (req: Request, res: Response) => { const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { + // Increment failed attempt on invalid password + if ((res as any).incrementLoginAttempt) { + await (res as any).incrementLoginAttempt(); + } return res.status(401).json({ success: false, message: 'Authentication failed', @@ -109,6 +121,11 @@ export const login = async (req: Request, res: Response) => { }); } + // Clear failed attempts on successful login + if ((res as any).clearLoginAttempts) { + await (res as any).clearLoginAttempts(); + } + const sessionId = generateSessionId(); await prisma.user.update({ diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..eb95c4a --- /dev/null +++ b/src/middleware/rateLimiter.ts @@ -0,0 +1,63 @@ +import { Request, Response, NextFunction } from 'express'; +import Redis from 'ioredis'; + +const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); + +const MAX_LOGIN_ATTEMPTS = 5; +const LOCKOUT_TIME = 15 * 60; // 15 minutes in seconds + +/** + * Rate limiter middleware for login endpoint + * Tracks failed login attempts per IP address + * Blocks requests after MAX_LOGIN_ATTEMPTS within LOCKOUT_TIME + */ +export const loginRateLimiter = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + // Get client IP (handles proxies) + const clientIp = + (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || + req.socket.remoteAddress || + 'unknown'; + + const rateLimitKey = `login-attempts:${clientIp}`; + + // Get current attempt count + const attemptCount = await redis.get(rateLimitKey); + const currentAttempts = parseInt(attemptCount || '0', 10); + + // Check if user is locked out + if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { + return res.status(429).json({ + success: false, + message: 'Too many login attempts', + error: `Maximum login attempts exceeded. Please try again in ${LOCKOUT_TIME / 60} minutes.` + }); + } + + // Attach helper function to response to increment failed attempts + (res as any).incrementLoginAttempt = async () => { + const newCount = currentAttempts + 1; + await redis.setex(rateLimitKey, LOCKOUT_TIME, newCount.toString()); + console.log(`[Rate Limiter] Login attempt ${newCount}/${MAX_LOGIN_ATTEMPTS} from IP: ${clientIp}`); + }; + + // Attach helper function to clear attempts on successful login + (res as any).clearLoginAttempts = async () => { + await redis.del(rateLimitKey); + console.log(`[Rate Limiter] Login attempts cleared for IP: ${clientIp}`); + }; + + // Store IP for logging + (req as any).clientIp = clientIp; + + next(); + } catch (error) { + console.error('Rate limiter error:', error); + // Don't block login on rate limiter error + next(); + } +}; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index cafaa13..b0b775f 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -2,11 +2,12 @@ import { Router } from 'express'; import { signup, login, searchUsers, logout } from '../controllers/userController'; import { validateSignup, validateLogin } from '../middleware/validation'; import { authenticate } from '../middleware/auth'; +import { loginRateLimiter } from '../middleware/rateLimiter'; const router = Router(); router.post('/signup', validateSignup, signup); -router.post('/login', validateLogin, login); +router.post('/login', validateLogin, loginRateLimiter, login); router.post('/logout', authenticate, logout); router.get('/search', authenticate, searchUsers); From ebc70aed5c80529765a55273cbbf8a7d4a20edb6 Mon Sep 17 00:00:00 2001 From: Suyash-ka-github <158561331+Suyash-ka-github@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:16:17 +0530 Subject: [PATCH 2/4] fix: Improve rate limiter security and reliability --- src/index.ts | 4 ++++ src/middleware/rateLimiter.ts | 33 +++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 47d4cf1..2b0c3c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,10 @@ import { startAnalyticsFlushWorker } from './workers/analyticsFlushWorker'; const app = express(); const PORT = process.env.PORT || 3000; +// Trust proxy - required for accurate IP detection in rate limiter +// Set to 1 for single proxy (e.g., nginx, load balancer) +app.set('trust proxy', 1); + app.use(cors()); app.use(express.json()); diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index eb95c4a..438fc9a 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -1,14 +1,12 @@ import { Request, Response, NextFunction } from 'express'; -import Redis from 'ioredis'; - -const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); +import redis from '../utils/redis'; const MAX_LOGIN_ATTEMPTS = 5; const LOCKOUT_TIME = 15 * 60; // 15 minutes in seconds /** * Rate limiter middleware for login endpoint - * Tracks failed login attempts per IP address + * Tracks failed login attempts per IP address using atomic Redis operations * Blocks requests after MAX_LOGIN_ATTEMPTS within LOCKOUT_TIME */ export const loginRateLimiter = async ( @@ -17,12 +15,8 @@ export const loginRateLimiter = async ( next: NextFunction ) => { try { - // Get client IP (handles proxies) - const clientIp = - (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || - req.socket.remoteAddress || - 'unknown'; - + // Get client IP - use req.ip which respects Express trust proxy settings + const clientIp = req.ip || 'unknown'; const rateLimitKey = `login-attempts:${clientIp}`; // Get current attempt count @@ -31,17 +25,28 @@ export const loginRateLimiter = async ( // Check if user is locked out if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { + // Get remaining TTL for Retry-After header + const ttl = await redis.ttl(rateLimitKey); + const retryAfter = ttl > 0 ? ttl : LOCKOUT_TIME; + + res.set('Retry-After', retryAfter.toString()); return res.status(429).json({ success: false, message: 'Too many login attempts', - error: `Maximum login attempts exceeded. Please try again in ${LOCKOUT_TIME / 60} minutes.` + error: `Maximum login attempts exceeded. Please try again in ${Math.ceil(retryAfter / 60)} minutes.` }); } // Attach helper function to response to increment failed attempts (res as any).incrementLoginAttempt = async () => { - const newCount = currentAttempts + 1; - await redis.setex(rateLimitKey, LOCKOUT_TIME, newCount.toString()); + // Use atomic INCR to prevent race conditions + const newCount = await redis.incr(rateLimitKey); + + // Set expiry only on first attempt + if (newCount === 1) { + await redis.expire(rateLimitKey, LOCKOUT_TIME); + } + console.log(`[Rate Limiter] Login attempt ${newCount}/${MAX_LOGIN_ATTEMPTS} from IP: ${clientIp}`); }; @@ -57,7 +62,7 @@ export const loginRateLimiter = async ( next(); } catch (error) { console.error('Rate limiter error:', error); - // Don't block login on rate limiter error + // Don't block login on rate limiter error - graceful degradation next(); } }; From e9aa3895aa64ad957cfdc8131fc3177ed5b84245 Mon Sep 17 00:00:00 2001 From: Suyash-ka-github <158561331+Suyash-ka-github@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:25:11 +0530 Subject: [PATCH 3/4] User-Email-Based rate limiter --- .env.example | 12 +++ src/controllers/userController.ts | 16 ++-- src/index.ts | 3 - src/middleware/emailRateLimiter.ts | 116 +++++++++++++++++++++++++++++ src/middleware/rateLimiter.ts | 68 ----------------- src/routes/userRoutes.ts | 6 +- 6 files changed, 140 insertions(+), 81 deletions(-) create mode 100644 src/middleware/emailRateLimiter.ts delete mode 100644 src/middleware/rateLimiter.ts diff --git a/.env.example b/.env.example index 1e218ec..0ed6901 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,15 @@ REDIS_URL="redis://localhost:6379" EMAIL_USER=test@gmail.com EMAIL_PASS=app-specific-password EMAIL_HOST="smtp.gmail.com" + +# RATE LIMITING CONFIGURATION +# Login attempt rate limiter protects accounts from brute force attacks +# Tracks failed login attempts per email/username identifier +LOGIN_ATTEMPT_MAX=5 # Maximum failed attempts allowed +LOGIN_ATTEMPT_WINDOW=60 # Time window in seconds (1 minute) +LOGIN_COOLDOWN_DURATION=600 # Lockout duration in seconds (10 minutes) + +# TRUST PROXY CONFIGURATION +# Required for correct IP detection behind reverse proxies (ALB, Nginx, etc.) +# Options: 'loopback' (local dev only), '1' (single proxy), 'false' (direct internet) +TRUST_PROXY="loopback" diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index e52bdd4..af667d6 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -77,8 +77,8 @@ export const login = async (req: Request, res: Response) => { const { identifier, password } = req.body; if (!identifier || !password) { // Increment failed attempt on missing credentials - if ((res as any).incrementLoginAttempt) { - await (res as any).incrementLoginAttempt(); + if ((res as any).incrementEmailAttempt) { + await (res as any).incrementEmailAttempt(); } return res.status(400).json({ success: false, @@ -97,8 +97,8 @@ export const login = async (req: Request, res: Response) => { if (!user) { // Increment failed attempt on invalid user - if ((res as any).incrementLoginAttempt) { - await (res as any).incrementLoginAttempt(); + if ((res as any).incrementEmailAttempt) { + await (res as any).incrementEmailAttempt(); } return res.status(401).json({ success: false, @@ -111,8 +111,8 @@ export const login = async (req: Request, res: Response) => { if (!isPasswordValid) { // Increment failed attempt on invalid password - if ((res as any).incrementLoginAttempt) { - await (res as any).incrementLoginAttempt(); + if ((res as any).incrementEmailAttempt) { + await (res as any).incrementEmailAttempt(); } return res.status(401).json({ success: false, @@ -122,8 +122,8 @@ export const login = async (req: Request, res: Response) => { } // Clear failed attempts on successful login - if ((res as any).clearLoginAttempts) { - await (res as any).clearLoginAttempts(); + if ((res as any).clearEmailAttempts) { + await (res as any).clearEmailAttempts(); } const sessionId = generateSessionId(); diff --git a/src/index.ts b/src/index.ts index 2b0c3c4..8a467e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,9 +17,6 @@ import { startAnalyticsFlushWorker } from './workers/analyticsFlushWorker'; const app = express(); const PORT = process.env.PORT || 3000; -// Trust proxy - required for accurate IP detection in rate limiter -// Set to 1 for single proxy (e.g., nginx, load balancer) -app.set('trust proxy', 1); app.use(cors()); app.use(express.json()); diff --git a/src/middleware/emailRateLimiter.ts b/src/middleware/emailRateLimiter.ts new file mode 100644 index 0000000..6c413d1 --- /dev/null +++ b/src/middleware/emailRateLimiter.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from 'express'; +import redis from '../utils/redis'; + +/** + * Email-based rate limiter middleware for login endpoint + * Protects against brute force attacks on specific user accounts + * + * Configuration (via environment variables): + * - LOGIN_ATTEMPT_MAX: Maximum failed login attempts allowed within window (default: 5) + * - LOGIN_ATTEMPT_WINDOW: Time window in seconds to track attempts (default: 60 = 1 minute) + * - LOGIN_COOLDOWN_DURATION: Lockout duration in seconds after exceeding max attempts (default: 600 = 10 minutes) + * + * Behavior: + * - Tracks failed login attempts per email/username identifier + * - If user fails MAX attempts within WINDOW seconds → lock account for COOLDOWN seconds + * - Uses atomic Redis INCR to prevent race conditions in high concurrency + * - After cooldown expires, counter resets and user can try again + * + * Edge Cases Handled: + * - Concurrent requests: Atomic INCR prevents lost increments from simultaneous requests + * - Window expiry: TTL auto-expires counter after WINDOW seconds + * - Cooldown expiry: TTL auto-expires cooldown lock after COOLDOWN seconds + * - Missing identifier: Passes through to next middleware (no rate limiting) + * - Redis failures: Graceful degradation - logs error but allows login attempt + */ +export const emailRateLimiter = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + // Load configuration from environment variables + const MAX_LOGIN_ATTEMPTS = parseInt(process.env.LOGIN_ATTEMPT_MAX || '5', 10); + const ATTEMPT_WINDOW = parseInt(process.env.LOGIN_ATTEMPT_WINDOW || '60', 10); // seconds + const COOLDOWN_PERIOD = parseInt(process.env.LOGIN_COOLDOWN_DURATION || '600', 10); // seconds + + const { identifier } = req.body; + + // Skip rate limiting if identifier not provided + if (!identifier) { + return next(); + } + + const rateLimitKey = `login-attempts:${identifier}`; + const cooldownKey = `login-cooldown:${identifier}`; + + // ==================== EDGE CASE 1: Concurrent Request During Cooldown ==================== + // Multiple requests arriving simultaneously while account is locked + // Solution: Atomic GET prevents race conditions - all requests see same cooldown status + const isOnCooldown = await redis.get(cooldownKey); + if (isOnCooldown) { + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials' + }); + } + + // ==================== EDGE CASE 2: Window Period Rollover ==================== + // Problem: What if window is 60 seconds and requests arrive at seconds 50, 55, 65? + // Second 50 & 55: Within window, count increases + // Second 65: Old count expired, new window starts + // Solution: TTL (expire) ensures counter resets after ATTEMPT_WINDOW seconds automatically + const attemptCount = await redis.get(rateLimitKey); + const currentAttempts = parseInt(attemptCount || '0', 10); + + // Check if current attempt count exceeds maximum + if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { + // Account needs cooldown + await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); + console.log(`[Rate Limiter] Account locked: ${identifier} | Cooldown: ${COOLDOWN_PERIOD}s`); + + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials' + }); + } + + // ==================== EDGE CASE 3: Concurrent Increment Requests ==================== + // Problem: Two requests arrive simultaneously, both read count=4, both increment to 5 + // Result: We lose one increment and need 6 attempts instead of 5 to trigger lockout + // Solution: Use atomic INCR operation instead of GET -> increment -> SET + // INCR is atomic at Redis level, guarantees no lost increments + (res as any).incrementEmailAttempt = async () => { + const newCount = await redis.incr(rateLimitKey); + + // Only set expiry on first attempt (INCR returns 1 on first call) + if (newCount === 1) { + await redis.expire(rateLimitKey, ATTEMPT_WINDOW); + console.log(`[Rate Limiter] New window: ${identifier} | Window duration: ${ATTEMPT_WINDOW}s`); + } + + // When max attempts reached, trigger cooldown for future requests + if (newCount >= MAX_LOGIN_ATTEMPTS) { + await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); + console.log(`[Rate Limiter] Max attempts reached: ${identifier} (${newCount}/${MAX_LOGIN_ATTEMPTS}) | Locked for ${COOLDOWN_PERIOD}s`); + } else { + console.log(`[Rate Limiter] Failed attempt: ${identifier} (${newCount}/${MAX_LOGIN_ATTEMPTS})`); + } + }; + + // Clear attempts on successful login + (res as any).clearEmailAttempts = async () => { + await redis.del(rateLimitKey); + console.log(`[Rate Limiter] Attempts cleared: ${identifier} (successful login)`); + }; + + next(); + } catch (error) { + console.error('[Rate Limiter] Error:', error); + // Graceful degradation: If Redis fails, don't block login + // Log the error but allow request to proceed + next(); + } +}; diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts deleted file mode 100644 index 438fc9a..0000000 --- a/src/middleware/rateLimiter.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import redis from '../utils/redis'; - -const MAX_LOGIN_ATTEMPTS = 5; -const LOCKOUT_TIME = 15 * 60; // 15 minutes in seconds - -/** - * Rate limiter middleware for login endpoint - * Tracks failed login attempts per IP address using atomic Redis operations - * Blocks requests after MAX_LOGIN_ATTEMPTS within LOCKOUT_TIME - */ -export const loginRateLimiter = async ( - req: Request, - res: Response, - next: NextFunction -) => { - try { - // Get client IP - use req.ip which respects Express trust proxy settings - const clientIp = req.ip || 'unknown'; - const rateLimitKey = `login-attempts:${clientIp}`; - - // Get current attempt count - const attemptCount = await redis.get(rateLimitKey); - const currentAttempts = parseInt(attemptCount || '0', 10); - - // Check if user is locked out - if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { - // Get remaining TTL for Retry-After header - const ttl = await redis.ttl(rateLimitKey); - const retryAfter = ttl > 0 ? ttl : LOCKOUT_TIME; - - res.set('Retry-After', retryAfter.toString()); - return res.status(429).json({ - success: false, - message: 'Too many login attempts', - error: `Maximum login attempts exceeded. Please try again in ${Math.ceil(retryAfter / 60)} minutes.` - }); - } - - // Attach helper function to response to increment failed attempts - (res as any).incrementLoginAttempt = async () => { - // Use atomic INCR to prevent race conditions - const newCount = await redis.incr(rateLimitKey); - - // Set expiry only on first attempt - if (newCount === 1) { - await redis.expire(rateLimitKey, LOCKOUT_TIME); - } - - console.log(`[Rate Limiter] Login attempt ${newCount}/${MAX_LOGIN_ATTEMPTS} from IP: ${clientIp}`); - }; - - // Attach helper function to clear attempts on successful login - (res as any).clearLoginAttempts = async () => { - await redis.del(rateLimitKey); - console.log(`[Rate Limiter] Login attempts cleared for IP: ${clientIp}`); - }; - - // Store IP for logging - (req as any).clientIp = clientIp; - - next(); - } catch (error) { - console.error('Rate limiter error:', error); - // Don't block login on rate limiter error - graceful degradation - next(); - } -}; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index b0b775f..a289aa4 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -2,12 +2,14 @@ import { Router } from 'express'; import { signup, login, searchUsers, logout } from '../controllers/userController'; import { validateSignup, validateLogin } from '../middleware/validation'; import { authenticate } from '../middleware/auth'; -import { loginRateLimiter } from '../middleware/rateLimiter'; +import { emailRateLimiter } from '../middleware/emailRateLimiter'; const router = Router(); router.post('/signup', validateSignup, signup); -router.post('/login', validateLogin, loginRateLimiter, login); +// Email-based rate limiter runs BEFORE validation to protect against brute force on password +// validateLogin checks format, emailRateLimiter checks attempt count per email +router.post('/login', emailRateLimiter, validateLogin, login); router.post('/logout', authenticate, logout); router.get('/search', authenticate, searchUsers); From 16a41de15389db8b05115f83414b39aef51141b3 Mon Sep 17 00:00:00 2001 From: Suyash-ka-github <158561331+Suyash-ka-github@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:29:26 +0530 Subject: [PATCH 4/4] Fixed Bugs in rate limiting using email --- .env.example | 5 -- src/controllers/forgotPasswordController.ts | 18 +++--- src/controllers/otpController.ts | 56 ++++++++++------ src/controllers/userController.ts | 45 ++++++------- src/middleware/emailRateLimiter.ts | 72 ++++++--------------- src/middleware/validation.ts | 16 +++-- src/types/express.d.ts | 10 +++ src/utils/loginIdentifier.ts | 35 ++++++++++ src/utils/redis.ts | 21 ++++++ 9 files changed, 163 insertions(+), 115 deletions(-) create mode 100644 src/types/express.d.ts create mode 100644 src/utils/loginIdentifier.ts diff --git a/.env.example b/.env.example index 0ed6901..db6aa0c 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,3 @@ EMAIL_HOST="smtp.gmail.com" LOGIN_ATTEMPT_MAX=5 # Maximum failed attempts allowed LOGIN_ATTEMPT_WINDOW=60 # Time window in seconds (1 minute) LOGIN_COOLDOWN_DURATION=600 # Lockout duration in seconds (10 minutes) - -# TRUST PROXY CONFIGURATION -# Required for correct IP detection behind reverse proxies (ALB, Nginx, etc.) -# Options: 'loopback' (local dev only), '1' (single proxy), 'false' (direct internet) -TRUST_PROXY="loopback" diff --git a/src/controllers/forgotPasswordController.ts b/src/controllers/forgotPasswordController.ts index a862391..0a18bc7 100644 --- a/src/controllers/forgotPasswordController.ts +++ b/src/controllers/forgotPasswordController.ts @@ -17,6 +17,7 @@ import { } from '../utils/redis'; import { sendOTPEmail, sendPasswordResetOTPEmail } from '../services/emailService'; import prisma from '../utils/prisma'; +import { isEmailIdentifier, normalizeEmail } from '../utils/loginIdentifier'; export const requestForgotPasswordOTP = async (req: Request, res: Response) => { try { @@ -30,18 +31,17 @@ export const requestForgotPasswordOTP = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let user; let email; if (isEmail) { + email = normalizeEmail(identifier); user = await prisma.user.findUnique({ - where: { email: identifier }, + where: { email }, select: { id: true, email: true, username: true } }); - email = identifier; } else { user = await prisma.user.findUnique({ where: { username: identifier }, @@ -114,12 +114,11 @@ export const verifyForgotPasswordOTP = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let email: string; if (isEmail) { - email = identifier; + email = normalizeEmail(identifier); } else { const user = await prisma.user.findUnique({ where: { username: identifier }, @@ -213,13 +212,12 @@ export const resetPassword = async (req: Request, res: Response) => { }); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const isEmail = emailRegex.test(identifier); + const isEmail = isEmailIdentifier(identifier); let email: string; let user; if (isEmail) { - email = identifier; + email = normalizeEmail(identifier); user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true } diff --git a/src/controllers/otpController.ts b/src/controllers/otpController.ts index 51b738f..9f08b60 100644 --- a/src/controllers/otpController.ts +++ b/src/controllers/otpController.ts @@ -15,6 +15,7 @@ import { } from '../utils/redis'; import { sendOTPEmail } from '../services/emailService'; import prisma from '../utils/prisma'; +import { normalizeEmail } from '../utils/loginIdentifier'; export const requestOTP = async (req: Request, res: Response) => { try { @@ -29,7 +30,7 @@ export const requestOTP = async (req: Request, res: Response) => { } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { + if (typeof email !== 'string' || !emailRegex.test(email)) { return res.status(400).json({ success: false, message: 'Invalid email format', @@ -37,8 +38,10 @@ export const requestOTP = async (req: Request, res: Response) => { }); } + const normalizedEmail = normalizeEmail(email); + const existingUser = await prisma.user.findUnique({ - where: { email } + where: { email: normalizedEmail } }); if (existingUser) { @@ -49,7 +52,7 @@ export const requestOTP = async (req: Request, res: Response) => { }); } - const attempts = await checkOTPAttempts(email); + const attempts = await checkOTPAttempts(normalizedEmail); if (attempts >= 3) { return res.status(429).json({ success: false, @@ -59,11 +62,11 @@ export const requestOTP = async (req: Request, res: Response) => { } const otp = generateOTP(); - await storeOTP(email, otp); - await incrementOTPAttempts(email); + await storeOTP(normalizedEmail, otp); + await incrementOTPAttempts(normalizedEmail); try { - await sendOTPEmail(email, otp); + await sendOTPEmail(normalizedEmail, otp); } catch (emailError) { console.error('Email sending failed:', emailError); return res.status(500).json({ @@ -77,7 +80,7 @@ export const requestOTP = async (req: Request, res: Response) => { success: true, message: 'OTP sent successfully', data: { - email, + email: normalizedEmail, expiresIn: 300 // 5 minutes } }); @@ -104,10 +107,20 @@ export const verifyOTPController = async (req: Request, res: Response) => { }); } - const verificationAttempts = await checkOTPVerificationAttempts(email); + if (typeof email !== 'string') { + return res.status(400).json({ + success: false, + message: 'Invalid email format', + error: 'Please provide a valid email address' + }); + } + + const normalizedEmail = normalizeEmail(email); + + const verificationAttempts = await checkOTPVerificationAttempts(normalizedEmail); if (verificationAttempts >= 5) { - await deleteOTP(email); - await resetOTPVerificationAttempts(email); + await deleteOTP(normalizedEmail); + await resetOTPVerificationAttempts(normalizedEmail); return res.status(429).json({ success: false, @@ -117,16 +130,16 @@ export const verifyOTPController = async (req: Request, res: Response) => { }); } - const isValid = await verifyOTP(email, otp); + const isValid = await verifyOTP(normalizedEmail, otp); if (!isValid) { - const newAttempts = await incrementOTPVerificationAttempts(email); + const newAttempts = await incrementOTPVerificationAttempts(normalizedEmail); const remainingAttempts = 5 - newAttempts; if (remainingAttempts <= 0) { - await deleteOTP(email); - await resetOTPVerificationAttempts(email); + await deleteOTP(normalizedEmail); + await resetOTPVerificationAttempts(normalizedEmail); return res.status(429).json({ success: false, @@ -144,16 +157,16 @@ export const verifyOTPController = async (req: Request, res: Response) => { }); } - await markEmailVerified(email); - await deleteOTP(email); - await resetOTPAttempts(email); - await resetOTPVerificationAttempts(email); + await markEmailVerified(normalizedEmail); + await deleteOTP(normalizedEmail); + await resetOTPAttempts(normalizedEmail); + await resetOTPVerificationAttempts(normalizedEmail); return res.status(200).json({ success: true, message: 'Email verified successfully', data: { - email, + email: normalizedEmail, verified: true, validFor: 300 // 5 minutes } @@ -181,13 +194,14 @@ export const checkEmailVerification = async (req: Request, res: Response) => { }); } - const verified = await isEmailVerified(email); + const normalizedEmail = normalizeEmail(email); + const verified = await isEmailVerified(normalizedEmail); return res.status(200).json({ success: true, message: 'Email verification status retrieved', data: { - email, + email: normalizedEmail, verified } }); diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index af667d6..9f054e8 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -4,6 +4,7 @@ import { SignupRequest, LoginRequest, ApiResponse } from '../types'; import bcrypt from 'bcrypt'; import { generateToken, generateSessionId } from '../utils/jwt'; import { isEmailVerified, clearEmailVerification } from '../utils/redis'; +import { normalizeLoginIdentifier } from '../utils/loginIdentifier'; export const signup = async (req: Request, res: Response) => { try { @@ -74,11 +75,14 @@ export const signup = async (req: Request, res: Response) => { export const login = async (req: Request, res: Response) => { try { - const { identifier, password } = req.body; - if (!identifier || !password) { - // Increment failed attempt on missing credentials - if ((res as any).incrementEmailAttempt) { - await (res as any).incrementEmailAttempt(); + const { password } = req.body; + const normalizedIdentifier = normalizeLoginIdentifier(req.body?.identifier); + + // validateLogin guarantees password is present; this catches non-string identifiers + // (e.g. []) that pass validateLogin's truthy check but fail normalization. + if (!normalizedIdentifier) { + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); } return res.status(400).json({ success: false, @@ -87,18 +91,17 @@ export const login = async (req: Request, res: Response) => { }); } - const isEmail = identifier.includes('@'); - + const isEmail = normalizedIdentifier.includes('@'); + const user = await prisma.user.findUnique({ - where: isEmail - ? { email: identifier } - : { username: identifier } + where: isEmail + ? { email: normalizedIdentifier } + : { username: normalizedIdentifier } }); - + if (!user) { - // Increment failed attempt on invalid user - if ((res as any).incrementEmailAttempt) { - await (res as any).incrementEmailAttempt(); + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); } return res.status(401).json({ success: false, @@ -108,11 +111,10 @@ export const login = async (req: Request, res: Response) => { } const isPasswordValid = await bcrypt.compare(password, user.password); - + if (!isPasswordValid) { - // Increment failed attempt on invalid password - if ((res as any).incrementEmailAttempt) { - await (res as any).incrementEmailAttempt(); + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); } return res.status(401).json({ success: false, @@ -120,10 +122,9 @@ export const login = async (req: Request, res: Response) => { error: 'Invalid credentials' }); } - - // Clear failed attempts on successful login - if ((res as any).clearEmailAttempts) { - await (res as any).clearEmailAttempts(); + + if (res.locals.clearEmailAttempts) { + await res.locals.clearEmailAttempts(); } const sessionId = generateSessionId(); diff --git a/src/middleware/emailRateLimiter.ts b/src/middleware/emailRateLimiter.ts index 6c413d1..646859a 100644 --- a/src/middleware/emailRateLimiter.ts +++ b/src/middleware/emailRateLimiter.ts @@ -1,27 +1,15 @@ import { Request, Response, NextFunction } from 'express'; -import redis from '../utils/redis'; +import redis, { incrementKeyWithExpiry } from '../utils/redis'; +import { normalizeLoginIdentifier, hashIdentifierForLog } from '../utils/loginIdentifier'; /** * Email-based rate limiter middleware for login endpoint * Protects against brute force attacks on specific user accounts - * + * * Configuration (via environment variables): * - LOGIN_ATTEMPT_MAX: Maximum failed login attempts allowed within window (default: 5) * - LOGIN_ATTEMPT_WINDOW: Time window in seconds to track attempts (default: 60 = 1 minute) * - LOGIN_COOLDOWN_DURATION: Lockout duration in seconds after exceeding max attempts (default: 600 = 10 minutes) - * - * Behavior: - * - Tracks failed login attempts per email/username identifier - * - If user fails MAX attempts within WINDOW seconds → lock account for COOLDOWN seconds - * - Uses atomic Redis INCR to prevent race conditions in high concurrency - * - After cooldown expires, counter resets and user can try again - * - * Edge Cases Handled: - * - Concurrent requests: Atomic INCR prevents lost increments from simultaneous requests - * - Window expiry: TTL auto-expires counter after WINDOW seconds - * - Cooldown expiry: TTL auto-expires cooldown lock after COOLDOWN seconds - * - Missing identifier: Passes through to next middleware (no rate limiting) - * - Redis failures: Graceful degradation - logs error but allows login attempt */ export const emailRateLimiter = async ( req: Request, @@ -29,24 +17,20 @@ export const emailRateLimiter = async ( next: NextFunction ) => { try { - // Load configuration from environment variables const MAX_LOGIN_ATTEMPTS = parseInt(process.env.LOGIN_ATTEMPT_MAX || '5', 10); - const ATTEMPT_WINDOW = parseInt(process.env.LOGIN_ATTEMPT_WINDOW || '60', 10); // seconds - const COOLDOWN_PERIOD = parseInt(process.env.LOGIN_COOLDOWN_DURATION || '600', 10); // seconds + const ATTEMPT_WINDOW = parseInt(process.env.LOGIN_ATTEMPT_WINDOW || '60', 10); + const COOLDOWN_PERIOD = parseInt(process.env.LOGIN_COOLDOWN_DURATION || '600', 10); - const { identifier } = req.body; + const normalizedIdentifier = normalizeLoginIdentifier(req.body?.identifier); - // Skip rate limiting if identifier not provided - if (!identifier) { + if (!normalizedIdentifier) { return next(); } - const rateLimitKey = `login-attempts:${identifier}`; - const cooldownKey = `login-cooldown:${identifier}`; + const logId = hashIdentifierForLog(normalizedIdentifier); + const rateLimitKey = `login-attempts:${normalizedIdentifier}`; + const cooldownKey = `login-cooldown:${normalizedIdentifier}`; - // ==================== EDGE CASE 1: Concurrent Request During Cooldown ==================== - // Multiple requests arriving simultaneously while account is locked - // Solution: Atomic GET prevents race conditions - all requests see same cooldown status const isOnCooldown = await redis.get(cooldownKey); if (isOnCooldown) { return res.status(401).json({ @@ -56,19 +40,12 @@ export const emailRateLimiter = async ( }); } - // ==================== EDGE CASE 2: Window Period Rollover ==================== - // Problem: What if window is 60 seconds and requests arrive at seconds 50, 55, 65? - // Second 50 & 55: Within window, count increases - // Second 65: Old count expired, new window starts - // Solution: TTL (expire) ensures counter resets after ATTEMPT_WINDOW seconds automatically const attemptCount = await redis.get(rateLimitKey); const currentAttempts = parseInt(attemptCount || '0', 10); - // Check if current attempt count exceeds maximum if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { - // Account needs cooldown await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); - console.log(`[Rate Limiter] Account locked: ${identifier} | Cooldown: ${COOLDOWN_PERIOD}s`); + console.log(`[Rate Limiter] Account locked: ${logId} | Cooldown: ${COOLDOWN_PERIOD}s`); return res.status(401).json({ success: false, @@ -77,40 +54,31 @@ export const emailRateLimiter = async ( }); } - // ==================== EDGE CASE 3: Concurrent Increment Requests ==================== - // Problem: Two requests arrive simultaneously, both read count=4, both increment to 5 - // Result: We lose one increment and need 6 attempts instead of 5 to trigger lockout - // Solution: Use atomic INCR operation instead of GET -> increment -> SET - // INCR is atomic at Redis level, guarantees no lost increments - (res as any).incrementEmailAttempt = async () => { - const newCount = await redis.incr(rateLimitKey); + res.locals.incrementEmailAttempt = async () => { + const newCount = await incrementKeyWithExpiry(rateLimitKey, ATTEMPT_WINDOW); - // Only set expiry on first attempt (INCR returns 1 on first call) if (newCount === 1) { - await redis.expire(rateLimitKey, ATTEMPT_WINDOW); - console.log(`[Rate Limiter] New window: ${identifier} | Window duration: ${ATTEMPT_WINDOW}s`); + console.log(`[Rate Limiter] New window: ${logId} | Window duration: ${ATTEMPT_WINDOW}s`); } - // When max attempts reached, trigger cooldown for future requests if (newCount >= MAX_LOGIN_ATTEMPTS) { await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); - console.log(`[Rate Limiter] Max attempts reached: ${identifier} (${newCount}/${MAX_LOGIN_ATTEMPTS}) | Locked for ${COOLDOWN_PERIOD}s`); + console.log( + `[Rate Limiter] Max attempts reached: ${logId} (${newCount}/${MAX_LOGIN_ATTEMPTS}) | Locked for ${COOLDOWN_PERIOD}s` + ); } else { - console.log(`[Rate Limiter] Failed attempt: ${identifier} (${newCount}/${MAX_LOGIN_ATTEMPTS})`); + console.log(`[Rate Limiter] Failed attempt: ${logId} (${newCount}/${MAX_LOGIN_ATTEMPTS})`); } }; - // Clear attempts on successful login - (res as any).clearEmailAttempts = async () => { + res.locals.clearEmailAttempts = async () => { await redis.del(rateLimitKey); - console.log(`[Rate Limiter] Attempts cleared: ${identifier} (successful login)`); + console.log(`[Rate Limiter] Attempts cleared: ${logId} (successful login)`); }; next(); } catch (error) { console.error('[Rate Limiter] Error:', error); - // Graceful degradation: If Redis fails, don't block login - // Log the error but allow request to proceed next(); } }; diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index 2c46126..d786589 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; -import { SignupRequest, LoginRequest } from '../types/index'; +import { SignupRequest } from '../types/index'; +import { normalizeEmail } from '../utils/loginIdentifier'; const usernameRegex = /^[a-zA-Z][a-zA-Z0-9_-]{3,15}$/; const consecutiveSpecialChars = /[_-]{2,}/; @@ -46,6 +47,8 @@ export const validateSignup = (req: Request, res: Response, next: NextFunction) }); } + req.body.email = normalizeEmail(email); + if (!password || password.length < 8) { return res.status(400).json({ success: false, @@ -57,9 +60,9 @@ export const validateSignup = (req: Request, res: Response, next: NextFunction) next(); }; -export const validateLogin = (req: Request, res: Response, next: NextFunction) => { +export const validateLogin = async (req: Request, res: Response, next: NextFunction) => { const { identifier, password } = req.body; - + if (!identifier) { return res.status(400).json({ success: false, @@ -67,14 +70,17 @@ export const validateLogin = (req: Request, res: Response, next: NextFunction) = error: 'Username or email must be provided' }); } - + if (!password) { + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); + } return res.status(400).json({ success: false, message: 'Password is required', error: 'Password must be provided' }); } - + next(); }; \ No newline at end of file diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..d82b21b --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace Express { + interface Locals { + incrementEmailAttempt?: () => Promise; + clearEmailAttempts?: () => Promise; + } + } +} + +export {}; diff --git a/src/utils/loginIdentifier.ts b/src/utils/loginIdentifier.ts new file mode 100644 index 0000000..7375acf --- /dev/null +++ b/src/utils/loginIdentifier.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto'; + +const MAX_IDENTIFIER_LENGTH = 320; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function isEmailIdentifier(value: string): boolean { + return EMAIL_REGEX.test(value); +} + +export function normalizeLoginIdentifier(identifier: unknown): string | null { + if (typeof identifier !== 'string') { + return null; + } + + const trimmed = identifier.trim(); + if (!trimmed) { + return null; + } + + let normalized = trimmed.includes('@') ? normalizeEmail(trimmed) : trimmed; + + if (normalized.length > MAX_IDENTIFIER_LENGTH) { + normalized = crypto.createHash('sha256').update(normalized).digest('hex'); + } + + return normalized; +} + +export function hashIdentifierForLog(identifier: string): string { + return crypto.createHash('sha256').update(identifier).digest('hex').slice(0, 8); +} diff --git a/src/utils/redis.ts b/src/utils/redis.ts index 0631da9..9a04373 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -251,4 +251,25 @@ export const clearEmailVerification = async (email: string) => { await redis.del(`otp:verified:${email}`); }; +const INCR_WITH_EXPIRE_SCRIPT = ` + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return count +`; + +export const incrementKeyWithExpiry = async ( + key: string, + expirySeconds: number +): Promise => { + const result = await redis.eval( + INCR_WITH_EXPIRE_SCRIPT, + 1, + key, + expirySeconds + ); + return Number(result); +}; + export default redis; \ No newline at end of file