From 119994b602f675fb79727da996d1ca46e148324b Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 16 Feb 2026 01:43:27 -0800 Subject: [PATCH] feat: add db-backed session cookie auth compatibility --- backend/controllers/auth.controller.js | 32 ++++++++++++++- backend/middleware/auth.js | 47 ++++++++++++++++----- backend/models/session.model.js | 56 ++++++++++++++++++++++++++ backend/routes/auth.routes.js | 1 + backend/utils/cookies.js | 20 +++++++++ backend/utils/session-cookie.js | 36 +++++++++++++++++ 6 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 backend/models/session.model.js create mode 100644 backend/utils/cookies.js create mode 100644 backend/utils/session-cookie.js diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 742e55e..3b809a9 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -2,6 +2,10 @@ const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const User = require("../models/user.model"); const { sendError } = require("../utils/http"); +const Session = require("../models/session.model"); +const { parseCookieHeader } = require("../utils/cookies"); +const { setSessionCookie, clearSessionCookie, cookieName } = require("../utils/session-cookie"); +const { logError } = require("../utils/logger"); exports.register = async (req, res) => { let { username, password, name } = req.body; @@ -15,6 +19,7 @@ exports.register = async (req, res) => { res.json({ message: "User registered", user }); } catch (err) { + logError(req, "auth.register", err); sendError(res, 400, "Registration failed"); } }; @@ -41,5 +46,30 @@ exports.login = async (req, res) => { { expiresIn: "1 year" } ); + try { + const session = await Session.createSession(user.id, req.headers["user-agent"] || null); + setSessionCookie(res, session.id); + } catch (err) { + logError(req, "auth.login.createSession", err); + return sendError(res, 500, "Failed to create session"); + } + res.json({ token, userId: user.id, username, role: user.role }); -}; +}; + +exports.logout = async (req, res) => { + try { + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; + + if (sid) { + await Session.deleteSession(sid); + } + + clearSessionCookie(res); + res.json({ message: "Logged out" }); + } catch (err) { + logError(req, "auth.logout", err); + sendError(res, 500, "Failed to logout"); + } +}; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 1006f50..9702cba 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,19 +1,48 @@ const jwt = require("jsonwebtoken"); const { sendError } = require("../utils/http"); +const Session = require("../models/session.model"); +const { parseCookieHeader } = require("../utils/cookies"); +const { cookieName } = require("../utils/session-cookie"); +const { logError } = require("../utils/logger"); -function auth(req, res, next) { - const header = req.headers.authorization; - if (!header) return sendError(res, 401, "Missing token"); +async function auth(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; - const token = header.split(" ")[1]; - if (!token) return sendError(res, 401, "Invalid token format"); + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; // id + role + return next(); + } catch (err) { + return sendError(res, 401, "Invalid or expired token"); + } + } try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - req.user = decoded; // id + role - next(); + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; + + if (!sid) { + return sendError(res, 401, "Missing authentication"); + } + + const session = await Session.getActiveSessionWithUser(sid); + if (!session) { + return sendError(res, 401, "Invalid or expired session"); + } + + req.user = { + id: session.user_id, + role: session.role, + username: session.username, + }; + req.session_id = session.id; + + return next(); } catch (err) { - sendError(res, 401, "Invalid or expired token"); + logError(req, "middleware.auth", err); + return sendError(res, 500, "Authentication check failed"); } } diff --git a/backend/models/session.model.js b/backend/models/session.model.js new file mode 100644 index 0000000..6eb5988 --- /dev/null +++ b/backend/models/session.model.js @@ -0,0 +1,56 @@ +const crypto = require("crypto"); +const pool = require("../db/pool"); +const { SESSION_TTL_DAYS } = require("../utils/session-cookie"); + +function generateSessionId() { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID().replace(/-/g, "") + crypto.randomBytes(8).toString("hex"); + } + return crypto.randomBytes(32).toString("hex"); +} + +exports.createSession = async (userId, userAgent = null) => { + const id = generateSessionId(); + const result = await pool.query( + `INSERT INTO sessions (id, user_id, expires_at, user_agent) + VALUES ($1, $2, NOW() + ($3 || ' days')::interval, $4) + RETURNING id, user_id, created_at, expires_at`, + [id, userId, String(SESSION_TTL_DAYS), userAgent] + ); + return result.rows[0]; +}; + +exports.getActiveSessionWithUser = async (sessionId) => { + const result = await pool.query( + `SELECT + s.id, + s.user_id, + s.expires_at, + u.username, + u.role + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = $1 + AND s.expires_at > NOW()`, + [sessionId] + ); + + const session = result.rows[0] || null; + if (!session) return null; + + await pool.query( + `UPDATE sessions + SET last_seen_at = NOW() + WHERE id = $1`, + [sessionId] + ); + + return session; +}; + +exports.deleteSession = async (sessionId) => { + await pool.query( + `DELETE FROM sessions WHERE id = $1`, + [sessionId] + ); +}; diff --git a/backend/routes/auth.routes.js b/backend/routes/auth.routes.js index 5d64852..5c87ebb 100644 --- a/backend/routes/auth.routes.js +++ b/backend/routes/auth.routes.js @@ -4,6 +4,7 @@ const User = require("../models/user.model"); router.post("/register", controller.register); router.post("/login", controller.login); +router.post("/logout", controller.logout); router.post("/", async (req, res) => { res.status(200).json({ message: "Auth API is running.", diff --git a/backend/utils/cookies.js b/backend/utils/cookies.js new file mode 100644 index 0000000..7b0469a --- /dev/null +++ b/backend/utils/cookies.js @@ -0,0 +1,20 @@ +function parseCookieHeader(cookieHeader) { + const cookies = {}; + if (!cookieHeader || typeof cookieHeader !== "string") return cookies; + + const segments = cookieHeader.split(";"); + for (const segment of segments) { + const index = segment.indexOf("="); + if (index === -1) continue; + const key = segment.slice(0, index).trim(); + const value = segment.slice(index + 1).trim(); + if (!key) continue; + cookies[key] = decodeURIComponent(value); + } + + return cookies; +} + +module.exports = { + parseCookieHeader, +}; diff --git a/backend/utils/session-cookie.js b/backend/utils/session-cookie.js new file mode 100644 index 0000000..0cd8dbd --- /dev/null +++ b/backend/utils/session-cookie.js @@ -0,0 +1,36 @@ +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sid"; +const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS || 30); + +function sessionMaxAgeMs() { + return SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; +} + +function cookieName() { + return SESSION_COOKIE_NAME; +} + +function setSessionCookie(res, sessionId) { + res.cookie(cookieName(), sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: sessionMaxAgeMs(), + }); +} + +function clearSessionCookie(res) { + res.clearCookie(cookieName(), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); +} + +module.exports = { + SESSION_TTL_DAYS, + clearSessionCookie, + cookieName, + setSessionCookie, +};