283 lines
9.7 KiB
TypeScript
283 lines
9.7 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
|
|
await sendVerificationEmail(sanitizedEmail, sanitizedName, token);
|
|
|
|
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);
|
|
await sendVerificationEmail(user.email, user.name, token);
|
|
|
|
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" });
|
|
});
|
|
}
|