The server had no authentication: every /api route was open, CORS allowed any origin, and the identity written to the audit log came from the request body — the UI sent a hardcoded pf_user: 'admin', which any client could have set to anything it liked. Accounts live in pf.app_user with scrypt hashes from node's own crypto, so there is no native build step and the parameters travel with each hash. Sessions are express-session over connect-pg-simple in pf.session: a restart no longer signs everyone out, and a session can be revoked by deleting its row, which is how disable-user cuts off access immediately rather than at cookie expiry. Everything under /api except login/logout/me now requires a session, and the React app is mounted only once there is one — its load effects call the API on mount, so a logged-out mount would just fire a burst of 401s. A session that expires while the app is open lands back on the login screen: auth.jsx wraps fetch once rather than teaching every call site to check. Identity is now read from the session for pf_user, created_by and closed_by, and the body values are ignored. Hardened for an internet-facing deployment: trust proxy so req.ip and secure-cookie detection are right behind TLS termination, httpOnly + SameSite=Lax + Secure cookies, ten login failures per IP per fifteen minutes, one error message for unknown, wrong and disabled alike, and a fresh session id on success. CORS is off entirely unless CORS_ORIGIN names an origin — a wildcard alongside a session cookie would be CSRF by construction. The server refuses to boot without SESSION_SECRET rather than falling back to a guessable default. pf.sh grows add-user, passwd, list-users, disable-user and enable-user; passwords are read on stdin and hashed before they reach psql, so no plaintext in argv or shell history. install.sh generates the secret, applies 02_auth.sql, and creates the first account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
4.0 KiB
JavaScript
108 lines
4.0 KiB
JavaScript
const express = require('express');
|
|
const { verifyPassword } = require('../lib/auth');
|
|
|
|
// Per-IP login throttle. In-memory on purpose: it only has to blunt online
|
|
// guessing, and a counter that resets on restart is the acceptable cost of not
|
|
// writing a failed-attempt row for every knock on an internet-facing port.
|
|
const WINDOW_MS = 15 * 60 * 1000;
|
|
const MAX_ATTEMPTS = 10;
|
|
const attempts = new Map(); // ip -> { count, resetAt }
|
|
|
|
function tooManyAttempts(ip) {
|
|
const rec = attempts.get(ip);
|
|
if (!rec || Date.now() > rec.resetAt) return false;
|
|
return rec.count >= MAX_ATTEMPTS;
|
|
}
|
|
|
|
function recordFailure(ip) {
|
|
const rec = attempts.get(ip);
|
|
if (!rec || Date.now() > rec.resetAt) {
|
|
attempts.set(ip, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
|
} else {
|
|
rec.count += 1;
|
|
}
|
|
}
|
|
|
|
// Keep the map from growing without bound on a long-lived process.
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, rec] of attempts) if (now > rec.resetAt) attempts.delete(ip);
|
|
}, WINDOW_MS).unref();
|
|
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const ip = req.ip;
|
|
if (tooManyAttempts(ip)) {
|
|
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
|
|
}
|
|
|
|
const username = String(req.body?.username || '').trim();
|
|
const password = String(req.body?.password || '');
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT id, username, display_name, pass_hash, is_active
|
|
FROM pf.app_user WHERE lower(username) = lower($1)`,
|
|
[username]
|
|
);
|
|
const user = result.rows[0];
|
|
|
|
// Same message and roughly the same work either way: no unknown
|
|
// user / wrong password / disabled distinction to enumerate.
|
|
const ok = user && user.is_active && verifyPassword(password, user.pass_hash);
|
|
if (!ok) {
|
|
recordFailure(ip);
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
// New session id on login — an existing cookie can't be fixated.
|
|
req.session.regenerate(err => {
|
|
if (err) {
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Could not start session' });
|
|
}
|
|
req.session.user = {
|
|
id: user.id,
|
|
username: user.username,
|
|
display_name: user.display_name,
|
|
};
|
|
pool.query(`UPDATE pf.app_user SET last_login_at = now() WHERE id = $1`, [user.id])
|
|
.catch(e => console.error('last_login_at update failed', e));
|
|
req.session.save(err2 => {
|
|
if (err2) {
|
|
console.error(err2);
|
|
return res.status(500).json({ error: 'Could not start session' });
|
|
}
|
|
attempts.delete(ip);
|
|
res.json({ user: req.session.user });
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/logout', (req, res) => {
|
|
const name = req.session?.cookie && req.app.get('session cookie name');
|
|
req.session.destroy(err => {
|
|
if (err) console.error(err);
|
|
res.clearCookie(name || 'pf.sid');
|
|
res.json({ ok: true });
|
|
});
|
|
});
|
|
|
|
// The UI calls this on load to decide between the login screen and the app.
|
|
router.get('/me', (req, res) => {
|
|
if (!req.session?.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
res.json({ user: req.session.user });
|
|
});
|
|
|
|
return router;
|
|
};
|