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>
71 lines
2.7 KiB
JavaScript
71 lines
2.7 KiB
JavaScript
// Password hashing and the route guard.
|
|
//
|
|
// Hashes are scrypt, from node's own crypto — no native build step, and the
|
|
// stored form carries its own parameters so they can be raised later without
|
|
// invalidating existing rows:
|
|
//
|
|
// scrypt$<N>$<r>$<p>$<salt base64>$<derived key base64>
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
|
|
|
|
function hashPassword(password, params = SCRYPT) {
|
|
const { N, r, p, keylen } = params;
|
|
const salt = crypto.randomBytes(16);
|
|
const dk = crypto.scryptSync(password, salt, keylen, { N, r, p, maxmem: 256 * 1024 * 1024 });
|
|
return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${dk.toString('base64')}`;
|
|
}
|
|
|
|
// Constant-time compare. Returns false rather than throwing on a malformed or
|
|
// legacy hash, so one bad row can't 500 the login route.
|
|
function verifyPassword(password, stored) {
|
|
if (typeof stored !== 'string') return false;
|
|
const parts = stored.split('$');
|
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
|
|
|
const [, N, r, p, saltB64, dkB64] = parts;
|
|
try {
|
|
const salt = Buffer.from(saltB64, 'base64');
|
|
const expected = Buffer.from(dkB64, 'base64');
|
|
const actual = crypto.scryptSync(password, salt, expected.length, {
|
|
N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024,
|
|
});
|
|
return crypto.timingSafeEqual(actual, expected);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Every /api route except the auth ones sits behind this.
|
|
function requireAuth(req, res, next) {
|
|
if (req.session?.user?.username) return next();
|
|
res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
// The identity used for pf.log.pf_user and the created_by/closed_by columns.
|
|
// Read from the session only — never from the request body, which the browser
|
|
// controls and which used to carry a hardcoded 'admin'.
|
|
function sessionUser(req) {
|
|
return req.session?.user?.username || null;
|
|
}
|
|
|
|
module.exports = { hashPassword, verifyPassword, requireAuth, sessionUser, SCRYPT };
|
|
|
|
// CLI: `node lib/auth.js hash` reads a password on stdin and prints its hash,
|
|
// so ./pf.sh can create users without the plaintext touching argv or psql.
|
|
if (require.main === module) {
|
|
if (process.argv[2] !== 'hash') {
|
|
console.error('usage: node lib/auth.js hash (password on stdin)');
|
|
process.exit(2);
|
|
}
|
|
let input = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => { input += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const password = input.replace(/\r?\n$/, '');
|
|
if (!password) { console.error('empty password'); process.exit(2); }
|
|
process.stdout.write(hashPassword(password) + '\n');
|
|
});
|
|
}
|