// eslint-disable-next-line @typescript-eslint/no-var-requires import * as bcrypt from "bcryptjs"; import * as crypto from "crypto"; import type { Express, Request, Response } from "express"; import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword, updateUserVerification, getUserByVerificationToken, markEmailVerified } from "./db"; import { sanitizeInput } from "./middleware"; import { sendVerificationEmail } from "./email"; export function requireAuth(req: Request, res: Response, next: () => void) { const session = req.session as Record; if (!session?.userId) { return res.status(401).json({ error: "Not authenticated" }); } next(); } export function registerAuthRoutes(app: Express) { // POST /api/auth/register app.post("/api/auth/register", async (req: Request, res: Response) => { const { name, email, password } = req.body as Record; // Validate required fields if (!name || !email || !password) { return res.status(400).json({ error: "Name, email, and password are required" }); } // Validate password complexity if (password.length < 8) { return res.status(400).json({ error: "Password must be at least 8 characters long", }); } // Sanitize inputs to prevent XSS const sanitizedName = sanitizeInput(name); const sanitizedEmail = sanitizeInput(email).toLowerCase(); // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(sanitizedEmail)) { return res.status(400).json({ error: "Invalid email format" }); } try { const existing = await getUserByEmail(sanitizedEmail); if (existing) { return res.status(409).json({ error: "Email already in use" }); } const hash = await bcrypt.hash(password, 12); const user = await createUser({ name: sanitizedName, email: sanitizedEmail, passwordHash: hash, }); // Generate verification token const token = crypto.randomUUID(); const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours await updateUserVerification(user.id, token, expiry); // Send verification email (fire-and-forget — don't block registration on SMTP failure) sendVerificationEmail(sanitizedEmail, sanitizedName, token).catch((err) => { console.error("[Auth] Verification email failed (non-blocking):", err); }); res.status(201).json({ ok: true, message: "Registration successful. Please check your email to verify your account.", email: sanitizedEmail, }); } catch (error) { console.error("Registration error:", error); res.status(500).json({ error: "Internal server error" }); } }); // POST /api/auth/login app.post("/api/auth/login", async (req: Request, res: Response) => { const { email, password } = req.body as Record; if (!email || !password) { return res.status(400).json({ error: "Email and password are required" }); } try { const user = await getUserByEmail(email.toLowerCase()); if (!user) { // Use same error message to prevent email enumeration await bcrypt.hash("dummy", 12); // Constant time operation return res.status(401).json({ error: "Invalid email or password" }); } const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { return res.status(401).json({ error: "Invalid email or password" }); } // Check if email is verified if (!user.emailVerified) { return res.status(403).json({ error: "Please verify your email before logging in. Check your inbox.", needsVerification: true, }); } const session = req.session as Record; session.userId = user.id; res.json({ id: user.id, name: user.name, email: user.email }); } catch (error) { console.error("Login error:", error); res.status(500).json({ error: "Internal server error" }); } }); // GET /api/auth/verify/:token app.get("/api/auth/verify/:token", async (req: Request, res: Response) => { const { token } = req.params; try { const user = await getUserByVerificationToken(token); if (!user) { return res.status(400).json({ error: "Invalid or expired verification link. Please register again.", }); } await markEmailVerified(user.id); // Auto-login after verification const session = req.session as Record; session.userId = user.id; res.json({ ok: true, message: "Email verified successfully!", user: { id: user.id, name: user.name, email: user.email }, }); } catch (error) { console.error("Verification error:", error); res.status(500).json({ error: "Internal server error" }); } }); // POST /api/auth/resend-verification (optional: resend verification email) app.post("/api/auth/resend-verification", async (req: Request, res: Response) => { const { email } = req.body as Record; if (!email) { return res.status(400).json({ error: "Email is required" }); } try { const user = await getUserByEmail(email.toLowerCase()); if (!user) { // Don't reveal whether account exists return res.json({ ok: true, message: "If an account exists, a verification email has been sent." }); } if (user.emailVerified) { return res.json({ ok: true, message: "Email is already verified." }); } const token = crypto.randomUUID(); const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); await updateUserVerification(user.id, token, expiry); // Fire-and-forget — don't block response on SMTP failure sendVerificationEmail(user.email, user.name, token).catch((err) => { console.error("[Auth] Resend verification email failed (non-blocking):", err); }); res.json({ ok: true, message: "Verification email sent. Please check your inbox." }); } catch (error) { console.error("Resend verification error:", error); res.status(500).json({ error: "Internal server error" }); } }); // POST /api/auth/logout app.post("/api/auth/logout", (req: Request, res: Response) => { req.session.destroy(() => { res.json({ ok: true }); }); }); // GET /api/auth/me app.get("/api/auth/me", requireAuth, async (req: Request, res: Response) => { try { const session = req.session as Record; const user = await getUserById(session.userId as number); if (!user) { req.session.destroy(); return res.status(401).json({ error: "Not authenticated" }); } res.json({ id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified }); } catch (error) { console.error("Get user error:", error); res.status(500).json({ error: "Internal server error" }); } }); // PUT /api/auth/profile app.put("/api/auth/profile", requireAuth, async (req: Request, res: Response) => { const { name, email } = req.body as Record; const session = req.session as Record; const updates: Record = {}; if (name) updates.name = sanitizeInput(name); if (email) { const sanitizedEmail = sanitizeInput(email).toLowerCase(); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(sanitizedEmail)) { return res.status(400).json({ error: "Invalid email format" }); } try { const existing = await getUserByEmail(sanitizedEmail); if (existing && existing.id !== session.userId) { return res.status(409).json({ error: "Email already in use" }); } updates.email = sanitizedEmail; } catch (error) { console.error("Email check error:", error); return res.status(500).json({ error: "Internal server error" }); } } if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "No valid fields to update" }); } try { const user = await updateUser(session.userId as number, updates); res.json({ id: user.id, name: user.name, email: user.email }); } catch (error) { console.error("Update profile error:", error); res.status(500).json({ error: "Internal server error" }); } }); // PUT /api/auth/password app.put("/api/auth/password", requireAuth, async (req: Request, res: Response) => { const { currentPassword, newPassword } = req.body as Record; const session = req.session as Record; if (!currentPassword || !newPassword) { return res.status(400).json({ error: "Current and new password are required" }); } // Validate new password complexity if (newPassword.length < 8) { return res.status(400).json({ error: "New password must be at least 8 characters long", }); } try { const user = await getUserById(session.userId as number); if (!user) { return res.status(401).json({ error: "Not authenticated" }); } const valid = await bcrypt.compare(currentPassword, user.passwordHash); if (!valid) { return res.status(401).json({ error: "Current password is incorrect" }); } const hash = await bcrypt.hash(newPassword, 12); await updateUserPassword(session.userId as number, hash); res.json({ ok: true }); } catch (error) { console.error("Password change error:", error); res.status(500).json({ error: "Internal server error" }); } }); // POST /api/auth/request-reset (stub) app.post("/api/auth/request-reset", (req: Request, res: Response) => { const { email } = req.body as Record; if (!email) { return res.status(400).json({ error: "Email is required" }); } // Always return success to prevent email enumeration res.json({ ok: true, message: "If an account exists, a reset link would be sent" }); }); }