/** * 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. */ 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'; const router = express.Router(); // --------------------------------------------------------------------------- // 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. // --------------------------------------------------------------------------- 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 loginAttempts = new Map(); 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 attemptKey(req, username) { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; return `${ip}:${String(username || '').trim().toLowerCase()}`; } function checkRateLimit(req, username) { const key = attemptKey(req, username); const entry = loginAttempts.get(key); const now = Date.now(); if (!entry) return { limited: false }; if (entry.lockedUntil && now < entry.lockedUntil) { return { limited: true, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; } if (now - entry.firstAttempt > WINDOW_MS) { loginAttempts.delete(key); } return { limited: false }; } function recordFail(req, username) { const key = attemptKey(req, username); const now = Date.now(); const entry = loginAttempts.get(key) || { attempts: 0, firstAttempt: now, lockedUntil: null }; entry.attempts += 1; 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)); } // --------------------------------------------------------------------------- // 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 result = await pool.query( 'SELECT id, username, password_hash, display_name, role, is_client FROM users WHERE username = $1', [String(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) { await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000'); recordFail(req, username); audit(req, 'auth.login.fail', { meta: { username, reason: 'no_such_user' } }); return res.status(401).json({ error: 'Invalid credentials' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { const n = recordFail(req, username); // Mirror counter to DB so admins can see hammered accounts. Best-effort. pool.query('UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = $1', [user.id]).catch(() => {}); audit(req, 'auth.login.fail', { targetType: 'user', targetId: user.id, meta: { username, attempt: n } }); return res.status(401).json({ error: 'Invalid credentials' }); } clearAttempts(req, username); pool.query( 'UPDATE users SET failed_attempts = 0, last_login_at = NOW() WHERE id = $1', [user.id] ).catch(() => {}); // Regenerate to prevent fixation. Let express-session handle Set-Cookie // and store-write on res.end — DO NOT call session.save() manually. req.session.regenerate((err) => { if (err) return next(err); req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; req.session.isClient = !!user.is_client; audit(req, 'auth.login.success', { targetType: 'user', targetId: user.id, meta: { username: user.username } }); res.json({ id: user.id, username: user.username, display_name: user.display_name, role: user.role, is_client: !!user.is_client, }); }); } catch (err) { next(err); } }); // --------------------------------------------------------------------------- // 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.json({ message: 'Logged out' }); }); }); // --------------------------------------------------------------------------- // GET /me // --------------------------------------------------------------------------- router.get('/me', async (req, res) => { // Auth off → synthetic admin so the app loads in dev / unprotected setups. if (process.env.AUTH_ENABLED !== 'true') { const osUser = process.env.LOCAL_OPERATOR || process.env.USER || process.env.USERNAME || 'operator'; return res.json({ id: null, username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''), display_name: osUser, role: 'admin', is_client: false, synthetic: true, }); } if (!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', [req.session.userId] ); if (r.rows.length === 0) { // Session points at a user that no longer exists — drop the session. req.session.destroy(() => {}); return res.status(401).json({ error: 'User not found' }); } res.json(r.rows[0]); } catch (err) { // DB hiccup — fall back to session data so the UI doesn't blank out. res.json({ id: req.session.userId, username: req.session.username, role: req.session.role, is_client: !!req.session.isClient, }); } }); // --------------------------------------------------------------------------- // GET /setup-status — front-end hint for login.html // --------------------------------------------------------------------------- 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; res.json({ 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. // --------------------------------------------------------------------------- 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' }); } const policyErr = checkPassword(password, { username }); if (policyErr) return res.status(400).json({ error: policyErr }); const count = await pool.query('SELECT COUNT(*)::int AS n FROM users'); if (count.rows[0].n > 0) { return res.status(403).json({ error: 'Setup is already complete. Use an existing admin 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 newUser = r.rows[0]; audit(req, 'auth.setup', { targetType: 'user', targetId: newUser.id, meta: { username: newUser.username } }); res.status(201).json(newUser); } catch (err) { next(err); } }); // --------------------------------------------------------------------------- // POST /password — self-service password change. Requires current password. // --------------------------------------------------------------------------- router.post('/password', async (req, res, next) => { if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) { return res.status(401).json({ error: 'Sign in required' }); } const { current_password, new_password } = req.body || {}; if (!current_password || !new_password) { return res.status(400).json({ error: 'current_password and new_password are required' }); } try { const r = await pool.query( 'SELECT id, username, password_hash FROM users WHERE id = $1', [req.session.userId] ); const u = r.rows[0]; if (!u) return res.status(401).json({ error: 'Session user not found' }); const ok = await bcrypt.compare(current_password, u.password_hash); if (!ok) { audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: false, reason: 'wrong_current' } }); return res.status(401).json({ error: 'Current password is incorrect' }); } const policyErr = checkPassword(new_password, { username: u.username }); if (policyErr) return res.status(400).json({ error: policyErr }); if (current_password === new_password) { return res.status(400).json({ error: 'New password must differ from current' }); } const newHash = await bcrypt.hash(new_password, 12); await pool.query( 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [newHash, u.id] ); audit(req, 'auth.password.change', { targetType: 'user', targetId: u.id, meta: { ok: true } }); res.json({ message: 'Password updated' }); } catch (err) { next(err); } }); // --------------------------------------------------------------------------- // PATCH /me — self-service display_name change. // --------------------------------------------------------------------------- router.patch('/me', async (req, res, next) => { if (process.env.AUTH_ENABLED !== 'true' || !req.session?.userId) { return res.status(401).json({ error: 'Sign in required' }); } const { display_name } = req.body || {}; if (typeof display_name !== 'string' || !display_name.trim()) { return res.status(400).json({ error: 'display_name is required' }); } try { const r = await pool.query( `UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, username, display_name, role, is_client`, [display_name.trim().slice(0, 120), req.session.userId] ); if (r.rows.length === 0) return res.status(404).json({ error: 'User not found' }); audit(req, 'auth.profile.update', { targetType: 'user', targetId: r.rows[0].id, meta: { display_name } }); res.json(r.rows[0]); } catch (err) { next(err); } }); export default router;