204 lines
6.9 KiB
TypeScript
204 lines
6.9 KiB
TypeScript
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
import * as bcrypt from "bcryptjs";
|
|
import type { Express, Request, Response } from "express";
|
|
import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword } from "./db";
|
|
import { sanitizeInput } from "./middleware";
|
|
|
|
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,
|
|
});
|
|
|
|
const session = req.session as Record<string, unknown>;
|
|
session.userId = user.id;
|
|
|
|
res.status(201).json({ id: user.id, name: user.name, email: user.email });
|
|
} 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" });
|
|
}
|
|
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// 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 });
|
|
} 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" });
|
|
});
|
|
}
|