/** * 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 req.session.regenerate((err) => { if (err) return next(err); req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; 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;