Files
website/server/src/model/users/users.db.js
Claude d38c98ad9e Harden admin login: RBAC-safe controls, 2FA, bot-scoring, rate limits (#9)
Adds a layered set of protections around the admin login and the app edge.

Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
  X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
  rejected (coerced to 1) to prevent XFF spoofing that would dodge every
  IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
  proxy IP without a redeploy. Documents the Omada static-reservation
  assumption.

Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
  separate per-IP exponential backoff that persists across the rate window.
  All failures return one generic message (no user/pass disclosure).

Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
  generically and is scored as an unambiguous bot.

Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
  Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
  signed challenge (stage:'totp', not a session) is required before the
  real session is issued.

Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
  unconditionally — independent of score/ban state, so a scanner rotating
  through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
  the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
  score with quiet-period decay temp-bans an IP from ALL routes once past a
  (deliberately low) threshold, to protect /admin from credential stuffing.
  Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
  store can't grow unbounded; the interval is unref'd and cleared on
  graceful shutdown.

Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:22:35 -05:00

85 lines
2.2 KiB
JavaScript

const { query } = require('../../utils/db')
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at'
async function insertUser({ username, passwordHash, role = 'admin' }) {
const res = await query(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[username, passwordHash, role],
)
return res.insertId
}
async function findByUsername(username) {
const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username])
return rows[0] || null
}
async function findById(id) {
const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
async function listUsers() {
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
}
async function updateUser(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
}
async function countAdmins() {
const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
return Number(rows[0].c)
}
async function touchLastLogin(id) {
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id])
}
async function enableTotp(id) {
return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id])
}
async function disableTotp(id) {
return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id])
}
module.exports = {
insertUser,
findByUsername,
findById,
listUsers,
updateUser,
deleteUser,
countUsers,
countAdmins,
touchLastLogin,
setTotpSecret,
enableTotp,
disableTotp,
}