From 8cbdda04af155ab58d12e75a2bcb3fd016d15377 Mon Sep 17 00:00:00 2001 From: Zaiba Machhaliya Date: Sun, 28 Jun 2026 17:06:32 +0530 Subject: [PATCH] feat(auth): refactor auth controller with security improvements --- backend/controllers/authController.js | 345 ++++++++++++++++++++++---- 1 file changed, 290 insertions(+), 55 deletions(-) diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 6dedbe6..39afa7d 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -1,3 +1,8 @@ +/** + * Authentication Controller with Security Improvements + * @module controllers/authController + */ + const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); @@ -7,10 +12,27 @@ const { sanitizeString, safeArray } = require("../utils/helpers"); // Appwrite SDK const { Client, Account, ID, Databases } = require('node-appwrite'); -// In-memory cache for pending signups (Email -> { name, password, userId, expiresAt }) +// ==================== CONSTANTS ==================== +const OTP_EXPIRY_MINUTES = parseInt(process.env.OTP_EXPIRY_MINUTES) || 10; +const OTP_RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes +const OTP_RATE_LIMIT_MAX = 3; // Max 3 OTP requests per window +const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes +const MAX_LOGIN_ATTEMPTS = 5; +const LOGIN_LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes + +// ==================== VALIDATION PATTERNS ==================== +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/; +const otpRegex = /^\d{6}$/; + +// ==================== RATE LIMITING ==================== +const otpRateLimiter = new Map(); +const loginAttempts = new Map(); + +// ==================== PENDING SIGNUPS CACHE ==================== const pendingSignups = new Map(); -// Clean up expired pending signups every 5 minutes to prevent memory leaks +// Clean up expired pending signups every 5 minutes setInterval(() => { const now = Date.now(); for (const [email, data] of pendingSignups.entries()) { @@ -18,25 +40,34 @@ setInterval(() => { pendingSignups.delete(email); } } -}, 5 * 60 * 1000); + // Clean up expired rate limiter entries + for (const [key, data] of otpRateLimiter.entries()) { + if (now > data.resetTime) { + otpRateLimiter.delete(key); + } + } +}, CLEANUP_INTERVAL); + +// ==================== JWT SECRET VALIDATION ==================== +if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is not set"); +} -// Initialize Appwrite Client (No API Key needed for Account API) +// ==================== APPWRITE CLIENT ==================== const appwriteClient = new Client() .setEndpoint(process.env.VITE_APPWRITE_ENDPOINT || 'https://fra.cloud.appwrite.io/v1') .setProject(process.env.VITE_APPWRITE_PROJECT_ID); const appwriteAccount = new Account(appwriteClient); -// validation patterns -const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/; - -if (!process.env.JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is not set"); -} +// ==================== HELPER FUNCTIONS ==================== function generateAccessToken(user) { - return jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || "15m" }); + return jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: process.env.JWT_EXPIRES_IN || "15m" } + ); } function generateRefreshToken() { @@ -58,13 +89,68 @@ function sendAuthResponse(res, { message, accessToken, refreshToken, user }) { }); } -// 1. Initiate Signup (Send OTP) +function isOTPRateLimited(email) { + const now = Date.now(); + const key = `otp_${email}`; + const record = otpRateLimiter.get(key); + + if (!record) { + otpRateLimiter.set(key, { count: 1, resetTime: now + OTP_RATE_LIMIT_WINDOW }); + return false; + } + + if (now > record.resetTime) { + otpRateLimiter.set(key, { count: 1, resetTime: now + OTP_RATE_LIMIT_WINDOW }); + return false; + } + + if (record.count >= OTP_RATE_LIMIT_MAX) { + return true; + } + + record.count++; + return false; +} + +function isLoginLocked(email) { + const now = Date.now(); + const record = loginAttempts.get(email); + + if (!record) return false; + if (now > record.lockoutUntil) { + loginAttempts.delete(email); + return false; + } + return true; +} + +function recordLoginFailure(email) { + const now = Date.now(); + const record = loginAttempts.get(email); + + if (!record) { + loginAttempts.set(email, { attempts: 1, lockoutUntil: now + LOGIN_LOCKOUT_DURATION }); + return; + } + + record.attempts++; + if (record.attempts >= MAX_LOGIN_ATTEMPTS) { + record.lockoutUntil = now + LOGIN_LOCKOUT_DURATION; + } +} + +function resetLoginAttempts(email) { + loginAttempts.delete(email); +} + +// ==================== 1. SIGNUP (Send OTP) ==================== const signup = async (req, res) => { try { const { name, email, password } = req.body; const cleanName = sanitizeString(name); const cleanEmail = sanitizeString(email).toLowerCase(); + // Validation if (!cleanName || !cleanEmail || !password) { return res.status(400).json({ success: false, message: "All fields are required" }); } @@ -72,7 +158,18 @@ const signup = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid email format" }); } if (password.length < 8 || !strongPasswordRegex.test(password)) { - return res.status(400).json({ success: false, message: "Password must contain uppercase, lowercase, number and special character and 8 characters" }); + return res.status(400).json({ + success: false, + message: "Password must contain uppercase, lowercase, number and special character and min 8 characters" + }); + } + + // Rate limiting + if (isOTPRateLimited(cleanEmail)) { + return res.status(429).json({ + success: false, + message: "Too many OTP requests. Please wait 5 minutes." + }); } // Check if user already exists in MySQL @@ -81,15 +178,16 @@ const signup = async (req, res) => { return res.status(400).json({ success: false, message: "Email already exists" }); } - // Send OTP via Appwrite Email Token + // Send OTP via Appwrite const token = await appwriteAccount.createEmailToken(ID.unique(), cleanEmail); - // Store pending user + // Store pending user with hashed password + const hashedPassword = await bcrypt.hash(password, 10); pendingSignups.set(cleanEmail, { name: cleanName, - password: password, // Store plain text temporarily, hash before saving to DB + password: hashedPassword, userId: token.userId, - expiresAt: Date.now() + 10 * 60 * 1000 // 10 minutes expiry + expiresAt: Date.now() + OTP_EXPIRY_MINUTES * 60 * 1000 }); return res.status(200).json({ @@ -99,23 +197,28 @@ const signup = async (req, res) => { }); } catch (error) { console.error("SIGNUP OTP ERROR:", error); - return res.status(500).json({ success: false, message: error.message || "Failed to send OTP" }); + return res.status(500).json({ success: false, message: "Failed to send OTP. Please try again." }); } }; -// 2. Verify Signup OTP +// ==================== 2. VERIFY SIGNUP OTP ==================== const verifySignup = async (req, res) => { try { const { email, otp } = req.body; const cleanEmail = sanitizeString(email).toLowerCase(); + // Validate OTP format + if (!otpRegex.test(otp)) { + return res.status(400).json({ success: false, message: "Invalid OTP format. Must be 6 digits." }); + } + const pendingUser = pendingSignups.get(cleanEmail); if (!pendingUser) { return res.status(400).json({ success: false, message: "No pending registration found for this email" }); } if (Date.now() > pendingUser.expiresAt) { pendingSignups.delete(cleanEmail); - return res.status(400).json({ success: false, message: "Expired OTP" }); + return res.status(400).json({ success: false, message: "OTP has expired. Please request a new one." }); } // Verify OTP with Appwrite @@ -123,11 +226,10 @@ const verifySignup = async (req, res) => { try { session = await appwriteAccount.createSession(pendingUser.userId, otp); } catch (err) { - return res.status(400).json({ success: false, message: "Invalid OTP" }); + return res.status(400).json({ success: false, message: "Invalid OTP. Please try again." }); } - // Session created, user verified! - // Initialize user-scoped Appwrite client to update name/password + // Initialize user-scoped Appwrite client const userClient = new Client() .setEndpoint(process.env.VITE_APPWRITE_ENDPOINT || 'https://fra.cloud.appwrite.io/v1') .setProject(process.env.VITE_APPWRITE_PROJECT_ID) @@ -135,52 +237,51 @@ const verifySignup = async (req, res) => { const userAccount = new Account(userClient); const databases = new Databases(userClient); - - // Update Name & Password in Appwrite + + // Update Appwrite profile try { await userAccount.updateName(pendingUser.name); - await userAccount.updatePassword(pendingUser.password); } catch (updateErr) { - console.warn("Failed to update Appwrite profile:", updateErr.message); + console.warn("Failed to update Appwrite name:", updateErr.message); } - // Store in Appwrite Database if configured + // Save to Appwrite Database if configured if (process.env.VITE_APPWRITE_DATABASE_ID && process.env.VITE_APPWRITE_USERS_TABLE_ID) { try { await databases.createDocument( process.env.VITE_APPWRITE_DATABASE_ID, process.env.VITE_APPWRITE_USERS_TABLE_ID, ID.unique(), - { name: pendingUser.name, email: cleanEmail, role: 'user' } + { name: pendingUser.name, email: cleanEmail, role: 'user', isVerified: true } ); } catch (dbErr) { - console.warn("Could not save to Appwrite DB (might lack permissions):", dbErr.message); + console.warn("Could not save to Appwrite DB:", dbErr.message); } } - // Hash password and save to MySQL - const hashedPassword = await bcrypt.hash(pendingUser.password, 10); + // Save to MySQL with email_verified flag await db.query( - `INSERT INTO users (name, email, password, role) VALUES (?, ?, ?, ?)`, - [pendingUser.name, cleanEmail, hashedPassword, "user"] + `INSERT INTO users (name, email, password, role, email_verified) VALUES (?, ?, ?, ?, ?)`, + [pendingUser.name, cleanEmail, pendingUser.password, "user", 1] ); - // Log user out from Appwrite (we will use local JWT for sessions) + // Cleanup Appwrite session try { await userAccount.deleteSession('current'); } catch (logoutErr) { console.warn("Failed to delete Appwrite session:", logoutErr.message); } + pendingSignups.delete(cleanEmail); return res.status(201).json({ success: true, message: "Account created successfully" }); } catch (error) { console.error("VERIFY SIGNUP ERROR:", error); - return res.status(500).json({ success: false, message: "Server error during verification: " + error.message, stack: error.stack }); + return res.status(500).json({ success: false, message: "Server error during verification" }); } }; -// 3. Login +// ==================== 3. LOGIN ==================== const login = async (req, res) => { try { const { email, password } = req.body; @@ -190,9 +291,18 @@ const login = async (req, res) => { return res.status(400).json({ success: false, message: "Email and password required" }); } + // Check login lockout + if (isLoginLocked(cleanEmail)) { + return res.status(429).json({ + success: false, + message: "Too many failed attempts. Account locked for 15 minutes." + }); + } + const [users] = await db.query(`SELECT * FROM users WHERE email = ? LIMIT 1`, [cleanEmail]); if (!safeArray(users).length) { - return res.status(400).json({ success: false, message: "Invalid credentials" }); + recordLoginFailure(cleanEmail); + return res.status(401).json({ success: false, message: "Invalid credentials" }); } const user = users[0]; @@ -202,39 +312,95 @@ const login = async (req, res) => { const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { - return res.status(400).json({ success: false, message: "Invalid credentials" }); + recordLoginFailure(cleanEmail); + return res.status(401).json({ success: false, message: "Invalid credentials" }); } + // Reset login attempts on success + resetLoginAttempts(cleanEmail); + const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(); - await db.query(`UPDATE users SET refresh_token = ? WHERE id = ?`, [refreshToken, user.id]); + // Update refresh token and last login time + await db.query( + `UPDATE users SET refresh_token = ?, last_login = NOW() WHERE id = ?`, + [refreshToken, user.id] + ); - return sendAuthResponse(res, { message: "Login successful", accessToken, refreshToken, user }); + return sendAuthResponse(res, { + message: "Login successful", + accessToken, + refreshToken, + user + }); } catch (error) { console.error("LOGIN ERROR:", error); return res.status(500).json({ success: false, message: "Server error" }); } }; -// 4. Forgot Password (Send OTP) +// ==================== 4. LOGOUT ==================== +const logout = async (req, res) => { + try { + const userId = req.user?.id; + + if (userId) { + // Clear refresh token from database + await db.query(`UPDATE users SET refresh_token = NULL WHERE id = ?`, [userId]); + } + + return res.status(200).json({ + success: true, + message: "Logged out successfully" + }); + } catch (error) { + console.error("LOGOUT ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// ==================== 5. FORGOT PASSWORD ==================== const forgotPassword = async (req, res) => { try { const { email } = req.body; const cleanEmail = sanitizeString(email).toLowerCase(); - const [users] = await db.query(`SELECT id FROM users WHERE email = ? LIMIT 1`, [cleanEmail]); + if (!emailRegex.test(cleanEmail)) { + return res.status(400).json({ success: false, message: "Invalid email format" }); + } + + // Rate limiting + if (isOTPRateLimited(cleanEmail)) { + return res.status(429).json({ + success: false, + message: "Too many OTP requests. Please wait 5 minutes." + }); + } + + const [users] = await db.query(`SELECT id, email_verified FROM users WHERE email = ? LIMIT 1`, [cleanEmail]); if (!safeArray(users).length) { - // Do not reveal if email exists, just say OTP sent for security - return res.status(200).json({ success: true, message: "If the email is registered, an OTP has been sent." }); + // Security: Don't reveal if email exists + return res.status(200).json({ + success: true, + message: "If the email is registered, an OTP has been sent." + }); } - const token = await appwriteAccount.createEmailToken(ID.unique(), cleanEmail); + const user = users[0]; + if (!user.email_verified) { + return res.status(400).json({ + success: false, + message: "Please verify your email first before requesting password reset." + }); + } + + // Send OTP via Appwrite + await appwriteAccount.createEmailToken(ID.unique(), cleanEmail); return res.status(200).json({ success: true, - message: "OTP sent to your email", - userId: token.userId + message: "OTP sent to your email" }); } catch (error) { console.error("FORGOT PASSWORD ERROR:", error); @@ -242,7 +408,7 @@ const forgotPassword = async (req, res) => { } }; -// 5. Reset Password (Verify OTP & Set New Password) +// ==================== 6. RESET PASSWORD ==================== const resetPassword = async (req, res) => { try { const { userId, otp, newPassword } = req.body; @@ -251,8 +417,17 @@ const resetPassword = async (req, res) => { return res.status(400).json({ success: false, message: "Missing required fields" }); } + // Validate OTP format + if (!otpRegex.test(otp)) { + return res.status(400).json({ success: false, message: "Invalid OTP format. Must be 6 digits." }); + } + + // Validate password if (newPassword.length < 8 || !strongPasswordRegex.test(newPassword)) { - return res.status(400).json({ success: false, message: "Password must contain uppercase, lowercase, number and special character and 8 characters" }); + return res.status(400).json({ + success: false, + message: "Password must contain uppercase, lowercase, number and special character and min 8 characters" + }); } // Verify OTP @@ -260,10 +435,10 @@ const resetPassword = async (req, res) => { try { session = await appwriteAccount.createSession(userId, otp); } catch (err) { - return res.status(400).json({ success: false, message: "Invalid OTP or Expired OTP" }); + return res.status(400).json({ success: false, message: "Invalid or expired OTP" }); } - // Use session to fetch user details and update password + // Initialize user-scoped Appwrite client const userClient = new Client() .setEndpoint(process.env.VITE_APPWRITE_ENDPOINT || 'https://fra.cloud.appwrite.io/v1') .setProject(process.env.VITE_APPWRITE_PROJECT_ID) @@ -290,13 +465,65 @@ const resetPassword = async (req, res) => { console.warn("Failed to delete Appwrite session:", logoutErr.message); } - return res.status(200).json({ success: true, message: "Password reset successfully. You can now login." }); + return res.status(200).json({ + success: true, + message: "Password reset successfully. You can now login." + }); } catch (error) { console.error("RESET PASSWORD ERROR:", error); return res.status(500).json({ success: false, message: "Failed to reset password" }); } }; +// ==================== 7. CHANGE PASSWORD ==================== +const changePassword = async (req, res) => { + try { + const userId = req.user?.id; + const { currentPassword, newPassword } = req.body; + + if (!userId) { + return res.status(401).json({ success: false, message: "Unauthorized" }); + } + + if (!currentPassword || !newPassword) { + return res.status(400).json({ success: false, message: "Current password and new password required" }); + } + + // Validate new password + if (newPassword.length < 8 || !strongPasswordRegex.test(newPassword)) { + return res.status(400).json({ + success: false, + message: "Password must contain uppercase, lowercase, number and special character and min 8 characters" + }); + } + + // Get user from database + const [users] = await db.query(`SELECT password FROM users WHERE id = ? LIMIT 1`, [userId]); + if (!safeArray(users).length) { + return res.status(404).json({ success: false, message: "User not found" }); + } + + // Verify current password + const isMatch = await bcrypt.compare(currentPassword, users[0].password); + if (!isMatch) { + return res.status(401).json({ success: false, message: "Current password is incorrect" }); + } + + // Hash and update new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + await db.query(`UPDATE users SET password = ? WHERE id = ?`, [hashedPassword, userId]); + + return res.status(200).json({ + success: true, + message: "Password changed successfully" + }); + } catch (error) { + console.error("CHANGE PASSWORD ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// ==================== 8. REFRESH ACCESS TOKEN ==================== const refreshAccessToken = async (req, res) => { try { const { refreshToken } = req.body; @@ -325,18 +552,26 @@ const refreshAccessToken = async (req, res) => { await db.query(`UPDATE users SET refresh_token = ? WHERE id = ?`, [newRefreshToken, user.id]); - return sendAuthResponse(res, { message: "Token refreshed", accessToken: newAccessToken, refreshToken: newRefreshToken, user }); + return sendAuthResponse(res, { + message: "Token refreshed", + accessToken: newAccessToken, + refreshToken: newRefreshToken, + user + }); } catch (error) { console.error("REFRESH TOKEN ERROR:", error); return res.status(500).json({ success: false, message: "Server error" }); } }; +// ==================== EXPORTS ==================== module.exports = { signup, verifySignup, login, + logout, forgotPassword, resetPassword, + changePassword, refreshAccessToken }; \ No newline at end of file