dragonflight/services/mam-api/src/routes/auth.js

258 lines
9 KiB
JavaScript
Raw Normal View History

/**
* 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,
});
}
});
fix(auth): make AUTH_ENABLED=true workable end-to-end Three concrete issues kept the login flow broken on dragonflight.live: 1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the session cookie's `secure` flag and the rate-limiter's IP keying both saw the wrong values. Now sets `app.set('trust proxy', 1)`. 2. Session config was tied to NODE_ENV and lacked sameSite/name. Now: - SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a site behind HTTPS gets Secure cookies regardless of NODE_ENV. - `sameSite: 'lax'` for predictable post-login redirects. - Renamed to `df.sid` so it's obvious in DevTools. - `rolling: true` extends the 7-day TTL on active use. - SESSION_SECRET is now required when AUTH_ENABLED=true; the server refuses to start with a dev default in prod. 3. login.html silently showed the sign-in panel even when no users exist or auth is off: - New GET /auth/setup-status reports {needs_setup, user_count, auth_enabled}. - login.html calls it on load and auto-flips into setup mode when needs_setup is true, or shows an explicit "auth is off" flash when auth_enabled is false (the previous symptom: logout button did nothing because /auth/me returned a synthetic admin no matter what). - Added a `.flash.info` style for the new neutral notice. 4. Sidebar logout used to call /auth/logout then `window.location .reload()`. With auth off that reload landed back on the synthetic- admin app and looked like nothing happened. It now redirects to /login.html in all states so the operator sees feedback (and the server-side messaging about auth being off) instead of a no-op. Deploy notes for zampp1: - Set AUTH_ENABLED=true and a random SESSION_SECRET in the mam-api environment (e.g. /opt/wild-dragon/.env). - Restart mam-api. - First load of /login.html will auto-route to the setup form so you can create the first admin.
2026-05-26 22:47:09 -04:00
// ---------------------------------------------------------------------------
// 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;