gammanexus/server/authRoutes.ts

288 lines
10 KiB
TypeScript

// 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<string, unknown>;
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<string, string>;
// 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<string, string>;
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<string, unknown>;
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<string, unknown>;
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<string, string>;
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<string, unknown>;
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<string, string>;
const session = req.session as Record<string, unknown>;
const updates: Record<string, string> = {};
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<string, string>;
const session = req.session as Record<string, unknown>;
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<string, string>;
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" });
});
}