2026-05-15 23:40:11 -04:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
|
2026-05-26 07:39:14 -04:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:40:11 -04:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 07:39:14 -04:00
|
|
|
// 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.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:40:11 -04:00
|
|
|
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');
|
2026-05-26 07:39:14 -04:00
|
|
|
recordFailedAttempt(req, username);
|
2026-05-15 23:40:11 -04:00
|
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = result.rows[0];
|
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
|
|
|
|
|
|
|
|
if (!valid) {
|
2026-05-26 07:39:14 -04:00
|
|
|
recordFailedAttempt(req, username);
|
2026-05-15 23:40:11 -04:00
|
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 07:39:14 -04:00
|
|
|
// Successful login — clear any accumulated failed attempts
|
|
|
|
|
clearAttempts(req, username);
|
|
|
|
|
|
2026-05-26 22:59:15 -04:00
|
|
|
// 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.
|
2026-05-15 23:40:11 -04:00
|
|
|
req.session.regenerate((err) => {
|
2026-05-26 22:54:25 -04:00
|
|
|
if (err) {
|
|
|
|
|
console.error('[auth] session.regenerate failed:', err);
|
|
|
|
|
return next(err);
|
|
|
|
|
}
|
2026-05-15 23:40:11 -04:00
|
|
|
req.session.userId = user.id;
|
|
|
|
|
req.session.username = user.username;
|
|
|
|
|
req.session.role = user.role;
|
2026-05-26 22:59:15 -04:00
|
|
|
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,
|
2026-05-15 23:40:11 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} 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) => {
|
2026-05-23 14:52:04 -04:00
|
|
|
// 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.
|
2026-05-18 13:21:37 -04:00
|
|
|
if (process.env.AUTH_ENABLED !== 'true') {
|
2026-05-23 14:52:04 -04:00
|
|
|
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,
|
|
|
|
|
});
|
2026-05-18 13:21:37 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:40:11 -04:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 23:40:11 -04:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|