diff --git a/.env.example b/.env.example index 0e6a3cc..98de638 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 3644750..1a7f51a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (2018–2035); 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`. diff --git a/install.sh b/install.sh index 6d0ca93..2af88c5 100755 --- a/install.sh +++ b/install.sh @@ -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 <$$

$$ + +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'); + }); +} diff --git a/package-lock.json b/package-lock.json index e85dee9..4917a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e4583d9..b94d968 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pf.sh b/pf.sh index 3056c2a..0b68683 100755 --- a/pf.sh +++ b/pf.sh @@ -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 diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..b286bca --- /dev/null +++ b/routes/auth.js @@ -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; +}; diff --git a/routes/operations.js b/routes/operations.js index d397dce..fc88b58 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -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' }); diff --git a/routes/sources.js b/routes/sources.js index f09d77e..093daec 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -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' }); } diff --git a/routes/versions.js b/routes/versions.js index f874b07..5d92b65 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -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 diff --git a/server.js b/server.js index f6776d5..dede991 100644 --- a/server.js +++ b/server.js @@ -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. @@ -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)); diff --git a/setup_sql/02_auth.sql b/setup_sql/02_auth.sql new file mode 100644 index 0000000..fb74460 --- /dev/null +++ b/setup_sql/02_auth.sql @@ -0,0 +1,26 @@ +-- Pivot Forecast — authentication +-- Run after 01_schema.sql: psql -d -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); diff --git a/ui/src/auth.jsx b/ui/src/auth.jsx new file mode 100644 index 0000000..f04abfb --- /dev/null +++ b/ui/src/auth.jsx @@ -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 ( + + {children} + + ) +} + +const useAuth = () => useContext(AuthContext) +export default useAuth diff --git a/ui/src/components/StatusBar.jsx b/ui/src/components/StatusBar.jsx index 995d9d6..a0243df 100644 --- a/ui/src/components/StatusBar.jsx +++ b/ui/src/components/StatusBar.jsx @@ -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 )} -

+
+ {user && ( + <> + + {user.display_name || user.username} + + + + )} + +
+ ) +}