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

64 lines
2.2 KiB
JavaScript

/**
* Authentication middleware.
*
* When AUTH_ENABLED=true in the environment, every protected route requires
* either:
* - An active session (set by POST /api/v1/auth/login), or
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
*
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
* the stack can be run without user accounts during development.
*/
import crypto from 'crypto';
import pool from '../db/pool.js';
export const requireAuth = async (req, res, next) => {
if (process.env.AUTH_ENABLED !== 'true') return next();
// ── Session-based auth ────────────────────────────────────────
if (req.session?.userId) {
req.user = {
id: req.session.userId,
username: req.session.username,
role: req.session.role,
};
return next();
}
// ── Bearer token auth ─────────────────────────────────────────
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const raw = authHeader.slice(7).trim();
const hash = crypto.createHash('sha256').update(raw).digest('hex');
try {
const { rows } = await pool.query(
`SELECT t.user_id AS id, u.username, u.role, t.bound_hostname
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
[hash]
);
if (rows.length > 0) {
req.user = rows[0];
req.tokenBoundHostname = rows[0].bound_hostname || null;
// Fire-and-forget last_used_at update
pool.query(
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
[hash]
).catch(() => {});
return next();
}
} catch (err) {
return next(err);
}
}
return res.status(401).json({ error: 'Unauthorized' });
};
export const requireAdmin = (req, res, next) => {
if (process.env.AUTH_ENABLED !== 'true') return next();
if (req.user?.role === 'admin') return next();
return res.status(403).json({ error: 'Admin access required' });
};