diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 20dc7a8..f49d9e5 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -1,8 +1,6 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; -import session from 'express-session'; -import ConnectPgSimple from 'connect-pg-simple'; import os from 'node:os'; import { exec } from 'node:child_process'; import pool from './db/pool.js'; @@ -10,7 +8,6 @@ import { errorHandler } from './middleware/errors.js'; import { loadS3ConfigFromDb } from './s3/client.js'; // Routes -import authRouter from './routes/auth.js'; import assetsRouter from './routes/assets.js'; import projectsRouter from './routes/projects.js'; import binsRouter from './routes/bins.js'; @@ -20,9 +17,7 @@ import uploadRouter from './routes/upload.js'; import recordersRouter from './routes/recorders.js'; import settingsRouter from './routes/settings.js'; import amppRouter from './routes/ampp.js'; -import usersRouter from './routes/users.js'; import groupsRouter from './routes/groups.js'; -import tokensRouter from './routes/tokens.js'; import sequencesRouter from './routes/sequences.js'; import systemRouter from './routes/system.js'; import clusterRouter from './routes/cluster.js'; @@ -39,64 +34,13 @@ const app = express(); const PORT = process.env.PORT || 3000; // ── Middleware ──────────────────────────────────────────────────────────────── -// Trust the first proxy (nginx in front of us) so req.ip, req.secure, and -// req.protocol reflect the real client request — required for both the -// login rate-limiter's IP keying and `cookie.secure` cookie issuance. -app.set('trust proxy', 1); - app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '50mb' })); -const PgSession = ConnectPgSimple(session); - -// Session security knobs. -// -// - `secure` is set from SESSION_COOKIE_SECURE (default: true when AUTH_ENABLED). -// `trust proxy` above tells express-session that x-forwarded-proto can be -// trusted, so it issues Secure cookies on HTTPS requests forwarded by -// nginx/Cloudflare even though the proxy → mam-api hop is plain HTTP. -// Set SESSION_COOKIE_SECURE=false explicitly for local-only HTTP testing. -// - `sameSite: 'lax'` ships the cookie on top-level navigations (including -// the post-login redirect from /login.html) but blocks cross-site POSTs. -// - Renamed from default `connect.sid` to `df.sid` so it's obvious in DevTools. -// - `rolling: true` refreshes maxAge on every request so an active user -// doesn't get bounced to login after the 7-day TTL. -const authEnabled = process.env.AUTH_ENABLED === 'true'; -const SESSION_SECRET = process.env.SESSION_SECRET - || (authEnabled - ? (() => { throw new Error('SESSION_SECRET is required when AUTH_ENABLED=true'); })() - : 'dev-only-not-for-production'); - -const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE - ? process.env.SESSION_COOKIE_SECURE === 'true' - : authEnabled; // default: secure cookies whenever auth is on - -app.use( - session({ - name: 'df.sid', - store: new PgSession({ - pool, - tableName: 'sessions', - pruneSessionInterval: 3600, - }), - secret: SESSION_SECRET, - resave: false, - saveUninitialized: false, - rolling: true, - cookie: { - secure: SESSION_COOKIE_SECURE, - httpOnly: true, - sameSite: 'lax', - maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days - }, - }) -); - -// ── Health (no auth) ────────────────────────────────────────────────────────── +// ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ status: 'ok' })); // ── API Routes ──────────────────────────────────────────────────────────────── -app.use('/api/v1/auth', authRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); @@ -106,9 +50,7 @@ app.use('/api/v1/upload', uploadRouter); app.use('/api/v1/recorders', recordersRouter); app.use('/api/v1/settings', settingsRouter); app.use('/api/v1/ampp', amppRouter); -app.use('/api/v1/users', usersRouter); app.use('/api/v1/groups', groupsRouter); -app.use('/api/v1/tokens', tokensRouter); app.use('/api/v1/sequences', sequencesRouter); app.use('/api/v1/system', systemRouter); app.use('/api/v1/cluster', clusterRouter); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js deleted file mode 100644 index 21c06da..0000000 --- a/services/mam-api/src/middleware/auth.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Authentication middleware. - * - * When AUTH_ENABLED=true in the environment, every protected route requires - * either: - * - An active session (set by POST /api/v1/auth/login), or - * - A valid Bearer token in Authorization header (set by POST /api/v1/tokens) - * - * When AUTH_ENABLED is unset or any other value, all middleware is a no-op so - * the stack can be run without user accounts during development. - */ -import crypto from 'crypto'; -import pool from '../db/pool.js'; - -export const requireAuth = async (req, res, next) => { - if (process.env.AUTH_ENABLED !== 'true') return next(); - - // ── Session-based auth ──────────────────────────────────────── - if (req.session?.userId) { - req.user = { - id: req.session.userId, - username: req.session.username, - role: req.session.role, - }; - return next(); - } - - // ── Bearer token auth ───────────────────────────────────────── - const authHeader = req.headers.authorization; - if (authHeader?.startsWith('Bearer ')) { - const raw = authHeader.slice(7).trim(); - const hash = crypto.createHash('sha256').update(raw).digest('hex'); - try { - const { rows } = await pool.query( - `SELECT t.user_id AS id, u.username, u.role, t.bound_hostname - FROM api_tokens t - JOIN users u ON u.id = t.user_id - WHERE t.token_hash = $1 - AND (t.expires_at IS NULL OR t.expires_at > NOW())`, - [hash] - ); - if (rows.length > 0) { - req.user = rows[0]; - req.tokenBoundHostname = rows[0].bound_hostname || null; - // Fire-and-forget last_used_at update - pool.query( - 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', - [hash] - ).catch(() => {}); - return next(); - } - } catch (err) { - return next(err); - } - } - - return res.status(401).json({ error: 'Unauthorized' }); -}; - -export const requireAdmin = (req, res, next) => { - if (process.env.AUTH_ENABLED !== 'true') return next(); - if (req.user?.role === 'admin') return next(); - return res.status(403).json({ error: 'Admin access required' }); -}; diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index e4d2fd2..27ccfba 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -6,12 +6,9 @@ import path from 'node:path'; import pool from '../db/pool.js'; import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; const router = express.Router(); - -router.use(requireAuth); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // BullMQ queue connection (mirrors worker/src/index.js) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js deleted file mode 100644 index 3c8d862..0000000 --- a/services/mam-api/src/routes/auth.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * 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) - */ -import express from 'express'; -import bcrypt from 'bcrypt'; -import pool from '../db/pool.js'; - -const router = express.Router(); - -// --------------------------------------------------------------------------- -// BUG FIX #6: 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. -// --------------------------------------------------------------------------- -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()) { - const expired = entry.lockedUntil - ? now > entry.lockedUntil - : now - entry.firstAttempt > WINDOW_MS; - if (expired) loginAttempts.delete(key); - } -}, 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 checkRateLimit(req, username) { - const key = getAttemptKey(req, username); - const now = Date.now(); - 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 recordFailedAttempt(req, username) { - const key = getAttemptKey(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 - entry.attempts += 1; - if (entry.attempts >= MAX_ATTEMPTS) { - entry.lockedUntil = now + LOCKOUT_MS; - } - loginAttempts.set(key, entry); -} - -function clearAttempts(req, username) { - loginAttempts.delete(getAttemptKey(req, username)); -} - -// --------------------------------------------------------------------------- -// POST /login -// --------------------------------------------------------------------------- -router.post('/login', async (req, res, next) => { - 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()] - ); - - 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); - return res.status(401).json({ error: 'Invalid credentials' }); - } - - // Successful login — clear any accumulated failed attempts - clearAttempts(req, username); - - // 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) { - 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, - }); - }); - } catch (err) { - next(err); - } -}); - -// --------------------------------------------------------------------------- -// POST /logout -// --------------------------------------------------------------------------- -router.post('/logout', (req, res, next) => { - req.session.destroy((err) => { - if (err) return next(err); - res.clearCookie('connect.sid'); - res.json({ message: 'Logged out' }); - }); -}); - -// --------------------------------------------------------------------------- -// 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. - if (process.env.AUTH_ENABLED !== 'true') { - 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, ''), - display_name: osUser, - role: 'admin', - synthetic: true, - }); - } - - if (!req.session || !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', - [req.session.userId] - ); - if (result.rows.length === 0) { - req.session.destroy(() => {}); - return res.status(401).json({ error: 'User not found' }); - } - res.json(result.rows[0]); - } catch (err) { - // Fallback to session data if DB unreachable - res.json({ - id: req.session.userId, - username: req.session.username, - role: req.session.role, - }); - } -}); - -// --------------------------------------------------------------------------- -// 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 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', - }); - } catch (err) { next(err); } -}); - -// --------------------------------------------------------------------------- -// POST /setup — one-time first-admin bootstrap -// --------------------------------------------------------------------------- -router.post('/setup', async (req, res, next) => { - try { - 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' }); - } - - // 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 account 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] - ); - - res.status(201).json(result.rows[0]); - } catch (err) { - next(err); - } -}); - -export default router; diff --git a/services/mam-api/src/routes/bins.js b/services/mam-api/src/routes/bins.js index c7dbfd4..1fc91db 100644 --- a/services/mam-api/src/routes/bins.js +++ b/services/mam-api/src/routes/bins.js @@ -1,12 +1,9 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); - -router.use(requireAuth); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // GET / - List bins. Filter by project_id when supplied; otherwise return diff --git a/services/mam-api/src/routes/capture.js b/services/mam-api/src/routes/capture.js index 6cdf0e0..2f14630 100644 --- a/services/mam-api/src/routes/capture.js +++ b/services/mam-api/src/routes/capture.js @@ -1,10 +1,7 @@ import express from 'express'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); -router.use(requireAuth); - const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001'; async function proxyRequest(method, path, body = null) { diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 112449a..3a0a417 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -1,10 +1,8 @@ import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); -router.use(requireAuth); // 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/comments.js b/services/mam-api/src/routes/comments.js index 4b65089..6701155 100644 --- a/services/mam-api/src/routes/comments.js +++ b/services/mam-api/src/routes/comments.js @@ -5,10 +5,8 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router({ mergeParams: true }); -router.use(requireAuth); function rowToJson(r) { return { diff --git a/services/mam-api/src/routes/groups.js b/services/mam-api/src/routes/groups.js index cf475d3..5e6764c 100644 --- a/services/mam-api/src/routes/groups.js +++ b/services/mam-api/src/routes/groups.js @@ -11,10 +11,8 @@ */ 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); // ── List ────────────────────────────────────────────────────── router.get('/', async (_req, res, next) => { diff --git a/services/mam-api/src/routes/imports.js b/services/mam-api/src/routes/imports.js index 33afd64..7540e46 100644 --- a/services/mam-api/src/routes/imports.js +++ b/services/mam-api/src/routes/imports.js @@ -10,10 +10,8 @@ import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); -router.use(requireAuth); const parseRedisUrl = (url) => { try { diff --git a/services/mam-api/src/routes/jobs.js b/services/mam-api/src/routes/jobs.js index 58d2cda..47db682 100644 --- a/services/mam-api/src/routes/jobs.js +++ b/services/mam-api/src/routes/jobs.js @@ -1,11 +1,9 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; import { Queue } from 'bullmq'; const router = express.Router(); -router.use(requireAuth); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // ── Redis connection ────────────────────────────────────────────────────────── diff --git a/services/mam-api/src/routes/metrics.js b/services/mam-api/src/routes/metrics.js index 8535443..44518fd 100644 --- a/services/mam-api/src/routes/metrics.js +++ b/services/mam-api/src/routes/metrics.js @@ -5,10 +5,8 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); -router.use(requireAuth); const DEFAULT_HOURS = 24; const DEFAULT_POINTS = 13; diff --git a/services/mam-api/src/routes/projects.js b/services/mam-api/src/routes/projects.js index 18e3089..3524ab0 100644 --- a/services/mam-api/src/routes/projects.js +++ b/services/mam-api/src/routes/projects.js @@ -1,12 +1,9 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); - -router.use(requireAuth); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // Helper function to slugify diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 7f66445..a2a6914 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -4,13 +4,10 @@ import net from 'net'; import dgram from 'dgram'; import pool from '../db/pool.js'; import { getS3Bucket } from '../s3/client.js'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; 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..0b07605 100644 --- a/services/mam-api/src/routes/schedules.js +++ b/services/mam-api/src/routes/schedules.js @@ -5,11 +5,9 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; 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..aafbb46 100644 --- a/services/mam-api/src/routes/sdk.js +++ b/services/mam-api/src/routes/sdk.js @@ -14,10 +14,8 @@ 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'; const router = express.Router(); -router.use(requireAuth); const SDK_ROOT = process.env.SDK_ROOT || '/sdk'; diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js index 01d2825..f806159 100644 --- a/services/mam-api/src/routes/sequences.js +++ b/services/mam-api/src/routes/sequences.js @@ -2,7 +2,6 @@ import express from 'express'; import pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; -import { requireAuth } from '../middleware/auth.js'; import { validateUuid } from '../middleware/errors.js'; import { Queue } from 'bullmq'; @@ -20,7 +19,6 @@ const conformQueue = new Queue('conform', { }); const router = express.Router(); -router.use(requireAuth); router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // ── Row mapper ──────────────────────────────────────────────────────────────── diff --git a/services/mam-api/src/routes/settings.js b/services/mam-api/src/routes/settings.js index b37863a..f7cefdc 100644 --- a/services/mam-api/src/routes/settings.js +++ b/services/mam-api/src/routes/settings.js @@ -1,11 +1,9 @@ import express from 'express'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; import { getAmppConfig } from '../ampp/client.js'; import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js'; const router = express.Router(); -router.use(requireAuth); // ── S3 / Object Storage ─────────────────────────────────────────────────────── diff --git a/services/mam-api/src/routes/storage.js b/services/mam-api/src/routes/storage.js index e14a4f7..9d767a8 100644 --- a/services/mam-api/src/routes/storage.js +++ b/services/mam-api/src/routes/storage.js @@ -8,12 +8,10 @@ import { promisify } from 'node:util'; import { exec as execCb } from 'node:child_process'; import { HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; import { s3Client, getS3Bucket } from '../s3/client.js'; const exec = promisify(execCb); const router = express.Router(); -router.use(requireAuth); // Defaults mirrored from settings.js so the overview never returns nulls. const GROWING_DEFAULTS = { diff --git a/services/mam-api/src/routes/system.js b/services/mam-api/src/routes/system.js index 38b131f..1d9b928 100644 --- a/services/mam-api/src/routes/system.js +++ b/services/mam-api/src/routes/system.js @@ -1,9 +1,7 @@ import express from 'express'; import http from 'node:http'; -import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); -router.use(requireAuth); const DOCKER_SOCKET = '/var/run/docker.sock'; const COMPOSE_PROJECT = (process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon').split('_')[0]; diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js deleted file mode 100644 index 3f031b7..0000000 --- a/services/mam-api/src/routes/tokens.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Personal API token routes (requires authentication) - * - * GET /api/v1/tokens — list current user's tokens (no raw values) - * POST /api/v1/tokens — create token, returns raw value ONCE - * DELETE /api/v1/tokens/:id — revoke token - */ -import express from 'express'; -import crypto from 'crypto'; -import pool from '../db/pool.js'; -import { requireAuth } from '../middleware/auth.js'; - -const router = express.Router(); -router.use(requireAuth); - -// Helper: get current user ID from session or req.user -const userId = req => req.user?.id || req.session?.userId; - -// ── List ────────────────────────────────────────────────────── -router.get('/', async (req, res, next) => { - try { - const { rows } = await pool.query( - `SELECT id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname - FROM api_tokens - WHERE user_id = $1 - ORDER BY created_at DESC`, - [userId(req)] - ); - res.json(rows); - } catch (err) { next(err); } -}); - -// ── Create ──────────────────────────────────────────────────── -router.post('/', async (req, res, next) => { - try { - const { name, expires_in_days, bound_hostname } = req.body; - if (!name) return res.status(400).json({ error: 'name required' }); - - // Generate: wd_ + 40 random hex chars = 43 chars total - const raw = 'wd_' + crypto.randomBytes(20).toString('hex'); - const hash = crypto.createHash('sha256').update(raw).digest('hex'); - const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars - - const expiresAt = expires_in_days - ? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000) - : null; - - // Issue #106 — `bound_hostname` ties a token to a single worker hostname. - // Heartbeats are rejected if the body hostname doesn't match the binding, - // preventing a stolen worker token from hijacking another node's api_url. - const bound = bound_hostname && typeof bound_hostname === 'string' - ? bound_hostname.trim() || null - : null; - - const { rows } = await pool.query( - `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname`, - [userId(req), name.trim(), hash, prefix, expiresAt, bound] - ); - - // Return raw token ONCE — it is never stored in plaintext - res.status(201).json({ ...rows[0], token: raw }); - } catch (err) { next(err); } -}); - -// ── Revoke ──────────────────────────────────────────────────── -router.delete('/:id', async (req, res, next) => { - try { - const { rowCount } = await pool.query( - `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`, - [req.params.id, userId(req)] - ); - if (!rowCount) return res.status(404).json({ error: 'Token not found' }); - res.json({ message: 'Token revoked' }); - } catch (err) { next(err); } -}); - -export default router; diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js deleted file mode 100644 index d5e594c..0000000 --- a/services/mam-api/src/routes/users.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * User management routes (admin-only when AUTH_ENABLED=true) - * - * 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'; - -const router = express.Router(); -router.use(requireAuth, requireAdmin); - -const VALID_ROLES = ['admin', 'editor', 'viewer']; - -// ── 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 - GROUP BY u.id - ORDER BY u.created_at` - ); - res.json(rows); - } catch (err) { next(err); } -}); - -// ── 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)) - return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` }); - - 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] - ); - res.status(201).json(rows[0]); - } catch (err) { - if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' }); - next(err); - } -}); - -// ── 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]); - } catch (err) { next(err); } -}); - -// ── Update ──────────────────────────────────────────────────── -router.patch('/:id', async (req, res, next) => { - try { - const { display_name, role, password } = req.body; - const sets = []; const vals = []; - let passwordChanged = false; - - if (display_name !== undefined) { - sets.push(`display_name = $${sets.length + 1}`); - vals.push(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); - } - 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' }); - - 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' }); - - // 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); - } - } - - res.json(rows[0]); - } catch (err) { next(err); } -}); - -// ── 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' }); - res.json({ message: 'User deleted' }); - } catch (err) { next(err); } -}); - -export default router; diff --git a/services/web-ui/public/js/auth-guard.js b/services/web-ui/public/js/auth-guard.js deleted file mode 100644 index 2c26a31..0000000 --- a/services/web-ui/public/js/auth-guard.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * auth-guard.js - * Included on every protected page. - * - * - If /api/v1/auth/me returns 401 → redirect to login.html immediately. - * (When AUTH_ENABLED=false the endpoint returns a synthetic guest user, - * so the redirect only fires in production auth-enabled mode.) - * - On success, populate the sidebar user widget and wire up the logout button. - * - Side effect: marks any sidebar link pointing at editor.html with an - * "IN DEV" badge, so we don't have to touch every HTML file individually. - */ - -// ── Cross-page IN-DEV markers ───────────────────────────────────────────── -// Add (page-name → label) here to flag a sidebar item without editing all -// 13 HTML files. The CSS + DOM patch runs once on every page. -const IN_DEV_PAGES = { - 'editor.html': 'IN DEV', -}; - -(function tagInDevNavItems() { - // 1. Inject the badge styles once. Kept inline so we don't add another - // HTTP request per page — and so the rule can't get out of sync with - // the auth-guard that toggles it. - if (!document.getElementById('in-dev-style')) { - const style = document.createElement('style'); - style.id = 'in-dev-style'; - style.textContent = ` - .nav-item.is-in-dev { position: relative; } - .nav-item.is-in-dev .nav-dev-badge { - margin-left: auto; - font-size: 9px; - font-weight: 700; - letter-spacing: 0.12em; - padding: 2px 6px; - border-radius: 4px; - background: oklch(28% 0.14 80 / 0.45); - color: oklch(85% 0.16 85); - border: 1px solid oklch(50% 0.16 80 / 0.55); - text-transform: uppercase; - line-height: 1; - flex-shrink: 0; - } - .nav-item.is-in-dev:hover .nav-dev-badge { - background: oklch(32% 0.16 80 / 0.55); - } - `; - document.head.appendChild(style); - } - - // 2. Walk every sidebar nav-item link and tag the ones whose href matches - // a known in-dev page. Href is matched case-insensitively against the - // final path segment so '/editor.html', 'editor.html', and absolute - // URLs all hit. - const links = document.querySelectorAll('.nav-item'); - links.forEach((a) => { - const href = (a.getAttribute('href') || '').toLowerCase(); - const last = href.split('/').pop().split('?')[0]; - const label = IN_DEV_PAGES[last]; - if (!label) return; - if (a.querySelector('.nav-dev-badge')) return; // idempotent - a.classList.add('is-in-dev'); - const badge = document.createElement('span'); - badge.className = 'nav-dev-badge'; - badge.textContent = label; - a.appendChild(badge); - }); -})(); - -// ── Auth + user widget ──────────────────────────────────────────────────── -(async () => { - try { - const r = await fetch('/api/v1/auth/me', { credentials: 'include' }); - if (r.status === 401) { - location.replace('login.html'); - return; - } - if (r.ok) { - const u = await r.json(); - const name = u.display_name || u.username || 'User'; - const userNameEl = document.getElementById('userName'); - const userAvatarEl = document.getElementById('userAvatar'); - const userRoleEl = document.getElementById('userRole'); - if (userNameEl) userNameEl.textContent = name; - if (userAvatarEl) userAvatarEl.textContent = name[0].toUpperCase(); - if (userRoleEl) userRoleEl.textContent = u.role || ''; - } - } catch (_) { - // Network error — don't redirect; the user may be on a dev build without auth. - } - const logoutBtn = document.getElementById('logoutBtn'); - if (logoutBtn) { - logoutBtn.onclick = async () => { - try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {} - location.href = 'login.html'; - }; - } -})(); diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html deleted file mode 100644 index f0fcb36..0000000 --- a/services/web-ui/public/login.html +++ /dev/null @@ -1,317 +0,0 @@ - - - - - - - Sign in - Dragonflight - - - - - - - -
-
-
- Dragonflight -
-
-
Dragonflight
-
Media Asset Management
-
-
-
- -
-

Sign in

-

Enter your credentials to continue.

- - -
- -
-

Create admin

-

No accounts exist yet, create the first admin.

- - -
-
- - -