Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 8 additions & 10 deletions src/controllers/forgotPasswordController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 }
Expand Down
56 changes: 35 additions & 21 deletions src/controllers/otpController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,16 +30,18 @@ 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',
error: 'Please provide a valid email address'
});
}

const normalizedEmail = normalizeEmail(email);

const existingUser = await prisma.user.findUnique({
where: { email }
where: { email: normalizedEmail }
});

if (existingUser) {
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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
}
});
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
});
Expand Down
36 changes: 27 additions & 9 deletions src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,24 +75,34 @@ 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',
error: 'Username/email and password are required'
});
}

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',
Expand All @@ -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();

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
84 changes: 84 additions & 0 deletions src/middleware/emailRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -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();
}
};
Loading
Loading