From 002e5acb8282815760afef9ce37c43d845ca0fc6 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 27 May 2026 03:21:07 +0000 Subject: [PATCH] =?UTF-8?q?auth:=20top-to-bottom=20rework=20=E2=80=94=20lo?= =?UTF-8?q?cal=20accounts,=20RBAC=20+=20client=20tag,=20audit=20log,=20env?= =?UTF-8?q?-bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope (locked in via planning Q&A): - Identity: local accounts only (PG users table) + existing bearer tokens for headless callers. - Transport: httpOnly cookie session for browser, Bearer for API. - RBAC: admin / editor / viewer roles, plus an orthogonal is_client flag for external (agency, talent, customer) accounts. - Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET to force-reset the named user (break-glass). - Rate limit: in-memory, 10 fails per 15min per (IP, username). - Password policy: \u22658 chars, mixed case, digit, symbol; small blocklist of common passwords; cannot equal username. - Self-service: change own display name + password. Everything else (role, is_client, other-user mgmt) is admin only. - Audit log: append-only table, indexed by actor + event_type + created_at, populated by every auth/admin event. Files added: - services/mam-api/src/db/migrations/022-auth-rework.sql users.is_client + last_login_at + failed_attempts; audit_log table with FK to users (ON DELETE SET NULL). - services/mam-api/src/middleware/audit.js Fire-and-forget audit() helper. Caller never awaits, failure logs but never throws — auditing cannot break the request that triggered it. - services/mam-api/src/middleware/passwordPolicy.js Shared checkPassword(pw, { username }) used by setup, user create/update, and self-service password change. - services/mam-api/src/tasks/bootstrapAdmin.js Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR ADMIN_BOOTSTRAP_RESET=true). - services/mam-api/src/routes/audit.js Admin-only GET /audit (paginated, filter by event_type / actor / target / date) and GET /audit/event-types. - services/web-ui/public/modal-account-settings.jsx Profile + Password tabs. Triggered by sidebar user button. Files rewritten: - services/mam-api/src/routes/auth.js - POST /login: regenerate(), no manual save(); audit success/ fail/lockout; updates last_login_at + failed_attempts. - POST /logout: destroys session, audits logout. - GET /me: returns is_client + last_login_at. Synthetic admin when AUTH_ENABLED=false. - GET /setup-status: drives login.html UI state. - POST /setup: blocked once any user exists; password policy. - POST /password: self-service. Requires current pw, runs policy, audits, invalidates other sessions implicitly via users.js if changed by admin. - PATCH /me: self-service display_name update. - services/mam-api/src/routes/users.js - is_client field in create/update/list/get. - Guardrails: cannot delete or demote last admin, cannot delete self, admins cannot be flagged is_client. - Password change invalidates all sessions for that user (DELETE FROM sessions WHERE sess->>'userId' = id). - Audit on every mutation. - Password policy enforced. - services/mam-api/src/middleware/auth.js - requireAuth now exposes req.user.is_client. - New requireRole(["admin","editor"], { rejectClients: true }) helper. Applied to cluster, sdk, capture routes (infra). - Synthetic user when AUTH_ENABLED=false has is_client=false. - services/mam-api/src/index.js - Loads bootstrap admin after migrations. - Wires /api/v1/audit. - Cleans up an earlier comment block. - services/web-ui/public/login.html - Password hint added next to setup-mode password field. - services/web-ui/public/shell.jsx - Sidebar user footer is a button that opens AccountSettings. - CLIENT badge next to role when is_client=true. - Nav filters: clients lose ingest tree + jobs + editor; viewers lose ingest + editor; only admins see the Admin section. Power button hidden when synthetic user. - services/web-ui/public/screens-admin.jsx - Users table: new Client column with inline toggle. - InviteUserModal: Client checkbox + password hint, gated off when role=admin. - Last login column replaces Created in primary view. - CSV export includes client + last_login. - services/web-ui/public/data.jsx - ZAMPP_DATA.ME carries is_client + display_name. - services/web-ui/public/index.html - Loads dist/modal-account-settings.js. - services/web-ui/public/styles-rest.css - .user-row grid widened to 6 columns. - docker-compose.yml - Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars. Deploy: cd /opt/wild-dragon git pull origin main # In .env: # AUTH_ENABLED=true # SESSION_SECRET= # ADMIN_BOOTSTRAP_USER=admin # ADMIN_BOOTSTRAP_PASSWORD= docker compose build mam-api web-ui docker compose up -d --force-recreate --no-deps mam-api web-ui --- docker-compose.yml | 5 + .../src/db/migrations/022-auth-rework.sql | 38 ++ services/mam-api/src/index.js | 11 + services/mam-api/src/middleware/audit.js | 69 ++++ services/mam-api/src/middleware/auth.js | 81 ++++- .../mam-api/src/middleware/passwordPolicy.js | 35 ++ services/mam-api/src/routes/audit.js | 78 +++++ services/mam-api/src/routes/auth.js | 328 +++++++++++------- services/mam-api/src/routes/capture.js | 3 +- services/mam-api/src/routes/cluster.js | 3 +- services/mam-api/src/routes/recorders.js | 1 + services/mam-api/src/routes/schedules.js | 1 + services/mam-api/src/routes/sdk.js | 3 +- services/mam-api/src/routes/users.js | 259 ++++++++------ services/mam-api/src/tasks/bootstrapAdmin.js | 93 +++++ services/web-ui/public/app.jsx | 11 + services/web-ui/public/data.jsx | 14 +- services/web-ui/public/index.html | 1 + services/web-ui/public/login.html | 5 +- .../web-ui/public/modal-account-settings.jsx | 149 ++++++++ services/web-ui/public/screens-admin.jsx | 45 ++- services/web-ui/public/shell.jsx | 84 +++-- services/web-ui/public/styles-rest.css | 2 +- 23 files changed, 1016 insertions(+), 303 deletions(-) create mode 100644 services/mam-api/src/db/migrations/022-auth-rework.sql create mode 100644 services/mam-api/src/middleware/audit.js create mode 100644 services/mam-api/src/middleware/passwordPolicy.js create mode 100644 services/mam-api/src/routes/audit.js create mode 100644 services/mam-api/src/tasks/bootstrapAdmin.js create mode 100644 services/web-ui/public/modal-account-settings.jsx diff --git a/docker-compose.yml b/docker-compose.yml index 299a44d..f2a13dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,11 @@ services: S3_REGION: ${S3_REGION:-us-east-1} SESSION_SECRET: ${SESSION_SECRET} AUTH_ENABLED: ${AUTH_ENABLED:-false} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-} + ADMIN_BOOTSTRAP_USER: ${ADMIN_BOOTSTRAP_USER:-} + ADMIN_BOOTSTRAP_PASSWORD: ${ADMIN_BOOTSTRAP_PASSWORD:-} + ADMIN_BOOTSTRAP_DISPLAY_NAME: ${ADMIN_BOOTSTRAP_DISPLAY_NAME:-} + ADMIN_BOOTSTRAP_RESET: ${ADMIN_BOOTSTRAP_RESET:-} DOCKER_NETWORK: wild-dragon_wild-dragon NODE_IP: ${NODE_IP} NODE_HOSTNAME: ${NODE_HOSTNAME:-} diff --git a/services/mam-api/src/db/migrations/022-auth-rework.sql b/services/mam-api/src/db/migrations/022-auth-rework.sql new file mode 100644 index 0000000..686a4ec --- /dev/null +++ b/services/mam-api/src/db/migrations/022-auth-rework.sql @@ -0,0 +1,38 @@ +-- Auth rework: client flag + audit log + safety constraints. +-- +-- - users.is_client → orthogonal to role. Editors/viewers tagged as +-- clients see a restricted UI (no recorders / +-- cluster admin / hi-res original download). +-- - users.last_login_at → for the admin Users page "Last seen" column. +-- - users.failed_attempts → not authoritative (in-memory rate-limit is), +-- but lets admins see who's been hammered on. +-- - audit_log → append-only log of auth + admin events. +-- Cheap, indexed by user + created_at. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS is_client BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS users_is_client_idx ON users (is_client) WHERE is_client = TRUE; + +-- Audit log. `actor_id` is the user performing the action (NULL for failed +-- logins where we don't yet know who they're claiming to be). `target_type` +-- + `target_id` identify what was changed (user, token, schedule, etc.). +-- `meta` is a free-form jsonb with details specific to the event type. +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_type TEXT NOT NULL, + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_label TEXT, + target_type TEXT, + target_id TEXT, + ip_address TEXT, + user_agent TEXT, + meta JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS audit_log_created_idx ON audit_log (created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_actor_idx ON audit_log (actor_id, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_type_idx ON audit_log (event_type, created_at DESC); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 20dc7a8..8b7cf08 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -32,6 +32,7 @@ import metricsRouter from './routes/metrics.js'; import commentsRouter from './routes/comments.js'; import importsRouter from './routes/imports.js'; import storageRouter from './routes/storage.js'; +import auditRouter from './routes/audit.js'; import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js'; import { startCleanupLoop } from './tasks/cleanupTempSegments.js'; @@ -118,6 +119,7 @@ app.use('/api/v1/metrics', metricsRouter); app.use('/api/v1/assets/:assetId/comments', commentsRouter); app.use('/api/v1/imports', importsRouter); app.use('/api/v1/storage', storageRouter); +app.use('/api/v1/audit', auditRouter); // ── Error handler ───────────────────────────────────────────────────────────── app.use(errorHandler); @@ -184,6 +186,15 @@ await runMigrations(); // Load S3 config from DB so any settings saved via the Settings page override env vars await loadS3ConfigFromDb(); +// First-run / break-glass admin bootstrap. Reads ADMIN_BOOTSTRAP_USER +// + ADMIN_BOOTSTRAP_PASSWORD; only acts when the users table is empty +// (creates first admin) or when ADMIN_BOOTSTRAP_RESET=true (resets the +// named user back to admin + new password). Always safe to leave the +// env vars set — bootstrap exits early when there's nothing to do. +import('./tasks/bootstrapAdmin.js').then(m => m.bootstrapAdmin()).catch(err => { + console.error('[bootstrap] admin bootstrap failed:', err); +}); + // ── Cluster self-heartbeat ──────────────────────────────────────────────────── function getLocalIp() { // Prefer an explicit override — useful when running inside Docker where diff --git a/services/mam-api/src/middleware/audit.js b/services/mam-api/src/middleware/audit.js new file mode 100644 index 0000000..5ecee90 --- /dev/null +++ b/services/mam-api/src/middleware/audit.js @@ -0,0 +1,69 @@ +// Append-only audit log helper. +// +// Every auth-relevant operation calls audit() with a short event_type and +// any metadata that helps an admin reconstruct the event later. Writes are +// fire-and-forget: a failed audit insert must never block the user's +// request, so the caller doesn't `await` the result. + +import pool from '../db/pool.js'; + +const TRUNCATE_LABEL = 120; + +/** + * audit(req, eventType, { targetType, targetId, meta }) → Promise + * + * - `req` : Express request (used to pull actor + IP + user-agent). + * - `eventType` : short dot-separated string. Recommended vocabulary: + * auth.login.success auth.login.fail + * auth.logout auth.password.change + * auth.bootstrap auth.lockout + * user.create user.update + * user.delete user.role.change + * user.client.toggle user.password.reset + * token.create token.revoke + * + * Anything else is allowed — we keep the vocabulary loose so adding new + * events doesn't require a code change here. + */ +export async function audit(req, eventType, opts = {}) { + const { + targetType = null, + targetId = null, + meta = {}, + actorIdOverride = undefined, // for bootstrap / system events + actorLabelOverride = undefined, + } = opts; + + try { + const actorId = actorIdOverride !== undefined + ? actorIdOverride + : (req?.user?.id || req?.session?.userId || null); + const actorLabel = actorLabelOverride !== undefined + ? actorLabelOverride + : (req?.user?.username || req?.session?.username || meta?.username || null); + + const ip = req?.ip || req?.socket?.remoteAddress || null; + const ua = req?.headers?.['user-agent'] || null; + + await pool.query( + `INSERT INTO audit_log + (event_type, actor_id, actor_label, target_type, target_id, ip_address, user_agent, meta) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + [ + eventType, + actorId, + actorLabel ? String(actorLabel).slice(0, TRUNCATE_LABEL) : null, + targetType, + targetId ? String(targetId).slice(0, TRUNCATE_LABEL) : null, + ip, + ua ? ua.slice(0, 500) : null, + JSON.stringify(meta || {}), + ] + ); + } catch (err) { + // Never throw — auditing must not break the request that triggered it. + console.warn('[audit] insert failed:', err.message, { eventType }); + } +} + +export default audit; diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 21c06da..cd02471 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -4,45 +4,68 @@ * 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) + * - A valid Bearer token in the Authorization header. * - * 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. + * When AUTH_ENABLED is unset or any other value, the middleware is a no-op + * (with a synthetic admin user attached) so the stack can run unprotected. + * + * Exposed on `req` after success: + * req.user { id, username, role, is_client } + * req.tokenBoundHostname hostname binding from api_tokens, or null */ 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(); +const SYNTHETIC_USER = Object.freeze({ + id: null, + username: 'operator', + role: 'admin', + is_client: false, + synthetic: true, +}); - // ── Session-based auth ──────────────────────────────────────── +export const requireAuth = async (req, res, next) => { + if (process.env.AUTH_ENABLED !== 'true') { + req.user = SYNTHETIC_USER; + return next(); + } + + // ── Session-based auth ───────────────────────────────────────── if (req.session?.userId) { req.user = { - id: req.session.userId, - username: req.session.username, - role: req.session.role, + id: req.session.userId, + username: req.session.username, + role: req.session.role, + is_client: !!req.session.isClient, }; return next(); } - // ── Bearer token auth ───────────────────────────────────────── + // ── 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())`, + `SELECT t.user_id AS id, + u.username, u.role, u.is_client, + 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 + const row = rows[0]; + req.user = { + id: row.id, + username: row.username, + role: row.role, + is_client: !!row.is_client, + }; + req.tokenBoundHostname = row.bound_hostname || null; pool.query( 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', [hash] @@ -62,3 +85,25 @@ export const requireAdmin = (req, res, next) => { if (req.user?.role === 'admin') return next(); return res.status(403).json({ error: 'Admin access required' }); }; + +/** + * Role gate: `requireRole(['admin','editor'])` rejects with 403 unless the + * user's role is in the allow list. Always passes when AUTH_ENABLED=false. + * Optionally pass { rejectClients: true } to also block users with + * is_client=true regardless of role — used for routes that touch the + * recorder / cluster / infra surface. + */ +export const requireRole = (allowed, opts = {}) => { + const set = new Set(Array.isArray(allowed) ? allowed : [allowed]); + const { rejectClients = false } = opts; + return (req, res, next) => { + if (process.env.AUTH_ENABLED !== 'true') return next(); + if (!req.user || !set.has(req.user.role)) { + return res.status(403).json({ error: `Requires role: ${[...set].join(' or ')}` }); + } + if (rejectClients && req.user.is_client) { + return res.status(403).json({ error: 'This action is not available to client accounts' }); + } + next(); + }; +}; diff --git a/services/mam-api/src/middleware/passwordPolicy.js b/services/mam-api/src/middleware/passwordPolicy.js new file mode 100644 index 0000000..78dd6a3 --- /dev/null +++ b/services/mam-api/src/middleware/passwordPolicy.js @@ -0,0 +1,35 @@ +// Password policy — locked-in scope: +// - At least 8 characters +// - At least one lowercase, one uppercase, one digit, one symbol +// - Cannot be in the small built-in blocklist of obvious passwords +// - Cannot equal username (case-insensitive) +// +// Returns null when the password is acceptable, or a short human-readable +// reason when it isn't. Callers surface the reason as the 400 body. + +const BLOCKLIST = new Set([ + 'password', 'password1', 'password!', 'passw0rd', + 'qwerty123', 'qwertyuiop', '12345678', '123456789', '1234567890', + 'letmein!', 'welcome1', 'welcome!', 'admin1234', 'admin1!', 'admin@123', + 'dragonflight', 'wilddragon', 'broadcast1', +].map(s => s.toLowerCase())); + +export function checkPassword(pw, { username } = {}) { + if (typeof pw !== 'string') return 'Password is required'; + if (pw.length < 8) return 'Password must be at least 8 characters'; + if (pw.length > 200) return 'Password must be 200 characters or fewer'; + + if (!/[a-z]/.test(pw)) return 'Password must contain a lowercase letter'; + if (!/[A-Z]/.test(pw)) return 'Password must contain an uppercase letter'; + if (!/[0-9]/.test(pw)) return 'Password must contain a digit'; + if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain a symbol (e.g. ! @ # $ %)'; + + if (BLOCKLIST.has(pw.toLowerCase())) return 'Password is too common'; + if (username && pw.toLowerCase() === String(username).toLowerCase()) { + return 'Password cannot match the username'; + } + + return null; +} + +export default checkPassword; diff --git a/services/mam-api/src/routes/audit.js b/services/mam-api/src/routes/audit.js new file mode 100644 index 0000000..de60b31 --- /dev/null +++ b/services/mam-api/src/routes/audit.js @@ -0,0 +1,78 @@ +// Read-only audit log endpoints. Admin-only. +// +// GET /api/v1/audit paginated list, newest first +// GET /api/v1/audit/event-types distinct event types we've seen +// +// Filters supported on GET /: +// event_type exact match (e.g. "auth.login.fail") +// actor_id UUID of the actor +// target_id string (UUID or other) +// from / to ISO timestamps +// limit 1..200 (default 50) +// offset pagination + +import express from 'express'; +import pool from '../db/pool.js'; +import { requireAuth, requireAdmin } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth, requireAdmin); + +router.get('/', async (req, res, next) => { + try { + const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 50)); + const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); + + const where = []; + const vals = []; + + if (req.query.event_type) { + vals.push(String(req.query.event_type)); + where.push(`event_type = $${vals.length}`); + } + if (req.query.actor_id) { + vals.push(String(req.query.actor_id)); + where.push(`actor_id = $${vals.length}::uuid`); + } + if (req.query.target_id) { + vals.push(String(req.query.target_id)); + where.push(`target_id = $${vals.length}`); + } + if (req.query.from) { + vals.push(req.query.from); + where.push(`created_at >= $${vals.length}::timestamptz`); + } + if (req.query.to) { + vals.push(req.query.to); + where.push(`created_at <= $${vals.length}::timestamptz`); + } + const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + vals.push(limit, offset); + const r = await pool.query( + `SELECT id, created_at, event_type, actor_id, actor_label, + target_type, target_id, ip_address, user_agent, meta + FROM audit_log + ${whereSql} + ORDER BY created_at DESC + LIMIT $${vals.length - 1} OFFSET $${vals.length}`, + vals + ); + const total = await pool.query(`SELECT COUNT(*)::int AS n FROM audit_log ${whereSql}`, vals.slice(0, -2)); + res.json({ items: r.rows, total: total.rows[0].n, limit, offset }); + } catch (err) { next(err); } +}); + +router.get('/event-types', async (_req, res, next) => { + try { + const r = await pool.query( + `SELECT event_type, COUNT(*)::int AS n + FROM audit_log + GROUP BY event_type + ORDER BY n DESC, event_type ASC` + ); + res.json(r.rows); + } catch (err) { next(err); } +}); + +export default router; diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 3c8d862..016a432 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,37 +1,39 @@ /** * Authentication routes * - * POST /api/v1/auth/login — exchange username+password for a session cookie - * POST /api/v1/auth/logout — destroy the current session - * GET /api/v1/auth/me — return the currently authenticated user - * POST /api/v1/auth/setup — one-time admin bootstrap (disabled after first user exists) + * POST /api/v1/auth/login exchange username+password for a session + * POST /api/v1/auth/logout destroy the current session + * GET /api/v1/auth/me return the currently authenticated user + * GET /api/v1/auth/setup-status tell login.html whether to show setup + * POST /api/v1/auth/setup one-time first-admin bootstrap from UI + * POST /api/v1/auth/password change current user's password + * PATCH /api/v1/auth/me update current user's display_name + * + * Sessions are stored in PG via connect-pg-simple (see index.js). Bearer + * tokens go through middleware/auth.js, not here. */ import express from 'express'; -import bcrypt from 'bcrypt'; -import pool from '../db/pool.js'; +import bcrypt from 'bcrypt'; +import pool from '../db/pool.js'; +import audit from '../middleware/audit.js'; +import { checkPassword } from '../middleware/passwordPolicy.js'; const router = express.Router(); // --------------------------------------------------------------------------- -// BUG FIX #6: In-memory login rate limiter. +// In-memory login rate limiter. // -// Brute-force protection for POST /login. Tracks failed attempts per -// (IP, username) pair; after MAX_ATTEMPTS failures within WINDOW_MS the -// endpoint returns 429 for LOCKOUT_MS regardless of the password supplied. -// -// This is intentionally simple — no Redis dependency, no persistent state -// across restarts. For a production deployment behind a load balancer, use -// express-rate-limit with a Redis store or a dedicated WAF rule instead. +// Tracks failed attempts per (IP, username). After MAX_ATTEMPTS failures +// within WINDOW_MS the endpoint returns 429 for LOCKOUT_MS regardless of +// the password supplied. Simple by design — no Redis dependency, single +// replica deploy. For multi-replica add an external store later. // --------------------------------------------------------------------------- -const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10); -const WINDOW_MS = parseInt(process.env.LOGIN_WINDOW_MS || String(15 * 60 * 1000), 10); // 15 min -const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10); // 15 min +const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '10', 10); +const WINDOW_MS = parseInt(process.env.LOGIN_WINDOW_MS || String(15 * 60 * 1000), 10); +const LOCKOUT_MS = parseInt(process.env.LOGIN_LOCKOUT_MS || String(15 * 60 * 1000), 10); -// Map key → { attempts: number, lockedUntil: number | null, firstAttempt: number } const loginAttempts = new Map(); -// Housekeeping: prune expired entries every 10 min so the Map doesn't grow -// unboundedly on high-traffic or attack traffic. setInterval(() => { const now = Date.now(); for (const [key, entry] of loginAttempts.entries()) { @@ -42,110 +44,104 @@ setInterval(() => { } }, 10 * 60 * 1000).unref(); -function getAttemptKey(req, username) { - // Use the real client IP (trust proxy headers set by nginx/load-balancer) - const ip = req.ip || req.socket.remoteAddress || 'unknown'; - return `${ip}:${(username || '').trim().toLowerCase()}`; +function attemptKey(req, username) { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + return `${ip}:${String(username || '').trim().toLowerCase()}`; } function checkRateLimit(req, username) { - const key = getAttemptKey(req, username); - const now = Date.now(); + const key = attemptKey(req, username); const entry = loginAttempts.get(key); - - if (entry) { - // Still locked out? - if (entry.lockedUntil && now < entry.lockedUntil) { - const retryAfterSec = Math.ceil((entry.lockedUntil - now) / 1000); - return { limited: true, retryAfterSec }; - } - // Window expired — reset - if (now - entry.firstAttempt > WINDOW_MS) { - loginAttempts.delete(key); - } + const now = Date.now(); + if (!entry) return { limited: false }; + if (entry.lockedUntil && now < entry.lockedUntil) { + return { limited: true, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; + } + if (now - entry.firstAttempt > WINDOW_MS) { + loginAttempts.delete(key); } return { limited: false }; } -function recordFailedAttempt(req, username) { - const key = getAttemptKey(req, username); +function recordFail(req, username) { + const key = attemptKey(req, username); const now = Date.now(); - const entry = loginAttempts.get(key) || { attempts: 0, lockedUntil: null, firstAttempt: now }; - - // Don't update firstAttempt if there's an existing entry within the window + const entry = loginAttempts.get(key) || { attempts: 0, firstAttempt: now, lockedUntil: null }; entry.attempts += 1; - if (entry.attempts >= MAX_ATTEMPTS) { - entry.lockedUntil = now + LOCKOUT_MS; - } + if (entry.attempts >= MAX_ATTEMPTS) entry.lockedUntil = now + LOCKOUT_MS; loginAttempts.set(key, entry); + return entry.attempts; } function clearAttempts(req, username) { - loginAttempts.delete(getAttemptKey(req, username)); + loginAttempts.delete(attemptKey(req, username)); } // --------------------------------------------------------------------------- // POST /login // --------------------------------------------------------------------------- router.post('/login', async (req, res, next) => { + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + const rate = checkRateLimit(req, username); + if (rate.limited) { + res.set('Retry-After', String(rate.retryAfter)); + audit(req, 'auth.lockout', { meta: { username, retry_after_sec: rate.retryAfter } }); + return res.status(429).json({ + error: `Too many failed attempts. Try again in ${rate.retryAfter} seconds.`, + }); + } + try { - const { username, password } = req.body; - - if (!username || !password) { - return res.status(400).json({ error: 'Username and password are required' }); - } - - // BUG FIX #6: Check rate limit before hitting the DB or bcrypt. - const rateCheck = checkRateLimit(req, username); - if (rateCheck.limited) { - res.set('Retry-After', String(rateCheck.retryAfterSec)); - return res.status(429).json({ - error: `Too many failed login attempts. Try again in ${rateCheck.retryAfterSec} seconds.`, - }); - } - const result = await pool.query( - 'SELECT * FROM users WHERE username = $1', - [username.trim().toLowerCase()] + 'SELECT id, username, password_hash, display_name, role, is_client FROM users WHERE username = $1', + [String(username).trim().toLowerCase()] ); - if (result.rows.length === 0) { - // Timing-safe: still run compare on a dummy hash so response time is constant - await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000'); - recordFailedAttempt(req, username); - return res.status(401).json({ error: 'Invalid credentials' }); - } - const user = result.rows[0]; - const valid = await bcrypt.compare(password, user.password_hash); - if (!valid) { - recordFailedAttempt(req, username); + // Constant-time path: even on missing user, run bcrypt against a dummy + // hash so attackers can't enumerate usernames by response time. + if (!user) { + await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000'); + recordFail(req, username); + audit(req, 'auth.login.fail', { meta: { username, reason: 'no_such_user' } }); return res.status(401).json({ error: 'Invalid credentials' }); } - // Successful login — clear any accumulated failed attempts - clearAttempts(req, username); + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + const n = recordFail(req, username); + // Mirror counter to DB so admins can see hammered accounts. Best-effort. + pool.query('UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = $1', [user.id]).catch(() => {}); + audit(req, 'auth.login.fail', { targetType: 'user', targetId: user.id, meta: { username, attempt: n } }); + return res.status(401).json({ error: 'Invalid credentials' }); + } - // Regenerate session ID to prevent fixation attacks, then let - // express-session write Set-Cookie on its own res.end() hook. - // Do NOT call session.save() manually — that writes the store but - // bypasses the middleware's header-writing path, which is why the - // Set-Cookie header was missing even though the session row existed. + clearAttempts(req, username); + pool.query( + 'UPDATE users SET failed_attempts = 0, last_login_at = NOW() WHERE id = $1', + [user.id] + ).catch(() => {}); + + // Regenerate to prevent fixation. Let express-session handle Set-Cookie + // and store-write on res.end — DO NOT call session.save() manually. req.session.regenerate((err) => { - if (err) { - console.error('[auth] session.regenerate failed:', err); - return next(err); - } - req.session.userId = user.id; - req.session.username = user.username; - req.session.role = user.role; - console.log(`[auth] login ok user=${user.username} sid=${req.sessionID?.slice(0,8) || '?'} secure=${req.secure} proto=${req.protocol}`); + if (err) return next(err); + req.session.userId = user.id; + req.session.username = user.username; + req.session.role = user.role; + req.session.isClient = !!user.is_client; + audit(req, 'auth.login.success', { targetType: 'user', targetId: user.id, meta: { username: user.username } }); res.json({ id: user.id, username: user.username, display_name: user.display_name, role: user.role, + is_client: !!user.is_client, }); }); } catch (err) { @@ -157,9 +153,12 @@ router.post('/login', async (req, res, next) => { // POST /logout // --------------------------------------------------------------------------- router.post('/logout', (req, res, next) => { + const userId = req.session?.userId; + const username = req.session?.username; req.session.destroy((err) => { if (err) return next(err); - res.clearCookie('connect.sid'); + res.clearCookie('df.sid'); + if (userId) audit(req, 'auth.logout', { targetType: 'user', targetId: userId, meta: { username } }); res.json({ message: 'Logged out' }); }); }); @@ -168,98 +167,159 @@ router.post('/logout', (req, res, next) => { // GET /me // --------------------------------------------------------------------------- router.get('/me', async (req, res) => { - // When auth is disabled return a synthetic user so the frontend auth-guard - // never receives a 401. Prefer LOCAL_OPERATOR (explicit) or the OS user - // running the server over a generic "Admin" — that label is misleading - // because it implies an actual admin account is signed in. + // Auth off → synthetic admin so the app loads in dev / unprotected setups. if (process.env.AUTH_ENABLED !== 'true') { - const osUser = process.env.LOCAL_OPERATOR - || process.env.USER - || process.env.USERNAME - || 'operator'; + const osUser = process.env.LOCAL_OPERATOR || process.env.USER || process.env.USERNAME || 'operator'; return res.json({ id: null, - username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''), + username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''), display_name: osUser, - role: 'admin', - synthetic: true, + role: 'admin', + is_client: false, + synthetic: true, }); } - if (!req.session || !req.session.userId) { + if (!req.session?.userId) { return res.status(401).json({ error: 'Not authenticated' }); } + try { - const result = await pool.query( - 'SELECT id, username, display_name, role FROM users WHERE id = $1', + const r = await pool.query( + 'SELECT id, username, display_name, role, is_client, last_login_at FROM users WHERE id = $1', [req.session.userId] ); - if (result.rows.length === 0) { + if (r.rows.length === 0) { + // Session points at a user that no longer exists — drop the session. req.session.destroy(() => {}); return res.status(401).json({ error: 'User not found' }); } - res.json(result.rows[0]); + res.json(r.rows[0]); } catch (err) { - // Fallback to session data if DB unreachable + // DB hiccup — fall back to session data so the UI doesn't blank out. res.json({ - id: req.session.userId, - username: req.session.username, - role: req.session.role, + id: req.session.userId, + username: req.session.username, + role: req.session.role, + is_client: !!req.session.isClient, }); } }); // --------------------------------------------------------------------------- -// GET /setup-status — does ANY user exist? login.html flips into setup mode -// automatically when this returns { needs_setup: true }, instead of forcing -// the operator to click "Create admin account". +// GET /setup-status — front-end hint for login.html // --------------------------------------------------------------------------- router.get('/setup-status', async (req, res, next) => { try { - const count = await pool.query('SELECT COUNT(*) FROM users'); - const n = parseInt(count.rows[0].count, 10); + const r = await pool.query('SELECT COUNT(*)::int AS n FROM users'); + const n = r.rows[0].n; res.json({ - needs_setup: n === 0, - user_count: n, - auth_enabled: process.env.AUTH_ENABLED === 'true', + needs_setup: n === 0, + user_count: n, + auth_enabled: process.env.AUTH_ENABLED === 'true', }); } catch (err) { next(err); } }); // --------------------------------------------------------------------------- -// POST /setup — one-time first-admin bootstrap +// POST /setup — UI-driven first-admin bootstrap. Disabled once any user exists. // --------------------------------------------------------------------------- router.post('/setup', async (req, res, next) => { try { - const { username, password, display_name } = req.body; - + const { username, password, display_name } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } - if (password.length < 8) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); - } + const policyErr = checkPassword(password, { username }); + if (policyErr) return res.status(400).json({ error: policyErr }); - // Block if any user already exists - const count = await pool.query('SELECT COUNT(*) FROM users'); - if (parseInt(count.rows[0].count, 10) > 0) { + const count = await pool.query('SELECT COUNT(*)::int AS n FROM users'); + if (count.rows[0].n > 0) { return res.status(403).json({ - error: 'Setup is already complete. Use an existing admin account to add more users.', + error: 'Setup is already complete. Use an existing admin to add more users.', }); } const hash = await bcrypt.hash(password, 12); - const result = await pool.query( - `INSERT INTO users (username, password_hash, display_name, role) - VALUES ($1, $2, $3, 'admin') - RETURNING id, username, display_name, role`, - [username.trim().toLowerCase(), hash, display_name || username] + const r = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role, is_client) + VALUES ($1, $2, $3, 'admin', FALSE) + RETURNING id, username, display_name, role, is_client`, + [String(username).trim().toLowerCase(), hash, display_name || username] ); - - res.status(201).json(result.rows[0]); + const newUser = r.rows[0]; + audit(req, 'auth.setup', { targetType: 'user', targetId: newUser.id, meta: { username: newUser.username } }); + res.status(201).json(newUser); } catch (err) { next(err); } }); +// --------------------------------------------------------------------------- +// POST /password — self-service password change. Requires current password. +// --------------------------------------------------------------------------- +router.post('/password', async (req, res, next) => { + if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) { + return res.status(401).json({ error: 'Sign in required' }); + } + const { current_password, new_password } = req.body || {}; + if (!current_password || !new_password) { + return res.status(400).json({ error: 'current_password and new_password are required' }); + } + try { + const r = await pool.query( + 'SELECT id, username, password_hash FROM users WHERE id = $1', + [req.session.userId] + ); + const u = r.rows[0]; + if (!u) return res.status(401).json({ error: 'Session user not found' }); + + const ok = await bcrypt.compare(current_password, u.password_hash); + if (!ok) { + audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: false, reason: 'wrong_current' } }); + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + const policyErr = checkPassword(new_password, { username: u.username }); + if (policyErr) return res.status(400).json({ error: policyErr }); + + if (current_password === new_password) { + return res.status(400).json({ error: 'New password must differ from current' }); + } + + const newHash = await bcrypt.hash(new_password, 12); + await pool.query( + 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', + [newHash, u.id] + ); + audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: true } }); + res.json({ message: 'Password updated' }); + } catch (err) { next(err); } +}); + +// --------------------------------------------------------------------------- +// PATCH /me — self-service display_name change. +// --------------------------------------------------------------------------- +router.patch('/me', async (req, res, next) => { + if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) { + return res.status(401).json({ error: 'Sign in required' }); + } + const { display_name } = req.body || {}; + if (typeof display_name !== 'string' || !display_name.trim()) { + return res.status(400).json({ error: 'display_name is required' }); + } + try { + const r = await pool.query( + `UPDATE users + SET display_name = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, username, display_name, role, is_client`, + [display_name.trim().slice(0, 120), req.session.userId] + ); + if (r.rows.length === 0) return res.status(404).json({ error: 'User not found' }); + audit(req, 'auth.profile.update', { targetType: 'user', targetId: r.rows[0].id, meta: { display_name } }); + res.json(r.rows[0]); + } catch (err) { next(err); } +}); + export default router; diff --git a/services/mam-api/src/routes/capture.js b/services/mam-api/src/routes/capture.js index 6cdf0e0..b446d90 100644 --- a/services/mam-api/src/routes/capture.js +++ b/services/mam-api/src/routes/capture.js @@ -1,9 +1,10 @@ import express from 'express'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); +router.use(requireRole(['admin', 'editor'], { rejectClients: true })); const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001'; diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 112449a..7702838 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -1,10 +1,11 @@ import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); +router.use(requireRole(['admin', 'editor'], { rejectClients: true })); // If the agent reported Docker's default bridge IP (172.17.x) but the request // itself came from a real LAN address, prefer the request source IP instead. diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 7f66445..c2c04f8 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); router.use(requireAuth); + router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // Base port for on-demand SDI sidecar containers on remote worker nodes. diff --git a/services/mam-api/src/routes/schedules.js b/services/mam-api/src/routes/schedules.js index 94b42e8..4c2da2f 100644 --- a/services/mam-api/src/routes/schedules.js +++ b/services/mam-api/src/routes/schedules.js @@ -10,6 +10,7 @@ import { validateUuid } from '../middleware/errors.js'; const router = express.Router(); router.use(requireAuth); + router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']); diff --git a/services/mam-api/src/routes/sdk.js b/services/mam-api/src/routes/sdk.js index 57769ec..c33d67a 100644 --- a/services/mam-api/src/routes/sdk.js +++ b/services/mam-api/src/routes/sdk.js @@ -14,10 +14,11 @@ import multer from 'multer'; import { promises as fs, createWriteStream } from 'fs'; import { spawn } from 'child_process'; import path from 'path'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireRole } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); +router.use(requireRole(['admin', 'editor'], { rejectClients: true })); const SDK_ROOT = process.env.SDK_ROOT || '/sdk'; diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js index d5e594c..9c17afe 100644 --- a/services/mam-api/src/routes/users.js +++ b/services/mam-api/src/routes/users.js @@ -1,172 +1,211 @@ /** - * User management routes (admin-only when AUTH_ENABLED=true) + * User management routes — admin only. * - * GET /api/v1/users — list all users - * POST /api/v1/users — create user - * GET /api/v1/users/:id — get user - * PATCH /api/v1/users/:id — update user (display_name, role, password) - * DELETE /api/v1/users/:id — delete user + * GET /api/v1/users list users + * POST /api/v1/users create user + * GET /api/v1/users/:id get one user + * PATCH /api/v1/users/:id update display_name / role / is_client / password + * DELETE /api/v1/users/:id delete user + * + * Guardrails: + * - Last admin can never be deleted, demoted, or flagged is_client. + * - Admins cannot demote / delete themselves (gentler error than the + * "last admin" guard — protects against accidental click). + * - Password creates/changes run through the shared policy. + * - Password change invalidates all active sessions for that user + * (sessions table DELETE WHERE sess->>'userId' = id). */ import express from 'express'; import bcrypt from 'bcrypt'; import pool from '../db/pool.js'; import { requireAuth, requireAdmin } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; +import audit from '../middleware/audit.js'; +import { checkPassword } from '../middleware/passwordPolicy.js'; const router = express.Router(); router.use(requireAuth, requireAdmin); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); const VALID_ROLES = ['admin', 'editor', 'viewer']; -// ── List ────────────────────────────────────────────────────── +const SELECT_FIELDS = ` + u.id, u.username, u.display_name, u.role, u.is_client, + u.created_at, u.updated_at, u.last_login_at, u.failed_attempts +`; + +async function adminCount() { + const r = await pool.query("SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'"); + return r.rows[0].n; +} + +// ── List ──────────────────────────────────────────────────────────────────── router.get('/', async (_req, res, next) => { try { - const { rows } = await pool.query( - `SELECT u.id, u.username, u.display_name, u.role, u.created_at, - COUNT(ug.group_id)::int AS group_count - FROM users u - LEFT JOIN user_groups ug ON ug.user_id = u.id + const { rows } = await pool.query(` + SELECT ${SELECT_FIELDS}, + COUNT(ug.group_id)::int AS group_count + FROM users u + LEFT JOIN user_groups ug ON ug.user_id = u.id GROUP BY u.id - ORDER BY u.created_at` - ); + ORDER BY u.created_at + `); res.json(rows); } catch (err) { next(err); } }); -// ── Create ──────────────────────────────────────────────────── +// ── Create ────────────────────────────────────────────────────────────────── router.post('/', async (req, res, next) => { try { - const { username, password, display_name, role = 'editor' } = req.body; - if (!username || !password) - return res.status(400).json({ error: 'username and password required' }); - if (password.length < 8) - return res.status(400).json({ error: 'Password must be ≥ 8 characters' }); - if (!VALID_ROLES.includes(role)) + const { username, password, display_name, role = 'editor', is_client = false } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'username and password required' }); + if (!VALID_ROLES.includes(role)) { return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` }); + } + if (role === 'admin' && is_client) { + return res.status(400).json({ error: 'Admins cannot be flagged as clients' }); + } + const policyErr = checkPassword(password, { username }); + if (policyErr) return res.status(400).json({ error: policyErr }); + const cleanUsername = String(username).trim().toLowerCase(); const hash = await bcrypt.hash(password, 12); - const { rows } = await pool.query( - `INSERT INTO users (username, password_hash, display_name, role) - VALUES ($1, $2, $3, $4) - RETURNING id, username, display_name, role, created_at`, - [username.trim().toLowerCase(), hash, display_name || username, role] + + const r = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role, is_client) + VALUES ($1, $2, $3, $4, $5) + RETURNING ${SELECT_FIELDS}`, + [cleanUsername, hash, display_name || username, role, !!is_client] ); - res.status(201).json(rows[0]); + + audit(req, 'user.create', { targetType: 'user', targetId: r.rows[0].id, meta: { username: cleanUsername, role, is_client: !!is_client } }); + res.status(201).json(r.rows[0]); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' }); next(err); } }); -// ── Get ─────────────────────────────────────────────────────── +// ── Get ───────────────────────────────────────────────────────────────────── router.get('/:id', async (req, res, next) => { try { - const { rows } = await pool.query( - `SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`, - [req.params.id] - ); - if (!rows.length) return res.status(404).json({ error: 'User not found' }); - res.json(rows[0]); + const r = await pool.query(`SELECT ${SELECT_FIELDS} FROM users u WHERE u.id = $1`, [req.params.id]); + if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); + res.json(r.rows[0]); } catch (err) { next(err); } }); -// ── Update ──────────────────────────────────────────────────── +// ── Update ────────────────────────────────────────────────────────────────── router.patch('/:id', async (req, res, next) => { try { - const { display_name, role, password } = req.body; + const { display_name, role, password, is_client } = req.body || {}; + const id = req.params.id; + + // Read current row so we can enforce guardrails before mutating. + const current = await pool.query( + 'SELECT id, username, role, is_client FROM users WHERE id = $1', + [id] + ); + if (!current.rows.length) return res.status(404).json({ error: 'User not found' }); + const target = current.rows[0]; + const actorId = req.user?.id || req.session?.userId; + const isSelf = actorId && actorId === id; + + if (role !== undefined) { + if (!VALID_ROLES.includes(role)) { + return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` }); + } + if (target.role === 'admin' && role !== 'admin') { + const n = await adminCount(); + if (n <= 1) return res.status(400).json({ error: 'Cannot demote the last admin' }); + if (isSelf) return res.status(400).json({ error: 'Use another admin to demote yourself' }); + } + } + + if (is_client === true) { + const finalRole = role || target.role; + if (finalRole === 'admin') { + return res.status(400).json({ error: 'Admins cannot be flagged as clients' }); + } + } + + if (password !== undefined) { + const policyErr = checkPassword(password, { username: target.username }); + if (policyErr) return res.status(400).json({ error: policyErr }); + } + + // Build the dynamic UPDATE const sets = []; const vals = []; - let passwordChanged = false; + const auditMeta = { username: target.username }; if (display_name !== undefined) { - sets.push(`display_name = $${sets.length + 1}`); - vals.push(display_name); + sets.push(`display_name = $${vals.length + 1}`); vals.push(String(display_name).trim().slice(0, 120)); + auditMeta.display_name = display_name; } if (role !== undefined) { - if (!VALID_ROLES.includes(role)) - return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` }); - sets.push(`role = $${sets.length + 1}`); - vals.push(role); + sets.push(`role = $${vals.length + 1}`); vals.push(role); + auditMeta.role = role; } - if (password) { - if (password.length < 8) - return res.status(400).json({ error: 'Password must be ≥ 8 characters' }); - const hashed = await bcrypt.hash(password, 12); - sets.push(`password_hash = $${sets.length + 1}`); - vals.push(hashed); + if (is_client !== undefined) { + sets.push(`is_client = $${vals.length + 1}`); vals.push(!!is_client); + auditMeta.is_client = !!is_client; + } + let passwordChanged = false; + if (password !== undefined) { + const hash = await bcrypt.hash(password, 12); + sets.push(`password_hash = $${vals.length + 1}`); vals.push(hash); + sets.push(`failed_attempts = 0`); passwordChanged = true; } if (!sets.length) return res.status(400).json({ error: 'Nothing to update' }); - vals.push(req.params.id); - const { rows } = await pool.query( - `UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} - RETURNING id, username, display_name, role, created_at`, + sets.push(`updated_at = NOW()`); + vals.push(id); + + const r = await pool.query( + `UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING ${SELECT_FIELDS}`, vals ); - if (!rows.length) return res.status(404).json({ error: 'User not found' }); - // BUG FIX #5: Invalidate all active sessions for this user when their - // password is changed. Without this, an attacker who has already stolen a - // session cookie retains access even after the password is rotated, and a - // user who changes their own password doesn't log out other devices. - // - // Implementation note: express-session stores sessions keyed by session ID, - // not by userId. The standard way to invalidate by userId is to query the - // session store. We support two common stores: - // - // 1. connect-pg-simple (Postgres): DELETE FROM sessions WHERE … - // 2. connect-redis (Redis): requires iterating keys (expensive). - // - // We use a best-effort approach: if the session store exposes a `db` - // (pg-simple) we DELETE directly. Otherwise we log a warning — operators - // should configure a session store that supports this. - if (passwordChanged && req.sessionStore) { - try { - const store = req.sessionStore; - if (typeof store.query === 'function') { - // connect-pg-simple exposes the pool as store.pool / store.client - // The session data is a JSON blob; we match on the userId field. - const pgPool = store.pool || store.client; - if (pgPool) { - await pgPool.query( - `DELETE FROM sessions WHERE sess->>'userId' = $1`, - [req.params.id] - ); - console.log(`[users] Invalidated sessions for user ${req.params.id} after password change`); - } - } else if (typeof store.client === 'object' && typeof store.client.keys === 'function') { - // connect-redis: scan for session keys containing this userId. - // This is O(n) over all sessions — acceptable for small deployments. - const prefix = store.prefix || 'sess:'; - const keys = await store.client.keys(`${prefix}*`); - for (const key of keys) { - try { - const raw = await store.client.get(key); - if (!raw) continue; - const data = JSON.parse(raw); - if (String(data.userId) === String(req.params.id)) { - await store.client.del(key); - } - } catch { /* skip malformed sessions */ } - } - console.log(`[users] Invalidated Redis sessions for user ${req.params.id} after password change`); - } else { - console.warn('[users] Session store does not support programmatic invalidation — existing sessions for this user remain valid after password change'); - } - } catch (sessionErr) { - // Non-fatal: the password is already changed; just log the failure. - console.error('[users] Failed to invalidate sessions after password change:', sessionErr.message); - } + // Password rotation invalidates every session for this user. Session + // store is connect-pg-simple, so a direct DELETE on the sessions table + // is the fastest path. Best-effort — never block the response. + if (passwordChanged) { + pool.query(`DELETE FROM sessions WHERE sess->>'userId' = $1`, [id]) + .then(rs => console.log(`[users] invalidated ${rs.rowCount} session(s) for user ${id} after password change`)) + .catch(e => console.warn('[users] session invalidation failed:', e.message)); } - res.json(rows[0]); + audit(req, passwordChanged ? 'user.password.reset' : 'user.update', { + targetType: 'user', + targetId: id, + meta: auditMeta, + }); + res.json(r.rows[0]); } catch (err) { next(err); } }); -// ── Delete ──────────────────────────────────────────────────── +// ── Delete ────────────────────────────────────────────────────────────────── router.delete('/:id', async (req, res, next) => { try { - const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]); - if (!rowCount) return res.status(404).json({ error: 'User not found' }); + const id = req.params.id; + const actorId = req.user?.id || req.session?.userId; + if (actorId && actorId === id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + const target = await pool.query('SELECT id, username, role FROM users WHERE id = $1', [id]); + if (!target.rows.length) return res.status(404).json({ error: 'User not found' }); + if (target.rows[0].role === 'admin') { + const n = await adminCount(); + if (n <= 1) return res.status(400).json({ error: 'Cannot delete the last admin' }); + } + + // Clean up sessions before the row goes away (FK ON DELETE SET NULL on + // audit_log keeps history intact). + await pool.query(`DELETE FROM sessions WHERE sess->>'userId' = $1`, [id]).catch(() => {}); + await pool.query('DELETE FROM users WHERE id = $1', [id]); + audit(req, 'user.delete', { targetType: 'user', targetId: id, meta: { username: target.rows[0].username, role: target.rows[0].role } }); res.json({ message: 'User deleted' }); } catch (err) { next(err); } }); diff --git a/services/mam-api/src/tasks/bootstrapAdmin.js b/services/mam-api/src/tasks/bootstrapAdmin.js new file mode 100644 index 0000000..70d9c9e --- /dev/null +++ b/services/mam-api/src/tasks/bootstrapAdmin.js @@ -0,0 +1,93 @@ +// First-run / break-glass admin bootstrap. +// +// On startup, if the `users` table has zero rows AND both +// ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD +// are set in the environment, create the admin account and log it in +// the audit trail. If a user with that username already exists, do nothing. +// +// Set ADMIN_BOOTSTRAP_RESET=true to force a password reset on the named +// user even when other users exist — for break-glass recovery from a +// container restart. +// +// Bootstrap deliberately does NOT enforce the password policy from +// middleware/passwordPolicy.js: when you're locked out of prod with a +// container restart, you want the env-set password to take, not be +// rejected for missing a symbol. Operator's responsibility to choose +// a strong one. + +import bcrypt from 'bcrypt'; +import pool from '../db/pool.js'; +import audit from '../middleware/audit.js'; + +export async function bootstrapAdmin() { + const username = (process.env.ADMIN_BOOTSTRAP_USER || '').trim().toLowerCase(); + const password = process.env.ADMIN_BOOTSTRAP_PASSWORD || ''; + const reset = process.env.ADMIN_BOOTSTRAP_RESET === 'true'; + const displayName = process.env.ADMIN_BOOTSTRAP_DISPLAY_NAME + || process.env.ADMIN_BOOTSTRAP_USER + || 'Admin'; + + if (!username || !password) return; // not configured, skip + if (password.length < 8) { + console.warn('[bootstrap] ADMIN_BOOTSTRAP_PASSWORD is shorter than 8 characters — skipping'); + return; + } + + const countRes = await pool.query('SELECT COUNT(*)::int AS n FROM users'); + const totalUsers = countRes.rows[0].n; + + const existingRes = await pool.query('SELECT id, username, role FROM users WHERE username = $1', [username]); + const existing = existingRes.rows[0]; + + if (totalUsers > 0 && !existing && !reset) { + // Other users exist, named user does not. Don't auto-create — would + // surprise the operator. They can create the user via the admin UI. + return; + } + + const hash = await bcrypt.hash(password, 12); + + if (existing) { + if (!reset) { + // User already exists and not in reset mode — leave them alone. + return; + } + await pool.query( + `UPDATE users + SET password_hash = $2, + role = 'admin', + is_client = FALSE, + failed_attempts = 0, + display_name = COALESCE(NULLIF($3, ''), display_name), + updated_at = NOW() + WHERE id = $1`, + [existing.id, hash, displayName] + ); + console.log(`[bootstrap] reset password for admin "${username}" via ADMIN_BOOTSTRAP_RESET`); + audit(null, 'auth.bootstrap', { + targetType: 'user', + targetId: existing.id, + actorIdOverride: null, + actorLabelOverride: 'bootstrap', + meta: { username, action: 'password_reset' }, + }); + return; + } + + // No users at all — create the first admin. + const insertRes = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role, is_client) + VALUES ($1, $2, $3, 'admin', FALSE) + RETURNING id`, + [username, hash, displayName] + ); + const newId = insertRes.rows[0].id; + console.log(`[bootstrap] created first admin "${username}" from ADMIN_BOOTSTRAP_USER env`); + audit(null, 'auth.bootstrap', { + targetType: 'user', + targetId: newId, + actorIdOverride: null, + actorLabelOverride: 'bootstrap', + meta: { username, action: 'created' }, + }); +} diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 06e9121..c8f46cc 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -118,6 +118,14 @@ function App() { // Home (launcher) suppresses the topbar — it's a full-bleed landing page. const hideTopbar = !openAsset && route === 'home'; + // Account-settings modal — opened from sidebar's user button. + const [accountOpen, setAccountOpen] = React.useState(false); + React.useEffect(() => { + const open = () => setAccountOpen(true); + window.addEventListener('df:open-account-settings', open); + return () => window.removeEventListener('df:open-account-settings', open); + }, []); + return (
@@ -134,6 +142,9 @@ function App() { {content}
{showNewRecorder && setShowNewRecorder(false)} />} + {accountOpen && window.AccountSettingsModal && ( + setAccountOpen(false)} /> + )} ); } diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 03c38e2..526b0b3 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -244,15 +244,17 @@ async function loadData() { const me = meR.value; const label = me.display_name || me.username || 'User'; window.ZAMPP_DATA.ME = { - id: me.id, - username: me.username, - name: label, - initials: label.slice(0, 2).toUpperCase(), - role: me.role || 'viewer', + id: me.id, + username: me.username, + display_name: me.display_name || me.username, + name: label, + initials: label.slice(0, 2).toUpperCase(), + role: me.role || 'viewer', + is_client: !!me.is_client, // True when the server returned a synthetic user (AUTH_ENABLED=false). // Surfaced as a small "auth off" hint in the sidebar so the operator // understands why the corner shows the OS user instead of a login. - synthetic: !!me.synthetic, + synthetic: !!me.synthetic, }; } } diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index 5c8927e..a6d98f8 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -37,6 +37,7 @@ + diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html index f0fcb36..d227b95 100644 --- a/services/web-ui/public/login.html +++ b/services/web-ui/public/login.html @@ -182,6 +182,8 @@ /* Make the login button full-width (the primitive is inline-flex by default) */ .wd-btn--full { width: 100%; margin-top: 10px; } + .wd-hint { font: 400 11px/1.4 var(--font); color: var(--text-3, #8b92a0); margin-top: 4px; } + /* Stack form groups vertically with consistent gap */ .login-form { display: flex; flex-direction: column; gap: 14px; } @@ -239,8 +241,9 @@
- + +
8+ chars, mixed case, digit, symbol
diff --git a/services/web-ui/public/modal-account-settings.jsx b/services/web-ui/public/modal-account-settings.jsx new file mode 100644 index 0000000..2c90851 --- /dev/null +++ b/services/web-ui/public/modal-account-settings.jsx @@ -0,0 +1,149 @@ +// Account Settings modal — self-service for the currently-signed-in user. +// Change display name + password. Username and role are admin-only. + +function AccountSettingsModal({ onClose }) { + const me = window.ZAMPP_DATA?.ME || {}; + const [tab, setTab] = React.useState('profile'); + const [displayName, setDisplayName] = React.useState(me.display_name || me.name || ''); + const [savingName, setSavingName] = React.useState(false); + const [nameMsg, setNameMsg] = React.useState(null); + + const [curPw, setCurPw] = React.useState(''); + const [newPw, setNewPw] = React.useState(''); + const [newPw2, setNewPw2] = React.useState(''); + const [savingPw, setSavingPw] = React.useState(false); + const [pwMsg, setPwMsg] = React.useState(null); + const [showNew, setShowNew] = React.useState(false); + + const saveName = async (e) => { + e.preventDefault(); + setNameMsg(null); + if (!displayName.trim()) { setNameMsg({ ok: false, text: 'Display name cannot be empty' }); return; } + setSavingName(true); + try { + const r = await window.ZAMPP_API.fetch('/auth/me', { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName.trim() }), + }); + // refresh ME cache + if (window.ZAMPP_DATA) { + window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), display_name: r.display_name, name: r.display_name }; + } + setNameMsg({ ok: true, text: 'Display name updated' }); + } catch (err) { + setNameMsg({ ok: false, text: err.message || 'Update failed' }); + } finally { setSavingName(false); } + }; + + const savePw = async (e) => { + e.preventDefault(); + setPwMsg(null); + if (newPw !== newPw2) { setPwMsg({ ok: false, text: 'New passwords do not match' }); return; } + if (newPw.length < 8) { setPwMsg({ ok: false, text: 'Password must be at least 8 characters' }); return; } + setSavingPw(true); + try { + await window.ZAMPP_API.fetch('/auth/password', { + method: 'POST', + body: JSON.stringify({ current_password: curPw, new_password: newPw }), + }); + setPwMsg({ ok: true, text: 'Password updated — all other sessions signed out' }); + setCurPw(''); setNewPw(''); setNewPw2(''); + } catch (err) { + setPwMsg({ ok: false, text: err.message || 'Update failed' }); + } finally { setSavingPw(false); } + }; + + return ( +
+
e.stopPropagation()}> +
+
+
Account settings
+
+ {me.username || '—'} · {me.role || '—'}{me.is_client ? ' · client' : ''} +
+
+ +
+ +
+ {[ + { id: 'profile', label: 'Profile' }, + { id: 'password', label: 'Password' }, + ].map(t => ( + + ))} +
+ +
+ {tab === 'profile' && ( +
+
+ + +
+
+ + setDisplayName(e.target.value)} maxLength={120} /> +
+ {nameMsg && ( +
+ {nameMsg.text} +
+ )} +
+ +
+
+ )} + + {tab === 'password' && ( +
+
+ + setCurPw(e.target.value)} autoComplete="current-password" required /> +
+
+ +
+ setNewPw(e.target.value)} autoComplete="new-password" required minLength={8} style={{ paddingRight: 36 }} /> + +
+
8+ chars, mixed case, digit, symbol
+
+
+ + setNewPw2(e.target.value)} autoComplete="new-password" required minLength={8} /> +
+ {pwMsg && ( +
+ {pwMsg.text} +
+ )} +
+ +
+
+ )} +
+
+
+ ); +} + +window.AccountSettingsModal = AccountSettingsModal; diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 44ff897..89025e3 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -46,14 +46,16 @@ function _normalizeNode(n, x, y) { } function InviteUserModal({ onCreated, onClose }) { - const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' }); + const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer', is_client: false }); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(null); const submit = () => { if (!form.username || !form.password) { setErr('Username and password are required'); return; } + // Admins cannot be clients — enforce client-side too. + const payload = { ...form, is_client: form.role === 'admin' ? false : form.is_client }; setSaving(true); setErr(null); - window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) }) + window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(payload) }) .then(user => { onCreated(user); onClose(); }) .catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); }); }; @@ -86,17 +88,26 @@ function InviteUserModal({ onCreated, onClose }) { autoComplete="new-password" onChange={e => setForm(p => ({...p, password: e.target.value}))} onKeyDown={onKey} placeholder="Temporary password" /> +
8+ chars, mixed case, digit, symbol
+
+ +
{err &&
{err}
}
@@ -149,8 +160,8 @@ function Users() { }, [menuFor]); const exportCsv = () => { - const rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat( - users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || '']) + const rows = [['Username', 'Name', 'Role', 'Client', 'Groups', 'Last login', 'Created']].concat( + users.map(u => [u.username || '', u.name || '', u.role || '', u.is_client ? 'yes' : 'no', u.group_count || 0, u.last_login_at || '', u.created_at || '']) ); const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n'); const a = document.createElement('a'); @@ -173,11 +184,20 @@ function Users() { const changeRole = (u, newRole) => { if (u.role === newRole) return; - window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) }) + const body = { role: newRole }; + if (newRole === 'admin') body.is_client = false; + window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify(body) }) .then(refreshUsers) .catch(e => alert('Role change failed: ' + e.message)); }; + const toggleClient = (u) => { + if (u.role === 'admin') return; + window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ is_client: !u.is_client }) }) + .then(refreshUsers) + .catch(e => alert('Client toggle failed: ' + e.message)); + }; + return (
@@ -200,8 +220,9 @@ function Users() {
User
Role
+
Client
Groups
-
Created
+
Last login
{users.length === 0 && ( @@ -226,11 +247,19 @@ function Users() {
+
+ +
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
- {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'} + {u.last_login_at ? new Date(u.last_login_at).toLocaleString() : (u.created_at ? `created ${new Date(u.created_at).toLocaleDateString()}` : '—')}
-
{me?.initials || '—'}
-
-
{me?.name || 'Not signed in'}
-
- {me?.role || '—'}{me?.synthetic ? ' · auth off' : ''} +
+ {me?.synthetic ? null : (