feat(auth): bound-hostname tokens for node-agent + return role from /me
- 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.
This commit is contained in:
parent
e6da1432e5
commit
8028c4c4dd
4 changed files with 41 additions and 23 deletions
|
|
@ -93,18 +93,13 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||||
// Service-auth carve-outs: node-agent uses migration 019's bound-hostname
|
||||
// api_token mechanism, not user auth. Today only /cluster/heartbeat is
|
||||
// reached without a user session — operator/UI endpoints in cluster.js
|
||||
// (containers restart, DELETE /:id, blackmagic device queries) ARE expected
|
||||
// to require auth. If node-agent grows another endpoint, add it here.
|
||||
// TODO: long-term, issue node-agent a real bound api_token and drop this carve-out.
|
||||
const SERVICE_PATHS = new Set(['/cluster/heartbeat']);
|
||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||
// routes/cluster.js verifies body.hostname matches that binding.
|
||||
app.use('/api/v1', requireUiHeader);
|
||||
// then the existing gate:
|
||||
app.use('/api/v1', (req, res, next) => {
|
||||
if (UNAUTH_PATHS.has(req.path)) return next();
|
||||
if (SERVICE_PATHS.has(req.path)) return next();
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ async function destroyAnd401(req, res) {
|
|||
|
||||
async function loadUser(id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name FROM users WHERE id = $1`, [id]);
|
||||
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
|
@ -48,13 +48,23 @@ export async function requireAuth(req, res, next) {
|
|||
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
|
||||
`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 };
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -69,9 +79,11 @@ export async function requireAuth(req, res, next) {
|
|||
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.
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,12 @@ router.post('/logout', (req, res) => {
|
|||
|
||||
// GET /api/v1/auth/me
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name });
|
||||
res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
display_name: req.user.display_name,
|
||||
role: req.user.role,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/password { current_password, new_password }
|
||||
|
|
|
|||
|
|
@ -10,24 +10,30 @@ const router = express.Router();
|
|||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at
|
||||
`SELECT id, name, token_prefix AS prefix, bound_hostname, last_used_at, expires_at, created_at
|
||||
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[req.user.id]);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST / — create a new token
|
||||
// POST / — create a new token. Optional bound_hostname binds the token to a
|
||||
// specific node-agent hostname (migration 019) — the /cluster/heartbeat handler
|
||||
// rejects heartbeats whose body.hostname doesn't match a bound token.
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, expires_at } = req.body || {};
|
||||
const { name, expires_at, bound_hostname } = req.body || {};
|
||||
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
|
||||
if (bound_hostname !== undefined && bound_hostname !== null && typeof bound_hostname !== 'string') {
|
||||
return res.status(400).json({ error: 'bound_hostname must be a string' });
|
||||
}
|
||||
const raw = generateToken();
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, token_prefix AS prefix, expires_at, created_at`,
|
||||
[req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]);
|
||||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, token_prefix AS prefix, bound_hostname, expires_at, created_at`,
|
||||
[req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw),
|
||||
expires_at || null, bound_hostname?.trim() || null]);
|
||||
res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue