diff --git a/.env.example b/.env.example index 1e218ec..db6aa0c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,10 @@ 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) 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 e13972f..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,8 +75,15 @@ 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) { + 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, message: 'Missing credentials', @@ -83,15 +91,18 @@ 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) { + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); + } return res.status(401).json({ success: false, message: 'Authentication failed', @@ -100,14 +111,21 @@ export const login = async (req: Request, res: Response) => { } const isPasswordValid = await bcrypt.compare(password, user.password); - + if (!isPasswordValid) { + if (res.locals.incrementEmailAttempt) { + await res.locals.incrementEmailAttempt(); + } return res.status(401).json({ success: false, message: 'Authentication failed', error: 'Invalid credentials' }); } + + if (res.locals.clearEmailAttempts) { + await res.locals.clearEmailAttempts(); + } const sessionId = generateSessionId(); diff --git a/src/index.ts b/src/index.ts index 47d4cf1..8a467e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { startAnalyticsFlushWorker } from './workers/analyticsFlushWorker'; const app = express(); const PORT = process.env.PORT || 3000; + 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..646859a --- /dev/null +++ b/src/middleware/emailRateLimiter.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from 'express'; +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) + */ +export const emailRateLimiter = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const MAX_LOGIN_ATTEMPTS = parseInt(process.env.LOGIN_ATTEMPT_MAX || '5', 10); + const ATTEMPT_WINDOW = parseInt(process.env.LOGIN_ATTEMPT_WINDOW || '60', 10); + const COOLDOWN_PERIOD = parseInt(process.env.LOGIN_COOLDOWN_DURATION || '600', 10); + + const normalizedIdentifier = normalizeLoginIdentifier(req.body?.identifier); + + if (!normalizedIdentifier) { + return next(); + } + + const logId = hashIdentifierForLog(normalizedIdentifier); + const rateLimitKey = `login-attempts:${normalizedIdentifier}`; + const cooldownKey = `login-cooldown:${normalizedIdentifier}`; + + const isOnCooldown = await redis.get(cooldownKey); + if (isOnCooldown) { + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials' + }); + } + + const attemptCount = await redis.get(rateLimitKey); + const currentAttempts = parseInt(attemptCount || '0', 10); + + if (currentAttempts >= MAX_LOGIN_ATTEMPTS) { + await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); + console.log(`[Rate Limiter] Account locked: ${logId} | Cooldown: ${COOLDOWN_PERIOD}s`); + + return res.status(401).json({ + success: false, + message: 'Authentication failed', + error: 'Invalid credentials' + }); + } + + res.locals.incrementEmailAttempt = async () => { + const newCount = await incrementKeyWithExpiry(rateLimitKey, ATTEMPT_WINDOW); + + if (newCount === 1) { + console.log(`[Rate Limiter] New window: ${logId} | Window duration: ${ATTEMPT_WINDOW}s`); + } + + if (newCount >= MAX_LOGIN_ATTEMPTS) { + await redis.setex(cooldownKey, COOLDOWN_PERIOD, 'locked'); + console.log( + `[Rate Limiter] Max attempts reached: ${logId} (${newCount}/${MAX_LOGIN_ATTEMPTS}) | Locked for ${COOLDOWN_PERIOD}s` + ); + } else { + console.log(`[Rate Limiter] Failed attempt: ${logId} (${newCount}/${MAX_LOGIN_ATTEMPTS})`); + } + }; + + res.locals.clearEmailAttempts = async () => { + await redis.del(rateLimitKey); + console.log(`[Rate Limiter] Attempts cleared: ${logId} (successful login)`); + }; + + next(); + } catch (error) { + console.error('[Rate Limiter] Error:', error); + 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/routes/userRoutes.ts b/src/routes/userRoutes.ts index cafaa13..a289aa4 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -2,11 +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 { emailRateLimiter } from '../middleware/emailRateLimiter'; const router = Router(); router.post('/signup', validateSignup, signup); -router.post('/login', validateLogin, 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); 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