diff --git a/docker-compose.yml b/docker-compose.yml index f2a13dd..299a44d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,11 +55,6 @@ 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 deleted file mode 100644 index 686a4ec..0000000 --- a/services/mam-api/src/db/migrations/022-auth-rework.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 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 8b7cf08..20dc7a8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -32,7 +32,6 @@ 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'; @@ -119,7 +118,6 @@ 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); @@ -186,15 +184,6 @@ 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 deleted file mode 100644 index 5ecee90..0000000 --- a/services/mam-api/src/middleware/audit.js +++ /dev/null @@ -1,69 +0,0 @@ -// 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 cd02471..21c06da 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -4,68 +4,45 @@ * 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 the Authorization header. + * - A valid Bearer token in Authorization header (set by POST /api/v1/tokens) * - * 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 + * 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'; -const SYNTHETIC_USER = Object.freeze({ - id: null, - username: 'operator', - role: 'admin', - is_client: false, - synthetic: true, -}); - export const requireAuth = async (req, res, next) => { - if (process.env.AUTH_ENABLED !== 'true') { - req.user = SYNTHETIC_USER; - return next(); - } + if (process.env.AUTH_ENABLED !== 'true') return next(); - // ── Session-based auth ───────────────────────────────────────── + // ── Session-based auth ──────────────────────────────────────── if (req.session?.userId) { req.user = { - id: req.session.userId, - username: req.session.username, - role: req.session.role, - is_client: !!req.session.isClient, + id: req.session.userId, + username: req.session.username, + role: req.session.role, }; 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, 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())`, + `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) { - 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; + 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] @@ -85,25 +62,3 @@ 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 deleted file mode 100644 index 78dd6a3..0000000 --- a/services/mam-api/src/middleware/passwordPolicy.js +++ /dev/null @@ -1,35 +0,0 @@ -// 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 deleted file mode 100644 index de60b31..0000000 --- a/services/mam-api/src/routes/audit.js +++ /dev/null @@ -1,78 +0,0 @@ -// 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 016a432..3c8d862 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,39 +1,37 @@ /** * Authentication routes * - * 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. + * 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) */ import express from 'express'; -import bcrypt from 'bcrypt'; -import pool from '../db/pool.js'; -import audit from '../middleware/audit.js'; -import { checkPassword } from '../middleware/passwordPolicy.js'; +import bcrypt from 'bcrypt'; +import pool from '../db/pool.js'; const router = express.Router(); // --------------------------------------------------------------------------- -// In-memory login rate limiter. +// BUG FIX #6: In-memory login rate limiter. // -// 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. +// 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. // --------------------------------------------------------------------------- -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); +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 +// 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()) { @@ -44,104 +42,110 @@ setInterval(() => { } }, 10 * 60 * 1000).unref(); -function attemptKey(req, username) { - const ip = req.ip || req.socket?.remoteAddress || 'unknown'; - return `${ip}:${String(username || '').trim().toLowerCase()}`; +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 checkRateLimit(req, username) { - const key = attemptKey(req, username); - const entry = loginAttempts.get(key); + const key = getAttemptKey(req, username); 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); + 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); + } } return { limited: false }; } -function recordFail(req, username) { - const key = attemptKey(req, username); +function recordFailedAttempt(req, username) { + const key = getAttemptKey(req, username); const now = Date.now(); - const entry = loginAttempts.get(key) || { attempts: 0, firstAttempt: now, lockedUntil: null }; + const entry = loginAttempts.get(key) || { attempts: 0, lockedUntil: null, firstAttempt: now }; + + // Don't update firstAttempt if there's an existing entry within the window 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(attemptKey(req, username)); + loginAttempts.delete(getAttemptKey(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 id, username, password_hash, display_name, role, is_client FROM users WHERE username = $1', - [String(username).trim().toLowerCase()] + 'SELECT * FROM users WHERE username = $1', + [username.trim().toLowerCase()] ); - const user = result.rows[0]; - - // Constant-time path: even on missing user, run bcrypt against a dummy - // hash so attackers can't enumerate usernames by response time. - if (!user) { + 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'); - recordFail(req, username); - audit(req, 'auth.login.fail', { meta: { username, reason: 'no_such_user' } }); + 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) { - 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 } }); + recordFailedAttempt(req, username); return res.status(401).json({ error: 'Invalid credentials' }); } + // Successful login — clear any accumulated failed attempts 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. + // 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. req.session.regenerate((err) => { - 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 } }); + 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}`); res.json({ id: user.id, username: user.username, display_name: user.display_name, role: user.role, - is_client: !!user.is_client, }); }); } catch (err) { @@ -153,12 +157,9 @@ 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('df.sid'); - if (userId) audit(req, 'auth.logout', { targetType: 'user', targetId: userId, meta: { username } }); + res.clearCookie('connect.sid'); res.json({ message: 'Logged out' }); }); }); @@ -167,159 +168,98 @@ router.post('/logout', (req, res, next) => { // GET /me // --------------------------------------------------------------------------- router.get('/me', async (req, res) => { - // Auth off → synthetic admin so the app loads in dev / unprotected setups. + // 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. 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', - is_client: false, - synthetic: true, + role: 'admin', + synthetic: true, }); } - if (!req.session?.userId) { + if (!req.session || !req.session.userId) { return res.status(401).json({ error: 'Not authenticated' }); } - try { - const r = await pool.query( - 'SELECT id, username, display_name, role, is_client, last_login_at FROM users WHERE id = $1', + const result = await pool.query( + 'SELECT id, username, display_name, role FROM users WHERE id = $1', [req.session.userId] ); - if (r.rows.length === 0) { - // Session points at a user that no longer exists — drop the session. + if (result.rows.length === 0) { req.session.destroy(() => {}); return res.status(401).json({ error: 'User not found' }); } - res.json(r.rows[0]); + res.json(result.rows[0]); } catch (err) { - // DB hiccup — fall back to session data so the UI doesn't blank out. + // Fallback to session data if DB unreachable res.json({ - id: req.session.userId, - username: req.session.username, - role: req.session.role, - is_client: !!req.session.isClient, + id: req.session.userId, + username: req.session.username, + role: req.session.role, }); } }); // --------------------------------------------------------------------------- -// GET /setup-status — front-end hint for login.html +// 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". // --------------------------------------------------------------------------- router.get('/setup-status', async (req, res, next) => { try { - const r = await pool.query('SELECT COUNT(*)::int AS n FROM users'); - const n = r.rows[0].n; + const count = await pool.query('SELECT COUNT(*) FROM users'); + const n = parseInt(count.rows[0].count, 10); 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 — UI-driven first-admin bootstrap. Disabled once any user exists. +// POST /setup — one-time first-admin bootstrap // --------------------------------------------------------------------------- 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' }); } - const policyErr = checkPassword(password, { username }); - if (policyErr) return res.status(400).json({ error: policyErr }); + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } - const count = await pool.query('SELECT COUNT(*)::int AS n FROM users'); - if (count.rows[0].n > 0) { + // Block if any user already exists + const count = await pool.query('SELECT COUNT(*) FROM users'); + if (parseInt(count.rows[0].count, 10) > 0) { return res.status(403).json({ - error: 'Setup is already complete. Use an existing admin to add more users.', + error: 'Setup is already complete. Use an existing admin account to add more users.', }); } const hash = await bcrypt.hash(password, 12); - 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] + 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 newUser = r.rows[0]; - audit(req, 'auth.setup', { targetType: 'user', targetId: newUser.id, meta: { username: newUser.username } }); - res.status(201).json(newUser); + + res.status(201).json(result.rows[0]); } 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 b446d90..6cdf0e0 100644 --- a/services/mam-api/src/routes/capture.js +++ b/services/mam-api/src/routes/capture.js @@ -1,10 +1,9 @@ import express from 'express'; -import { requireAuth, requireRole } from '../middleware/auth.js'; +import { requireAuth } 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 7702838..112449a 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -1,11 +1,10 @@ import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; -import { requireAuth, requireRole } from '../middleware/auth.js'; +import { requireAuth } 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 c2c04f8..7f66445 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -11,7 +11,6 @@ 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 4c2da2f..94b42e8 100644 --- a/services/mam-api/src/routes/schedules.js +++ b/services/mam-api/src/routes/schedules.js @@ -10,7 +10,6 @@ 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 c33d67a..57769ec 100644 --- a/services/mam-api/src/routes/sdk.js +++ b/services/mam-api/src/routes/sdk.js @@ -14,11 +14,10 @@ import multer from 'multer'; import { promises as fs, createWriteStream } from 'fs'; import { spawn } from 'child_process'; import path from 'path'; -import { requireAuth, requireRole } from '../middleware/auth.js'; +import { requireAuth } 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 9c17afe..d5e594c 100644 --- a/services/mam-api/src/routes/users.js +++ b/services/mam-api/src/routes/users.js @@ -1,211 +1,172 @@ /** - * User management routes — admin only. + * User management routes (admin-only when AUTH_ENABLED=true) * - * 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). + * 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 */ 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']; -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 ──────────────────────────────────────────────────────────────────── +// ── List ────────────────────────────────────────────────────── router.get('/', async (_req, res, next) => { try { - 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 + 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 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', is_client = false } = req.body || {}; - if (!username || !password) return res.status(400).json({ error: 'username and password required' }); - if (!VALID_ROLES.includes(role)) { + 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)) 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 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] + 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] ); - - 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]); + res.status(201).json(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 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]); + 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]); } catch (err) { next(err); } }); -// ── Update ────────────────────────────────────────────────────────────────── +// ── Update ──────────────────────────────────────────────────── router.patch('/:id', async (req, res, next) => { try { - 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 { display_name, role, password } = req.body; const sets = []; const vals = []; - const auditMeta = { username: target.username }; + let passwordChanged = false; if (display_name !== undefined) { - sets.push(`display_name = $${vals.length + 1}`); vals.push(String(display_name).trim().slice(0, 120)); - auditMeta.display_name = display_name; + sets.push(`display_name = $${sets.length + 1}`); + vals.push(display_name); } if (role !== undefined) { - sets.push(`role = $${vals.length + 1}`); vals.push(role); - auditMeta.role = role; + 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); } - 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`); + 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); passwordChanged = true; } if (!sets.length) return res.status(400).json({ error: 'Nothing to update' }); - 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.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`, vals ); + if (!rows.length) return res.status(404).json({ error: 'User not found' }); - // 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)); + // 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); + } } - audit(req, passwordChanged ? 'user.password.reset' : 'user.update', { - targetType: 'user', - targetId: id, - meta: auditMeta, - }); - res.json(r.rows[0]); + res.json(rows[0]); } catch (err) { next(err); } }); -// ── Delete ────────────────────────────────────────────────────────────────── +// ── Delete ──────────────────────────────────────────────────── router.delete('/:id', async (req, res, next) => { try { - 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 } }); + 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' }); 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 deleted file mode 100644 index 70d9c9e..0000000 --- a/services/mam-api/src/tasks/bootstrapAdmin.js +++ /dev/null @@ -1,93 +0,0 @@ -// 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 c8f46cc..06e9121 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -118,14 +118,6 @@ 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 (
@@ -142,9 +134,6 @@ 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 526b0b3..03c38e2 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -244,17 +244,15 @@ 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, - display_name: me.display_name || me.username, - name: label, - initials: label.slice(0, 2).toUpperCase(), - role: me.role || 'viewer', - is_client: !!me.is_client, + id: me.id, + username: me.username, + name: label, + initials: label.slice(0, 2).toUpperCase(), + role: me.role || 'viewer', // 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 a6d98f8..5c8927e 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -37,7 +37,6 @@ - diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html index d227b95..f0fcb36 100644 --- a/services/web-ui/public/login.html +++ b/services/web-ui/public/login.html @@ -182,8 +182,6 @@ /* 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; } @@ -241,9 +239,8 @@
- + -
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 deleted file mode 100644 index 2c90851..0000000 --- a/services/web-ui/public/modal-account-settings.jsx +++ /dev/null @@ -1,149 +0,0 @@ -// 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 89025e3..44ff897 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -46,16 +46,14 @@ function _normalizeNode(n, x, y) { } function InviteUserModal({ onCreated, onClose }) { - const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer', is_client: false }); + const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' }); 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(payload) }) + window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) }) .then(user => { onCreated(user); onClose(); }) .catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); }); }; @@ -88,26 +86,17 @@ 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}
}
@@ -160,8 +149,8 @@ function Users() { }, [menuFor]); const exportCsv = () => { - 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 rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat( + users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || '']) ); const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n'); const a = document.createElement('a'); @@ -184,20 +173,11 @@ function Users() { const changeRole = (u, newRole) => { if (u.role === newRole) return; - const body = { role: newRole }; - if (newRole === 'admin') body.is_client = false; - window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify(body) }) + window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) }) .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 (
@@ -220,9 +200,8 @@ function Users() {
User
Role
-
Client
Groups
-
Last login
+
Created
{users.length === 0 && ( @@ -247,19 +226,11 @@ function Users() {
-
- -
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
- {u.last_login_at ? new Date(u.last_login_at).toLocaleString() : (u.created_at ? `created ${new Date(u.created_at).toLocaleDateString()}` : '—')} + {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
- +
{me?.synthetic ? null : (