- requireAuth bearer path now selects api_tokens.bound_hostname and users.role, populates req.tokenBoundHostname and req.user.role. /cluster/heartbeat can now authenticate via a bound api_token (issued via POST /auth/tokens with bound_hostname). - routes/tokens.js POST accepts bound_hostname; GET returns it so users can see which tokens are bound. - Remove /cluster/heartbeat from SERVICE_PATHS so requireAuth runs on it (the bearer auth handles the gate; the heartbeat handler still enforces the body.hostname === bound match). - /auth/me now returns role (final-review I2). Closes the gap where every signed-in user appeared as 'viewer' in the UI regardless of actual role. - loadUser SELECTs role for session auth. - Backend tests still 37/15/0/22 — no test changes needed; existing token CRUD tests stay passing since bound_hostname is optional.
98 lines
4.2 KiB
JavaScript
98 lines
4.2 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-000000000000';
|
|
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, role 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, 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' });
|
|
}
|
|
|
|
// 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();
|
|
// 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' });
|
|
}
|