dragonflight/services/mam-api/src/middleware/auth.js

135 lines
5.9 KiB
JavaScript
Raw Normal View History

import crypto from 'crypto';
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';
// In-process service token for the scheduler's loopback self-calls
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
// a per-boot random constant needs no env/compose config and is never exposed:
// it only travels over the loopback fetch inside the same process. Multi-replica
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
// matching that replica's token. Requests bearing it are treated as the seeded
// admin (DEV_USER) so RBAC + FK-bearing routes work.
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
const INTERNAL_HEADER = 'x-internal-token';
function isInternalCall(req) {
const got = req.headers[INTERNAL_HEADER];
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
}
// 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-000000000000';
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
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, role, totp_enabled FROM users WHERE id = $1`, [id]);
return rows[0] || null;
}
export async function requireAuth(req, res, next) {
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
// and FK-bearing routes work, regardless of AUTH_ENABLED.
if (isInternalCall(req)) {
req.user = DEV_USER;
return 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, t.bound_hostname,
u.username, u.display_name, u.role
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,
role: rows[0].role,
};
// Per migration 019: tokens with a bound_hostname can only be used by
// node-agents reporting that hostname. The /cluster/heartbeat handler
// enforces this; we just surface the binding here.
if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
return next();
}
}
// 3. Nothing matched
return res.status(401).json({ error: 'unauthorized' });
}
// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
return next();
}
// 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. The bearer-auth exemption (above)
// already covers node-agent because it sends Authorization: Bearer; this set
// is the belt for any future service path that might call us without a
// bearer header. Today it just lets an unauthenticated heartbeat probe
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
export function requireUiHeader(req, res, next) {
if (!MUTATING.has(req.method)) return next();
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
if (isInternalCall(req)) 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' });
}