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>
This commit is contained in:
Paul Trowbridge 2026-09-14 22:20:25 -04:00
parent 146961cc17
commit c2e6fc8e77
19 changed files with 732 additions and 22 deletions

View File

@ -4,3 +4,18 @@ DB_NAME=your_database
DB_USER=your_user
DB_PASSWORD=your_password
PORT=3010
# Signs the session cookie. Generate with:
# node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
# or let ./pf.sh config do it. Changing it signs everyone out.
SESSION_SECRET=
# Send the session cookie over HTTPS only. Keep true behind a TLS proxy;
# set false only to reach the app over plain HTTP on a trusted network.
COOKIE_SECURE=true
# Reverse proxy hops express should trust for req.ip / protocol. Default 1.
#TRUST_PROXY=1
# Only needed if the UI is served from a different origin than the API.
#CORS_ORIGIN=https://forecast.example.com

View File

@ -22,8 +22,9 @@ Data transport architecture options: `pf_perspective_options.md`
## Project layout
```
server.js Express entry point; pg pool; type parsers for bigint/numeric
server.js Express entry point; pg pool; session; type parsers for bigint/numeric
routes/
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
sources.js Source registration, col_meta, SQL generation
versions.js Version CRUD, baseline/reference load, data stream
@ -31,11 +32,15 @@ routes/
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
lib/
sql_generator.js buildFilterClause, token substitution helpers
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
utils.js
setup_sql/
01_schema.sql pf schema DDL — run once to install
02_auth.sql pf.app_user + pf.session
ui/src/
auth.jsx AuthProvider/useAuth; wraps fetch so any 401 returns to login
views/
Login.jsx Sign-in form
Setup.jsx DB browser, source registration, col_meta editor
Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot, selection handling, operation dispatch
@ -57,6 +62,8 @@ ui/src/
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`)
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
- **`pf.session`** — express-session store (connect-pg-simple layout)
- **`pf.dim_period`** — calendar lookup table (20182035); one row per month keyed on `sdat` (month start date); provides cal/fiscal year, quarter, and month columns; populated by `setup_sql/gen_dim_period.sql` with a configurable fiscal year start month
### Key token substitution tokens
@ -144,6 +151,33 @@ All three operations follow the same structure: insert a `pf.log` row in a CTE,
---
## Authentication
Everything under `/api` except the auth routes sits behind a session; the React
app is only mounted once there is one (`Gate` in `main.jsx`), because its load
effects call the API immediately.
- **Accounts:** `pf.app_user` — scrypt hashes from `lib/auth.js`, never plaintext.
Managed with `./pf.sh add-user | passwd | list-users | disable-user | enable-user`;
the password is read on stdin and hashed before it reaches psql.
- **Sessions:** `express-session` + `connect-pg-simple` in `pf.session`, so a
restart doesn't sign everyone out and a session can be revoked by deleting its
row (`disable-user` does exactly that). Cookie `pf.sid`: httpOnly, SameSite=Lax,
Secure unless `COOKIE_SECURE=false`, 12h rolling.
- **Config:** `SESSION_SECRET` is required — the server exits at boot without one.
`TRUST_PROXY` (default 1) makes `req.ip` and secure-cookie detection correct
behind the TLS proxy. `CORS_ORIGIN` is the only way CORS is enabled at all; a
wildcard origin plus a session cookie would be cross-site request forgery by
construction.
- **Login hardening:** `routes/auth.js` throttles to 10 failures per IP per 15
minutes (in-memory), returns one message for unknown/wrong/disabled alike, and
regenerates the session id on success.
**Identity is server-side.** `pf_user`, `created_by` and `closed_by` come from
`sessionUser(req)`, never from the request body — the UI used to send a hardcoded
`pf_user: 'admin'`, which any client could have set to anything. The audit log
now names the account that made the change.
## Light / dark mode
Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`.

View File

@ -26,6 +26,12 @@ echo ""
read -p "App port [3030]: " PORT
PORT=${PORT:-3030}
# Session cookies are signed with this; the server refuses to start without it.
SESSION_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
read -p "Send session cookie over HTTPS only? [Y/n]: " SECURE_ANS
case "${SECURE_ANS:-y}" in [Nn]*) COOKIE_SECURE=false ;; *) COOKIE_SECURE=true ;; esac
# ── Write .env ────────────────────────────────────────────────
cat > .env <<EOF
DB_HOST=${DB_HOST}
@ -34,7 +40,10 @@ DB_NAME=${DB_NAME}
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
PORT=${PORT}
SESSION_SECRET=${SESSION_SECRET}
COOKIE_SECURE=${COOKIE_SECURE}
EOF
chmod 600 .env
echo "✓ .env written"
# ── npm install ───────────────────────────────────────────────
@ -51,15 +60,23 @@ PGPASSWORD=${DB_PASSWORD} psql \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
-f setup_sql/01_schema.sql
-v ON_ERROR_STOP=1 \
-f setup_sql/01_schema.sql \
-f setup_sql/02_auth.sql
echo "✓ schema installed"
# ── first account ─────────────────────────────────────────────
echo ""
echo "The app is behind a login. Create the first account now:"
./pf.sh add-user
# ── done ─────────────────────────────────────────────────────
echo ""
echo "========================================"
echo " Install complete"
echo " Start with: npm run dev"
echo " Start with: npm run dev
More accounts: ./pf.sh add-user"
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "========================================"
echo ""

70
lib/auth.js Normal file
View File

@ -0,0 +1,70 @@
// 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');
});
}

69
package-lock.json generated
View File

@ -7,11 +7,14 @@
"": {
"name": "pf_app",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"apache-arrow": "^21.1.0",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.18.2",
"express-session": "^1.19.0",
"pg": "^8.11.3"
},
"devDependencies": {
@ -369,6 +372,18 @@
"node": ">=12.20.0"
}
},
"node_modules/connect-pg-simple": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
"integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==",
"license": "MIT",
"dependencies": {
"pg": "^8.12.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=22.0.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -582,6 +597,29 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -1085,6 +1123,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1105,6 +1152,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@ -1276,6 +1324,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -1592,6 +1649,18 @@
"node": ">=12.17"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",

View File

@ -11,9 +11,11 @@
},
"dependencies": {
"apache-arrow": "^21.1.0",
"connect-pg-simple": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.18.2",
"express-session": "^1.19.0",
"pg": "^8.11.3"
},
"devDependencies": {

160
pf.sh
View File

@ -4,6 +4,7 @@ set -euo pipefail
# ---------------------------------------------------------------------------
# pf.sh — Pivot Forecast management script
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
# ./pf.sh [add-user|passwd|list-users|disable-user|enable-user]
# ./pf.sh (interactive menu)
# ---------------------------------------------------------------------------
@ -68,7 +69,7 @@ require_service() {
}
# The keys cmd_config manages; anything else in .env is left alone.
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT)
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT SESSION_SECRET COOKIE_SECURE)
env_get() {
[[ -f "$ENV_FILE" ]] || return 0
@ -192,13 +193,21 @@ cmd_db_setup() {
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
echo
bold "DB Setup — will run: setup_sql/01_schema.sql"
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)."
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
warn "This creates the pf schema, tables, and the account/session tables."
warn "Safe to re-run (CREATE IF NOT EXISTS)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
success "Schema applied."
local n
n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0)
if [[ "${n:-0}" == "0" ]]; then
warn "No active accounts yet — create one with: ./pf.sh add-user"
fi
}
cmd_config() {
@ -239,6 +248,22 @@ cmd_config() {
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
local app_port="${input:-${cur_app_port:-3010}}"
# Session cookies are signed with this; regenerating it signs everyone out,
# so an existing secret is kept rather than re-rolled on every config run.
local secret
secret=$(env_get SESSION_SECRET)
if [[ -z "$secret" ]]; then
secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
success "SESSION_SECRET generated."
else
success "SESSION_SECRET kept (delete the line in .env to re-roll)."
fi
local cur_secure
cur_secure=$(env_get COOKIE_SECURE)
read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input
local cookie_secure="${input:-${cur_secure:-true}}"
# Rewrite the managed keys, carrying over any other lines already in .env.
local tmp
tmp=$(mktemp)
@ -249,6 +274,8 @@ DB_NAME=${name}
DB_USER=${user}
DB_PASSWORD=${pass}
PORT=${app_port}
SESSION_SECRET=${secret}
COOKIE_SECURE=${cookie_secure}
EOF
if [[ -f "$ENV_FILE" ]]; then
local managed
@ -266,6 +293,116 @@ EOF
fi
}
# -- Accounts ----------------------------------------------------------------
# Reads a password twice without echo and hashes it with lib/auth.js, so the
# plaintext never reaches argv, psql, or the shell history.
read_new_password() {
local p1 p2
# Prompts and their newlines go to stderr: stdout is the hash, and a stray
# newline there ends up prefixed to it by the caller's $( ).
read -rsp " Password: " p1; echo >&2
[[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; }
read -rsp " Confirm : " p2; echo >&2
[[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; }
printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash
}
# psql single-quoted literal: double any embedded quote.
sql_lit() { printf "%s" "${1//\'/\'\'}"; }
cmd_add_user() {
require_env; load_env
check_node >/dev/null
echo; bold "Add account"
local username display hash
read -rp " Username: " username
[[ -z "$username" ]] && die "Username is required."
read -rp " Display name [${username}]: " display
display="${display:-$username}"
hash=$(read_new_password) || return 1
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH ins AS (
INSERT INTO pf.app_user (username, pass_hash, display_name)
VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")')
ON CONFLICT (username) DO NOTHING
RETURNING id
) SELECT id FROM ins" | grep -q . \
&& success "Account '${username}' created." \
|| die "Account '${username}' already exists — change its password with: ./pf.sh passwd"
}
cmd_passwd() {
require_env; load_env
check_node >/dev/null
echo; bold "Change password"
local username hash
read -rp " Username: " username
[[ -z "$username" ]] && die "Username is required."
hash=$(read_new_password) || return 1
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")'
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
&& success "Password updated for '${username}'." \
|| die "No such account: ${username}"
}
cmd_list_users() {
require_env; load_env
echo; bold "Accounts"
run_psql -c "
SELECT username, display_name, is_active,
to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login
FROM pf.app_user ORDER BY username"
}
# Deactivating leaves the row (and its history) in place, and drops any live
# session so the account loses access immediately rather than at cookie expiry.
cmd_disable_user() {
require_env; load_env
local username="${1:-}"
[[ -z "$username" ]] && { read -rp " Username to disable: " username; }
[[ -z "$username" ]] && die "Username is required."
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET is_active = false
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
|| die "No such account: ${username}"
run_psql -v ON_ERROR_STOP=1 -c "
DELETE FROM pf.session
WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null
success "Account '${username}' disabled and signed out."
}
cmd_enable_user() {
require_env; load_env
local username="${1:-}"
[[ -z "$username" ]] && { read -rp " Username to enable: " username; }
[[ -z "$username" ]] && die "Username is required."
run_psql -v ON_ERROR_STOP=1 -tAc "
WITH upd AS (
UPDATE pf.app_user SET is_active = true
WHERE lower(username) = lower('$(sql_lit "$username")')
RETURNING id
) SELECT id FROM upd" | grep -q . \
&& success "Account '${username}' enabled." \
|| die "No such account: ${username}"
}
cmd_install_service() {
require_systemd
require_env
@ -349,6 +486,11 @@ interactive_menu() {
echo " 8) config set DB connection + app PORT"
echo " 9) install-service create systemd unit file"
echo " 10) uninstall-service remove systemd unit file"
echo " 11) add-user create a login account"
echo " 12) passwd change an account password"
echo " 13) list-users show accounts"
echo " 14) disable-user deactivate an account and sign it out"
echo " 15) enable-user reactivate an account"
echo " q) quit"
echo
read -rp " Choice: " choice
@ -363,6 +505,11 @@ interactive_menu() {
8|config) cmd_config ;;
9|install-service) cmd_install_service ;;
10|uninstall-service) cmd_uninstall_service ;;
11|add-user) cmd_add_user ;;
12|passwd) cmd_passwd ;;
13|list-users) cmd_list_users ;;
14|disable-user) cmd_disable_user ;;
15|enable-user) cmd_enable_user ;;
q|Q|quit|exit) echo "Bye."; exit 0 ;;
*) warn "Unknown option: $choice" ;;
esac
@ -382,6 +529,11 @@ case "${1:-}" in
config) cmd_config ;;
install-service) cmd_install_service ;;
uninstall-service) cmd_uninstall_service ;;
add-user) cmd_add_user ;;
passwd) cmd_passwd ;;
list-users) cmd_list_users ;;
disable-user) cmd_disable_user "${2:-}" ;;
enable-user) cmd_enable_user "${2:-}" ;;
"") interactive_menu ;;
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service" ;;
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user" ;;
esac

107
routes/auth.js Normal file
View File

@ -0,0 +1,107 @@
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;
};

View File

@ -1,6 +1,7 @@
const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
const { sessionUser } = require('../lib/auth');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
@ -304,7 +305,8 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try {
@ -339,7 +341,8 @@ module.exports = function(pool) {
router.put('/versions/:id/baseline/:logid', async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
@ -445,7 +448,8 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const { where_clause, date_offset, note, filters, raw_where } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
try {
@ -478,7 +482,8 @@ module.exports = function(pool) {
// target or by an increment. With several slices selected, apply_mode decides
// whether they are treated as one pool ('prorate') or independently ('each').
router.post('/versions/:id/scale', async (req, res) => {
const { pf_user, note, apply_mode } = req.body;
const { note, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
@ -565,7 +570,8 @@ module.exports = function(pool) {
// recode dimension values on one or more slices
// inserts negative rows to zero out the original, positive rows with new dimension values
router.post('/versions/:id/recode', async (req, res) => {
const { pf_user, note, set, apply_mode } = req.body;
const { note, set, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
@ -618,7 +624,8 @@ module.exports = function(pool) {
// clone one or more slices as new business under new dimension values
// does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => {
const { pf_user, note, set, scale, apply_mode } = req.body;
const { note, set, scale, apply_mode } = req.body;
const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });

View File

@ -1,5 +1,6 @@
const express = require('express');
const { generateSQL } = require('../lib/sql_generator');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) {
const router = express.Router();
@ -20,7 +21,8 @@ module.exports = function(pool) {
// register a source table
// auto-populates col_meta from information_schema with role='ignore'
router.post('/sources', async (req, res) => {
const { schema, tname, label, created_by } = req.body;
const { schema, tname, label } = req.body;
const created_by = sessionUser(req);
if (!schema || !tname) {
return res.status(400).json({ error: 'schema and tname are required' });
}

View File

@ -1,5 +1,6 @@
const express = require('express');
const { fcTable, mapType } = require('../lib/utils');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) {
const router = express.Router();
@ -22,7 +23,8 @@ module.exports = function(pool) {
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => {
const sourceId = parseInt(req.params.id);
const { name, description, created_by, exclude_iters } = req.body;
const { name, description, exclude_iters } = req.body;
const created_by = sessionUser(req);
if (!name) return res.status(400).json({ error: 'name is required' });
const client = await pool.connect();
@ -262,7 +264,7 @@ ${colDefs},
// close a version — blocks further edits
router.post('/versions/:id/close', async (req, res) => {
const { pf_user } = req.body;
const pf_user = sessionUser(req);
try {
const result = await pool.query(`
UPDATE pf.version

View File

@ -1,7 +1,10 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const { Pool, types } = require('pg');
const { requireAuth } = require('./lib/auth');
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
@ -9,7 +12,15 @@ types.setTypeParser(20, v => v === null ? null : Number(v));
types.setTypeParser(1700, v => v === null ? null : Number(v));
const app = express();
app.use(cors());
// Sessions ride on a cookie, so a wildcard CORS origin would let any site make
// credentialed calls on behalf of a logged-in user. The UI is served from this
// same origin and needs no CORS at all; set CORS_ORIGIN only for a separate
// front-end host, and it is then allowed by name, never by wildcard.
if (process.env.CORS_ORIGIN) {
app.use(cors({ origin: process.env.CORS_ORIGIN.split(',').map(o => o.trim()), credentials: true }));
}
app.use(express.json());
app.use(express.static('public/app'));
@ -26,6 +37,44 @@ pool.on('error', (err) => {
console.error('pg pool error', err);
});
// ── Authentication ────────────────────────────────────────────
// Refuse to boot without a secret rather than fall back to a default one:
// a predictable secret means forgeable session cookies.
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
console.error('SESSION_SECRET is not set. Run: ./pf.sh config');
process.exit(1);
}
// TLS terminates at the reverse proxy, so express has to trust its headers for
// req.ip (the login throttle) and for secure-cookie detection to be right.
app.set('trust proxy', process.env.TRUST_PROXY || 1);
const cookieSecure = process.env.COOKIE_SECURE !== 'false';
if (!cookieSecure) {
console.warn('COOKIE_SECURE=false — session cookie will be sent over plain HTTP.');
}
app.use(session({
name: 'pf.sid',
store: new PgSession({ pool, schemaName: 'pf', tableName: 'session', createTableIfMissing: false }),
secret: sessionSecret,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure,
maxAge: 1000 * 60 * 60 * 12,
},
}));
app.use('/api', require('./routes/auth')(pool));
// Everything below this line requires a session.
app.use('/api', requireAuth);
app.use('/api', require('./routes/tables')(pool));
app.use('/api', require('./routes/sources')(pool));
app.use('/api', require('./routes/versions')(pool));

26
setup_sql/02_auth.sql Normal file
View File

@ -0,0 +1,26 @@
-- Pivot Forecast — authentication
-- Run after 01_schema.sql: psql -d <db> -f setup_sql/02_auth.sql
-- Safe to re-run.
-- Application accounts. Passwords are scrypt hashes written by lib/auth.js;
-- the plaintext never reaches the database. Manage with ./pf.sh add-user.
CREATE TABLE IF NOT EXISTS pf.app_user (
id serial PRIMARY KEY,
username text NOT NULL UNIQUE,
pass_hash text NOT NULL,
display_name text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz
);
-- Session store for express-session (connect-pg-simple layout). Sessions live
-- here rather than in memory so a restart doesn't sign everyone out, and so a
-- session can be revoked by deleting its row.
CREATE TABLE IF NOT EXISTS pf.session (
sid varchar PRIMARY KEY NOT NULL COLLATE "default",
sess json NOT NULL,
expire timestamp(6) NOT NULL
);
CREATE INDEX IF NOT EXISTS session_expire_idx ON pf.session (expire);

62
ui/src/auth.jsx Normal file
View File

@ -0,0 +1,62 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
const AuthContext = createContext()
// A session can expire while the app is open. Rather than teach every one of
// the app's fetch calls to check for it, wrap fetch once: any 401 from /api
// drops the whole UI back to the login screen. Cookies ride along on their own
// fetch defaults to same-origin credentials, and the UI is served from the
// same origin as the API.
function installUnauthorizedHandler(onUnauthorized) {
const original = window.fetch
window.fetch = async (...args) => {
const res = await original(...args)
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''
if (res.status === 401 && url.includes('/api/') && !url.includes('/api/login')) {
onUnauthorized()
}
return res
}
return () => { window.fetch = original }
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [checking, setCheck] = useState(true)
useEffect(() => {
fetch('/api/me')
.then(r => r.ok ? r.json() : null)
.then(d => setUser(d?.user || null))
.catch(() => setUser(null))
.finally(() => setCheck(false))
}, [])
useEffect(() => installUnauthorizedHandler(() => setUser(null)), [])
const login = useCallback(async (username, password) => {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(data.error || 'Login failed')
setUser(data.user)
return data.user
}, [])
const logout = useCallback(async () => {
try { await fetch('/api/logout', { method: 'POST' }) } catch {}
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, checking, login, logout }}>
{children}
</AuthContext.Provider>
)
}
const useAuth = () => useContext(AuthContext)
export default useAuth

View File

@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import useTheme from '../theme.jsx'
import useAuth from '../auth.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
const { dark, setDark } = useTheme()
const { user, logout } = useAuth()
const showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
@ -125,7 +127,21 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
</>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
{user && (
<>
<span className="text-gray-500" title={`Signed in as ${user.username}`}>
{user.display_name || user.username}
</span>
<button
onClick={logout}
className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-0.5 rounded"
title="Sign out"
>
Sign out
</button>
</>
)}
<button
onClick={() => setDark(d => !d)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"

View File

@ -1,13 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ThemeProvider } from './theme.jsx'
import { AuthProvider } from './auth.jsx'
import useAuth from './auth.jsx'
import Login from './views/Login.jsx'
import './index.css'
import App from './App.jsx'
// App is mounted only once there is a session its load effects call /api
// straight away, and mounting it logged-out would just fire a burst of 401s.
function Gate() {
const { user, checking } = useAuth()
if (checking) return <div className="flex items-center justify-center h-screen text-sm text-gray-400">Loading</div>
if (!user) return <Login />
return <App />
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<App />
<AuthProvider>
<Gate />
</AuthProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@ -148,7 +148,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
@ -242,7 +241,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
async function closeVersion() {
const res = await fetch(`/api/versions/${versionId}/close`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pf_user: 'admin' })
body: JSON.stringify({})
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }

View File

@ -945,7 +945,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
}
// apply_mode only changes the maths when more than one slice is selected
let body = {
pf_user: 'admin',
tag: opTag.trim() || undefined,
slices: effectiveSlices,
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),

68
ui/src/views/Login.jsx Normal file
View File

@ -0,0 +1,68 @@
import { useState } from 'react'
import useAuth from '../auth.jsx'
export default function Login() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function submit(e) {
e.preventDefault()
setError(''); setBusy(true)
try {
await login(username.trim(), password)
} catch (err) {
setError(err.message)
setPassword('')
} finally {
setBusy(false)
}
}
return (
<div className="flex items-center justify-center h-screen w-full text-sm">
<form onSubmit={submit} className="bg-white border border-gray-200 rounded p-6 w-80 flex flex-col gap-4">
<div>
<div className="text-base font-medium">Pivot Forecast</div>
<div className="text-xs text-gray-500 mt-0.5">Sign in to continue</div>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">Username</span>
<input
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
autoComplete="username"
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">Password</span>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
/>
</label>
{error && (
<div className="px-3 py-2 text-xs rounded font-medium bg-red-50 text-red-700">{error}</div>
)}
<button
type="submit"
disabled={busy || !username.trim() || !password}
className="bg-blue-600 text-white text-xs px-3 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}