From d1156745cabb6f6963e0b249d404b9809efcd759 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:26:49 -0400 Subject: [PATCH] feat: add register, login, logout, and me auth endpoints --- server/src/routes/auth.ts | 114 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 541877b..de5c461 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,3 +1,117 @@ import { Router } from "express"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import bcrypt from "bcrypt"; +import db from "../db.js"; +import { signToken, COOKIE_NAME } from "../auth/jwt.js"; +import { requireAuth } from "../auth/middleware.js"; + const router = Router(); +const BCRYPT_ROUNDS = 12; +const COOKIE_OPTS = { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms +}; + +// POST /api/auth/register +router.post("/register", async (req, res) => { + try { + const { email, username, password } = req.body; + + if (!email?.trim() || !username?.trim() || !password) { + res.status(400).json({ error: "email, username, and password are required" }); + return; + } + if (password.length < 8) { + res.status(400).json({ error: "Password must be at least 8 characters" }); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "Invalid email format" }); + return; + } + + const [existing] = await db.execute( + "SELECT id FROM users WHERE email = ?", + [email.trim().toLowerCase()] + ); + if (existing.length > 0) { + res.status(409).json({ error: "Email already registered" }); + return; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const [result] = await db.execute( + "INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)", + [email.trim().toLowerCase(), username.trim(), passwordHash] + ); + + const token = signToken({ + userId: result.insertId, + email: email.trim().toLowerCase(), + username: username.trim(), + }); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.status(201).json({ + userId: result.insertId, + email: email.trim().toLowerCase(), + username: username.trim(), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST /api/auth/login +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email?.trim() || !password) { + res.status(400).json({ error: "email and password are required" }); + return; + } + + const [rows] = await db.execute( + "SELECT id, email, username, password_hash FROM users WHERE email = ?", + [email.trim().toLowerCase()] + ); + if (rows.length === 0) { + res.status(401).json({ error: "Invalid email or password" }); + return; + } + + const user = rows[0]; + const valid = await bcrypt.compare(password, user.password_hash as string); + if (!valid) { + res.status(401).json({ error: "Invalid email or password" }); + return; + } + + const token = signToken({ + userId: user.id as number, + email: user.email as string, + username: user.username as string, + }); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.json({ userId: user.id, email: user.email, username: user.username }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST /api/auth/logout +router.post("/logout", (_req, res) => { + res.clearCookie(COOKIE_NAME); + res.json({ ok: true }); +}); + +// GET /api/auth/me +router.get("/me", requireAuth, (req, res) => { + res.json(req.user); +}); + export default router;