import pool from '../db/pool.js'; import { parseBearer, hashToken } from '../auth/tokens.js'; // Stable UUID matching migration 023's seeded dev user. /** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */ export const DEV_USER_ID = '00000000-0000-4000-8000-000000000dev'; export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' }; const ABSOLUTE_MS = 8 * 3600 * 1000; const IDLE_MS = 1 * 3600 * 1000; async function destroyAnd401(req, res) { if (req.session?.destroy) { await new Promise(r => req.session.destroy(() => r())); } return res.status(401).json({ error: 'unauthorized' }); } async function loadUser(id) { const { rows } = await pool.query( `SELECT id, username, display_name FROM users WHERE id = $1`, [id]); return rows[0] || null; } export async function requireAuth(req, res, next) { // Dev mode — attach the seeded dev user so FK-bearing routes work. if (process.env.AUTH_ENABLED !== 'true') { req.user = DEV_USER; return next(); } // 1. Session if (req.session?.user_id) { const now = Date.now(); const first = req.session.first_seen_at || 0; const last = req.session.last_seen_at || 0; if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res); if (now - last > IDLE_MS) return destroyAnd401(req, res); const u = await loadUser(req.session.user_id); if (!u) return destroyAnd401(req, res); req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted req.user = u; return next(); } // 2. Bearer const bearer = parseBearer(req.headers.authorization); if (bearer) { const hash = hashToken(bearer); const { rows } = await pool.query( `SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = $1`, [hash]); if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) { pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id]) .catch(err => console.error('[auth] token last_used_at update failed:', err.message)); req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name }; return next(); } } // 3. Nothing matched return res.status(401).json({ error: 'unauthorized' }); } // Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site // cookie sends, but a custom header that no
can produce hardens // against the edge cases. Applied to mutating verbs only. const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const REQUIRED_HEADER = 'dragonflight-ui'; // Paths exempt from the CSRF header check. Must match the SERVICE_PATHS set // in index.js — these are non-browser service-to-service calls (node-agent // heartbeat) where the CSRF protection doesn't apply. const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']); export function requireUiHeader(req, res, next) { if (!MUTATING.has(req.method)) return next(); // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not // browsers and can't be drive-by'd from another origin. if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next(); // Service path carve-outs (e.g. node-agent heartbeat — not a browser). if (CSRF_EXEMPT_PATHS.has(req.path)) return next(); if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next(); return res.status(403).json({ error: 'missing X-Requested-With header' }); }