2026-05-27 13:59:50 -04:00
|
|
|
import pool from '../db/pool.js';
|
|
|
|
|
import { parseBearer, hashToken } from '../auth/tokens.js';
|
|
|
|
|
|
|
|
|
|
// Stable UUID matching migration 023's seeded dev user.
|
2026-05-27 14:04:15 -04:00
|
|
|
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
2026-05-27 13:59:50 -04:00
|
|
|
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);
|
2026-05-27 14:04:15 -04:00
|
|
|
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
|
2026-05-27 13:59:50 -04:00
|
|
|
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' });
|
|
|
|
|
}
|
2026-05-27 14:58:02 -04:00
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
|
2026-05-27 15:42:42 -04:00
|
|
|
// 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']);
|
|
|
|
|
|
2026-05-27 14:58:02 -04:00
|
|
|
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();
|
2026-05-27 15:42:42 -04:00
|
|
|
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
|
|
|
|
|
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
|
2026-05-27 14:58:02 -04:00
|
|
|
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
|
|
|
|
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
|
|
|
|
}
|