rip out entire auth/login flow

- remove requireAuth from all route files
- delete auth.js, tokens.js, users.js routes
- delete auth middleware
- remove session middleware and all auth deps from index.js
- delete login.html and auth-guard.js from web-ui
This commit is contained in:
Zac Gaetano 2026-05-27 03:39:58 +00:00
parent 9726dbb2df
commit 4172b0d70a
24 changed files with 1 additions and 1094 deletions

View file

@ -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);

View file

@ -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' });
};

View file

@ -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)

View file

@ -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;

View file

@ -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

View file

@ -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) {

View file

@ -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.

View file

@ -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 {

View file

@ -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) => {

View file

@ -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 {

View file

@ -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 ──────────────────────────────────────────────────────────

View file

@ -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;

View file

@ -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

View file

@ -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.

View file

@ -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']);

View file

@ -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';

View file

@ -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 ────────────────────────────────────────────────────────────────

View file

@ -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 ───────────────────────────────────────────────────────

View file

@ -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 = {

View file

@ -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];

View file

@ -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;

View file

@ -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;

View file

@ -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';
};
}
})();

View file

@ -1,317 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Sign in - Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
/* Page-only layout. Everything visual is from /dist/app.css primitives. */
body { display: grid; grid-template-columns: 1fr 460px; min-height: 100vh; margin: 0; }
@media (max-width: 900px) {
body { grid-template-columns: 1fr; grid-template-rows: 40vh 1fr; }
}
.hero {
position: relative;
overflow: hidden;
background: radial-gradient(ellipse at 30% 40%, var(--bg-panel) 0%, var(--bg-deep) 70%);
display: flex;
align-items: flex-end;
padding: 48px 56px;
}
.hero-img {
position: absolute;
inset: 0;
background-image: url(img/ampp-safe.png?v=hardhat3);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.hero-grad-bot {
position: absolute;
inset: auto 0 0 0;
height: 40%;
background: linear-gradient(to top, var(--bg-deep) 0%, transparent 100%);
pointer-events: none;
}
.hero-stamp {
position: absolute;
top: 32px;
left: 32px;
display: inline-flex;
align-items: center;
gap: 8px;
z-index: 2;
background: var(--overlay);
backdrop-filter: blur(6px);
padding: 6px 12px;
border: 1px solid var(--accent-border);
border-radius: 999px;
}
.hero-stamp-dot {
width: 6px;
height: 6px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 10px var(--accent);
}
.hero-stamp-text {
font: 600 10px/1 var(--font);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-bright);
}
.hero-caption {
position: relative;
z-index: 2;
max-width: 520px;
}
.hero-caption h2 {
font: 600 28px/1.15 var(--font);
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0 0 10px;
}
.hero-caption p {
font: 400 14px/1.55 var(--font);
color: var(--text-secondary);
margin: 0;
}
.hero-caption .accent { color: var(--accent-bright); }
/* Right column: brand + form panel */
.panel {
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 56px;
background: var(--bg-panel);
border-left: 1px solid var(--border);
}
@media (max-width: 900px) {
.panel { padding: 32px 24px; border-left: none; border-top: 1px solid var(--border); }
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 36px;
}
.brand-icon {
width: 32px;
height: 32px;
background: var(--accent);
color: var(--bg-deep);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font: 700 15px/1 var(--font);
}
.brand-icon img {
filter: brightness(0) invert(1);
}
.brand-name {
font: 600 15px/1.2 var(--font);
letter-spacing: -0.01em;
color: var(--text-primary);
}
.brand-sub {
font: 500 10px/1 var(--font);
color: var(--text-tertiary);
letter-spacing: 0.14em;
text-transform: uppercase;
margin-top: 3px;
}
.panel h1 {
font: 600 22px/1.2 var(--font);
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0 0 6px;
}
.panel .subtitle {
font: 400 13px/1.5 var(--font);
color: var(--text-secondary);
margin: 0 0 28px;
}
/* Flash messages — inline toast-shaped */
.flash {
display: none;
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 18px;
font: 400 13px/1.4 var(--font);
color: var(--text-primary);
}
.flash::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
border-radius: 4px 4px 0 0;
}
.flash.error { display: block; border-color: var(--signal-bad); }
.flash.error::before { background: var(--signal-bad); }
.flash.success { display: block; border-color: var(--signal-good); }
.flash.success::before { background: var(--signal-good); }
.flash.info { display: block; border-color: var(--accent, #5b7cfa); }
.flash.info::before { background: var(--accent, #5b7cfa); }
.setup-link {
text-align: center;
margin-top: 24px;
font: 400 12px/1.4 var(--font);
color: var(--text-tertiary);
}
.setup-link a {
color: var(--accent-bright);
text-decoration: none;
}
.setup-link a:hover { color: var(--accent); text-decoration: underline; }
#setup-panel { display: none; }
/* Make the login button full-width (the primitive is inline-flex by default) */
.wd-btn--full { width: 100%; margin-top: 10px; }
/* Stack form groups vertically with consistent gap */
.login-form { display: flex; flex-direction: column; gap: 14px; }
</style>
</head>
<body>
<section class="hero" aria-hidden="true">
<div class="hero-img"></div>
<div class="hero-grad-bot"></div>
<div class="hero-stamp">
<span class="hero-stamp-dot"></span>
<span class="hero-stamp-text">Dragonflight</span>
</div>
<div class="hero-caption">
<h2>Broadcast-safe media,<br><span class="accent">end to end.</span></h2>
<p>Dragonflight is the Wild Dragon media-asset hub. Ingest live SRT &amp; RTMP feeds, upload masters, and hand proxies to your editors in seconds.</p>
</div>
</section>
<section class="panel">
<div class="brand">
<div class="brand-icon">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;">
</div>
<div>
<div class="brand-name">Dragonflight</div>
<div class="brand-sub">Media Asset Management</div>
</div>
</div>
<div id="flash" class="flash"></div>
<div id="login-panel">
<h1>Sign in</h1>
<p class="subtitle">Enter your credentials to continue.</p>
<form id="login-form" class="login-form" autocomplete="on">
<div class="wd-form-group">
<label class="wd-label" for="username">Username</label>
<input class="wd-input" id="username" name="username" type="text" autocomplete="username" required placeholder="username">
</div>
<div class="wd-form-group">
<label class="wd-label" for="password">Password</label>
<input class="wd-input" id="password" name="password" type="password" autocomplete="current-password" required placeholder="••••••••">
</div>
<button type="submit" class="wd-btn wd-btn--primary wd-btn--md wd-btn--full" id="login-btn">Sign in</button>
</form>
<div class="setup-link">First time? <a href="#" id="show-setup">Create admin account</a></div>
</div>
<div id="setup-panel">
<h1>Create admin</h1>
<p class="subtitle">No accounts exist yet, create the first admin.</p>
<form id="setup-form" class="login-form" autocomplete="off">
<div class="wd-form-group">
<label class="wd-label" for="su-username">Username</label>
<input class="wd-input" id="su-username" name="username" type="text" required placeholder="admin">
</div>
<div class="wd-form-group">
<label class="wd-label" for="su-password">Password (min 8 chars)</label>
<input class="wd-input" id="su-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="••••••••">
</div>
<div class="wd-form-group">
<label class="wd-label" for="su-display">Display name (optional)</label>
<input class="wd-input" id="su-display" name="display_name" type="text" placeholder="Admin">
</div>
<button type="submit" class="wd-btn wd-btn--primary wd-btn--md wd-btn--full" id="setup-btn">Create account</button>
</form>
<div class="setup-link"><a href="#" id="show-login">Back to login</a></div>
</div>
</section>
<script>
const API = '/api/v1/auth';
const $ = id => document.getElementById(id);
const flash = $('flash');
function showFlash(m,t){ flash.textContent=m; flash.className='flash '+t; }
function clearFlash(){ flash.className='flash'; flash.textContent=''; }
function showSetup(){ $('login-panel').style.display='none'; $('setup-panel').style.display='block'; }
function showLogin(){ $('setup-panel').style.display='none'; $('login-panel').style.display='block'; }
$('show-setup').onclick = e => { e.preventDefault(); clearFlash(); showSetup(); };
$('show-login').onclick = e => { e.preventDefault(); clearFlash(); showLogin(); };
// Auth is parked for now. If the server reports auth is disabled, bounce
// straight to the app — no one should ever land on this screen while
// AUTH_ENABLED=false. If the server is unreachable, leave the panel
// visible so the operator at least sees something.
(async () => {
try {
const r = await fetch(API + '/setup-status', { credentials: 'same-origin' });
if (r.ok) {
const d = await r.json();
if (!d.auth_enabled) {
location.replace('/');
return;
}
if (d.needs_setup) {
showSetup();
showFlash('No accounts yet — create the first admin to continue.', 'info');
}
}
} catch (_) { /* offline → leave the login panel visible */ }
})();
$('login-form').onsubmit = async (e) => {
e.preventDefault(); clearFlash();
const btn=$('login-btn'); btn.disabled=true; btn.textContent='Signing in...';
try{
const res = await fetch(API + '/login', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
body: JSON.stringify({username:$('username').value.trim(),password:$('password').value})});
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='/'},600); }
else{ const d=await res.json().catch(()=>({})); showFlash(d.error||'Login failed','error'); }
} catch(err){ showFlash('Network error: '+err.message,'error'); }
finally{ btn.disabled=false; btn.textContent='Sign in'; }
};
$('setup-form').onsubmit = async (e) => {
e.preventDefault(); clearFlash();
const btn=$('setup-btn'); btn.disabled=true; btn.textContent='Creating...';
try{
const res = await fetch(API + '/setup', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
body: JSON.stringify({username:$('su-username').value.trim(),password:$('su-password').value,display_name:$('su-display').value.trim()})});
if(res.ok){
showFlash('Admin account created, you can now log in','success');
setTimeout(()=>{ $('setup-panel').style.display='none'; $('login-panel').style.display='block'; },1200);
} else{
const d=await res.json().catch(()=>({}));
showFlash(d.error||'Setup failed','error');
}
} catch(err){ showFlash('Network error: '+err.message,'error'); }
finally{ btn.disabled=false; btn.textContent='Create account'; }
};
</script>
</body></html>