const express = require("express"); const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); const { User, AlphaInvitation } = require("../models"); // Import from models/index.js to get models with associations const logger = require("../utils/logger"); const emailServices = require("../services/email"); const crypto = require("crypto"); const { sanitizeInput, validateRegistration, validateLogin, validateGoogleAuth, validateForgotPassword, validateResetPassword, validateVerifyResetToken, } = require("../middleware/validation"); const { csrfProtection, getCSRFToken } = require("../middleware/csrf"); const { loginLimiter, registerLimiter, passwordResetLimiter, emailVerificationLimiter, } = require("../middleware/rateLimiter"); const { authenticateToken } = require("../middleware/auth"); const router = express.Router(); const googleClient = new OAuth2Client( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback" ); // Get CSRF token endpoint router.get("/csrf-token", (req, res) => { getCSRFToken(req, res); }); router.post( "/register", registerLimiter, csrfProtection, sanitizeInput, validateRegistration, async (req, res) => { try { const { email, password, firstName, lastName, phone } = req.body; const existingUser = await User.findOne({ where: { email }, }); if (existingUser) { return res.status(400).json({ error: "Registration failed", details: [ { field: "email", message: "An account with this email already exists", }, ], }); } // Alpha access validation let alphaInvitation = null; if (process.env.ALPHA_TESTING_ENABLED === "true") { if (req.cookies && req.cookies.alphaAccessCode) { const { code } = req.cookies.alphaAccessCode; if (code) { alphaInvitation = await AlphaInvitation.findOne({ where: { code }, }); if (!alphaInvitation) { return res.status(403).json({ error: "Invalid alpha access code", }); } if (alphaInvitation.status === "revoked") { return res.status(403).json({ error: "This alpha access code is no longer valid", }); } } } if (!alphaInvitation) { return res.status(403).json({ error: "Alpha access required. Please enter your invitation code first.", }); } } const user = await User.create({ email, password, firstName, lastName, phone, }); // Link alpha invitation to user (only if alpha testing is enabled) if (alphaInvitation) { await alphaInvitation.update({ usedBy: user.id, usedAt: new Date(), status: "active", }); } // Generate verification token and send email await user.generateVerificationToken(); // Send verification email (don't block registration if email fails) let verificationEmailSent = false; try { await emailServices.auth.sendVerificationEmail( user, user.verificationToken ); verificationEmailSent = true; } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send verification email", { error: emailError.message, userId: user.id, email: user.email, }); // Continue with registration even if email fails } const token = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion }, process.env.JWT_ACCESS_SECRET, { expiresIn: "15m" } // Short-lived access token ); const refreshToken = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" } ); // Set tokens as httpOnly cookies res.cookie("accessToken", token, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 15 * 60 * 1000, // 15 minutes }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("User registration successful", { userId: user.id, email: user.email, }); res.status(201).json({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, isVerified: user.isVerified, role: user.role, }, verificationEmailSent, // Don't send token in response body for security }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Registration error", { error: error.message, stack: error.stack, email: req.body.email, }); res.status(500).json({ error: "Registration failed. Please try again." }); } } ); router.post( "/login", loginLimiter, csrfProtection, sanitizeInput, validateLogin, async (req, res) => { try { const { email, password } = req.body; const user = await User.findOne({ where: { email } }); if (!user) { return res.status(401).json({ error: "Unable to log in. Please check your email and password, or create an account.", }); } // Check if account is locked if (user.isLocked()) { return res.status(423).json({ error: "Account is temporarily locked due to too many failed login attempts. Please try again later.", }); } // Verify password const isPasswordValid = await user.comparePassword(password); if (!isPasswordValid) { // Increment login attempts await user.incLoginAttempts(); return res.status(401).json({ error: "Unable to log in. Please check your email and password, or create an account.", }); } // Reset login attempts on successful login await user.resetLoginAttempts(); const token = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion }, process.env.JWT_ACCESS_SECRET, { expiresIn: "15m" } // Short-lived access token ); const refreshToken = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" } ); // Set tokens as httpOnly cookies res.cookie("accessToken", token, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 15 * 60 * 1000, // 15 minutes }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("User login successful", { userId: user.id, email: user.email, }); res.json({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, isVerified: user.isVerified, role: user.role, }, // Don't send token in response body for security }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Login error", { error: error.message, stack: error.stack, email: req.body.email, }); res.status(500).json({ error: "Login failed. Please try again." }); } } ); router.post( "/google", loginLimiter, csrfProtection, sanitizeInput, validateGoogleAuth, async (req, res) => { try { const { code } = req.body; if (!code) { return res .status(400) .json({ error: "Authorization code is required" }); } // Exchange authorization code for tokens const { tokens } = await googleClient.getToken({ code, redirect_uri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback", }); // Verify the ID token from the token response const ticket = await googleClient.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID, }); const payload = ticket.getPayload(); const { sub: googleId, email, given_name: givenName, family_name: familyName, picture, } = payload; if (!email) { return res.status(400).json({ error: "Email permission is required to continue. Please grant email access when signing in with Google and try again.", }); } // Handle cases where Google doesn't provide name fields // Generate fallback values from email or use placeholder let firstName = givenName; let lastName = familyName; if (!firstName || !lastName) { const emailUsername = email.split("@")[0]; // Try to split email username by common separators const nameParts = emailUsername.split(/[._-]/); if (!firstName) { firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : "Google"; } if (!lastName) { lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1) : "User"; } } // Check if user exists by Google ID first let user = await User.findOne({ where: { providerId: googleId, authProvider: "google" }, }); if (!user) { // Check if user exists with same email but different auth provider const existingUser = await User.findOne({ where: { email } }); if (existingUser) { return res.status(409).json({ error: "An account with this email already exists. Please use email/password login.", }); } // Create new user (Google OAuth users are auto-verified) user = await User.create({ email, firstName, lastName, authProvider: "google", providerId: googleId, imageFilename: picture, isVerified: true, verifiedAt: new Date(), }); // Check if there's an alpha invitation for this email if (process.env.ALPHA_TESTING_ENABLED === "true") { const alphaInvitation = await AlphaInvitation.findOne({ where: { email: email.toLowerCase().trim() }, }); if (alphaInvitation && !alphaInvitation.usedBy) { // Link invitation to new user await alphaInvitation.update({ usedBy: user.id, usedAt: new Date(), status: "active", }); } } } // Generate JWT tokens const token = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion }, process.env.JWT_ACCESS_SECRET, { expiresIn: "15m" } ); const refreshToken = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" } ); // Set tokens as httpOnly cookies res.cookie("accessToken", token, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 15 * 60 * 1000, }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 7 * 24 * 60 * 60 * 1000, }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Google authentication successful", { userId: user.id, email: user.email, isNewUser: !user.createdAt || Date.now() - new Date(user.createdAt).getTime() < 1000, }); res.json({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, imageFilename: user.imageFilename, isVerified: user.isVerified, role: user.role, }, // Don't send token in response body for security }); } catch (error) { if (error.message && error.message.includes("invalid_grant")) { return res.status(401).json({ error: "Invalid or expired authorization code. Please try again.", }); } if (error.message && error.message.includes("redirect_uri_mismatch")) { return res .status(400) .json({ error: "Redirect URI mismatch. Please contact support." }); } const reqLogger = logger.withRequestId(req.id); reqLogger.error("Google OAuth error", { error: error.message, stack: error.stack, codePresent: !!req.body.code, }); res .status(500) .json({ error: "Google authentication failed. Please try again." }); } } ); // Email verification endpoint router.post( "/verify-email", emailVerificationLimiter, authenticateToken, sanitizeInput, async (req, res) => { try { const { code } = req.body; if (!code) { return res.status(400).json({ error: "Verification code required", code: "CODE_REQUIRED", }); } // Validate 6-digit format if (!/^\d{6}$/.test(code)) { return res.status(400).json({ error: "Verification code must be 6 digits", code: "INVALID_CODE_FORMAT", }); } // Get the authenticated user const user = await User.findByPk(req.user.id); if (!user) { return res.status(404).json({ error: "User not found", code: "USER_NOT_FOUND", }); } // Check if already verified if (user.isVerified) { return res.status(400).json({ error: "Email already verified", code: "ALREADY_VERIFIED", }); } // Check if too many failed attempts if (user.isVerificationLocked()) { return res.status(429).json({ error: "Too many verification attempts. Please request a new code.", code: "TOO_MANY_ATTEMPTS", }); } // Check if user has a verification token if (!user.verificationToken) { return res.status(400).json({ error: "No verification code found. Please request a new one.", code: "NO_CODE", }); } // Check if code is expired if ( user.verificationTokenExpiry && new Date() > new Date(user.verificationTokenExpiry) ) { return res.status(400).json({ error: "Verification code has expired. Please request a new one.", code: "VERIFICATION_EXPIRED", }); } // Validate the code if (!user.isVerificationTokenValid(code)) { // Increment failed attempts await user.incrementVerificationAttempts(); const reqLogger = logger.withRequestId(req.id); reqLogger.warn("Invalid verification code attempt", { userId: user.id, attempts: user.verificationAttempts + 1, }); return res.status(400).json({ error: "Invalid verification code", code: "VERIFICATION_INVALID", }); } // Verify the email await user.verifyEmail(); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Email verified successfully", { userId: user.id, email: user.email, }); res.json({ message: "Email verified successfully", user: { id: user.id, email: user.email, isVerified: true, }, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Email verification error", { error: error.message, stack: error.stack, }); res.status(500).json({ error: "Email verification failed. Please try again.", }); } } ); // Resend verification email endpoint router.post( "/resend-verification", loginLimiter, // Use login limiter for rate limiting (max 3 per hour) sanitizeInput, async (req, res) => { try { // Get user from cookies const { accessToken } = req.cookies; if (!accessToken) { return res.status(401).json({ error: "Authentication required", code: "NO_TOKEN", }); } const decoded = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET); const user = await User.findByPk(decoded.id); if (!user) { return res.status(404).json({ error: "User not found", code: "USER_NOT_FOUND", }); } // Check if already verified if (user.isVerified) { return res.status(400).json({ error: "Email already verified", code: "ALREADY_VERIFIED", }); } // Generate new verification token await user.generateVerificationToken(); // Send verification email try { await emailServices.auth.sendVerificationEmail( user, user.verificationToken ); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to resend verification email", { error: emailError.message, userId: user.id, email: user.email, }); return res.status(500).json({ error: "Failed to send verification email. Please try again.", }); } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Verification email resent", { userId: user.id, email: user.email, }); res.json({ message: "Verification email sent successfully", }); } catch (error) { if (error.name === "TokenExpiredError") { return res.status(401).json({ error: "Session expired. Please log in again.", code: "TOKEN_EXPIRED", }); } const reqLogger = logger.withRequestId(req.id); reqLogger.error("Resend verification error", { error: error.message, stack: error.stack, }); res.status(500).json({ error: "Failed to resend verification email. Please try again.", }); } } ); // Refresh token endpoint router.post("/refresh", async (req, res) => { try { const { refreshToken } = req.cookies; if (!refreshToken) { return res.status(401).json({ error: "Refresh token required" }); } // Verify refresh token const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); if (!decoded.id || decoded.type !== "refresh") { return res.status(401).json({ error: "Invalid refresh token" }); } // Find user const user = await User.findByPk(decoded.id); if (!user) { return res.status(401).json({ error: "User not found" }); } // Validate JWT version to invalidate old tokens after password change if (decoded.jwtVersion !== user.jwtVersion) { return res.status(401).json({ error: "Session expired due to password change. Please log in again.", code: "JWT_VERSION_MISMATCH", }); } // Generate new access token const newAccessToken = jwt.sign( { id: user.id, jwtVersion: user.jwtVersion }, process.env.JWT_ACCESS_SECRET, { expiresIn: "15m" } ); // Set new access token cookie res.cookie("accessToken", newAccessToken, { httpOnly: true, secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", sameSite: "strict", maxAge: 15 * 60 * 1000, }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Token refresh successful", { userId: user.id, }); res.json({ user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, isVerified: user.isVerified, role: user.role, }, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Token refresh error", { error: error.message, stack: error.stack, userId: req.user?.id, }); res.status(401).json({ error: "Invalid or expired refresh token" }); } }); // Logout endpoint router.post("/logout", (req, res) => { const reqLogger = logger.withRequestId(req.id); reqLogger.info("User logout", { userId: req.user?.id || "anonymous", }); // Clear cookies res.clearCookie("accessToken"); res.clearCookie("refreshToken"); res.json({ message: "Logged out successfully" }); }); // Auth status check endpoint - returns 200 regardless of auth state const { optionalAuth } = require("../middleware/auth"); router.get("/status", optionalAuth, async (req, res) => { if (req.user) { res.json({ authenticated: true, user: req.user, }); } else { res.json({ authenticated: false, }); } }); // Forgot password endpoint router.post( "/forgot-password", passwordResetLimiter, csrfProtection, sanitizeInput, validateForgotPassword, async (req, res) => { try { const { email } = req.body; // Find user with local auth provider only const user = await User.findOne({ where: { email, authProvider: "local", }, }); // Always return success to prevent email enumeration // Don't reveal whether the email exists or not if (user) { // Generate password reset token (returns plain token for email) const resetToken = await user.generatePasswordResetToken(); // Send password reset email try { await emailServices.auth.sendPasswordResetEmail(user, resetToken); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Password reset email sent", { userId: user.id, email: user.email, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send password reset email", { error: emailError.message, userId: user.id, email: user.email, }); // Continue - don't reveal email sending failure to user } } else { const reqLogger = logger.withRequestId(req.id); reqLogger.info( "Password reset requested for non-existent or OAuth user", { email: email, } ); } // Always return success message (security best practice) res.json({ message: "If an account exists with that email, you will receive password reset instructions.", }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forgot password error", { error: error.message, stack: error.stack, email: req.body.email, }); res.status(500).json({ error: "Failed to process password reset request. Please try again.", }); } } ); // Verify reset token endpoint (optional - for frontend UX) router.post( "/verify-reset-token", sanitizeInput, validateVerifyResetToken, async (req, res) => { try { const { token } = req.body; // Hash the token to search for it in the database const hashedToken = crypto .createHash("sha256") .update(token) .digest("hex"); // Find user with this reset token (hashed) const user = await User.findOne({ where: { passwordResetToken: hashedToken }, }); if (!user) { return res.status(400).json({ valid: false, error: "Invalid reset token", code: "TOKEN_INVALID", }); } // Check if token is valid (not expired) if (!user.isPasswordResetTokenValid(token)) { return res.status(400).json({ valid: false, error: "Reset token has expired. Please request a new one.", code: "TOKEN_EXPIRED", }); } res.json({ valid: true, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Verify reset token error", { error: error.message, stack: error.stack, }); res.status(500).json({ valid: false, error: "Failed to verify reset token. Please try again.", }); } } ); // Reset password endpoint router.post( "/reset-password", passwordResetLimiter, csrfProtection, sanitizeInput, validateResetPassword, async (req, res) => { try { const { token, newPassword } = req.body; // Hash the token to search for it in the database const crypto = require("crypto"); const hashedToken = crypto .createHash("sha256") .update(token) .digest("hex"); // Find user with this reset token (hashed) const user = await User.findOne({ where: { passwordResetToken: hashedToken }, }); if (!user) { return res.status(400).json({ error: "Invalid or expired reset token", code: "TOKEN_INVALID", }); } // Check if token is valid (not expired) if (!user.isPasswordResetTokenValid(token)) { return res.status(400).json({ error: "Reset token has expired. Please request a new one.", code: "TOKEN_EXPIRED", }); } // Reset password (this will clear the token and hash the new password) await user.resetPassword(newPassword); // Send password changed notification email try { await emailServices.auth.sendPasswordChangedEmail(user); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Password changed notification sent", { userId: user.id, email: user.email, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send password changed notification", { error: emailError.message, userId: user.id, email: user.email, }); // Continue - don't fail password reset if email fails } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Password reset successful", { userId: user.id, email: user.email, }); res.json({ message: "Password has been reset successfully. You can now log in with your new password.", }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Reset password error", { error: error.message, stack: error.stack, }); res.status(500).json({ error: "Failed to reset password. Please try again.", }); } } ); module.exports = router;