pf_app/routes/auth.js
Paul Trowbridge c2e6fc8e77 Put the app behind a login
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>
2026-09-14 22:20:25 -04:00

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;
};