dragonflight/services/mam-api/src/middleware/auth.js
Zac Gaetano 03d0d098f5 fix(auth): final-review integration fixes — Users page alias + PATCH, CSRF on uploads + heartbeat, drop .bak
Final-review findings:
- Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the
  existing SPA Users page works; add PATCH /:id for inline edits (display_name,
  role, password).
- Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass
  apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader
  403s before reaching the route.
- Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent
  heartbeats keep working when NODE_TOKEN is unset.
- Remove stale auth.js.bak.
2026-05-27 15:42:42 -04:00

86 lines
3.6 KiB
JavaScript

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 <form> 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' });
}