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>
This commit is contained in:
2026-07-02 23:22:35 -05:00
parent ad9c556c9a
commit d38c98ad9e
30 changed files with 2038 additions and 54 deletions

65
server/test/totp.test.js Normal file
View File

@@ -0,0 +1,65 @@
// Set before requiring auth.js (reads JWT_SECRET at load) and db.js (builds the
// pool at load). Pointing the DB at a closed port stops the pool from eagerly
// opening idle connections that would keep this test process alive — none of
// these tests touch the database.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const speakeasy = require('speakeasy')
const totp = require('../src/utils/totp')
const { needsTotp } = require('../src/router/v1/auth/auth.controller')
const { signTotpChallenge, verifyTotpChallenge, getUserFromRequest } = require('../src/utils/auth')
const db = require('../src/utils/db')
after(() => db.close())
test('needsTotp: disabled user does not require a second factor', () => {
assert.equal(needsTotp({ id: 1, totp_enabled: 0 }), false)
assert.equal(needsTotp({ id: 1 }), false)
})
test('needsTotp: enabled user requires a second factor', () => {
assert.equal(needsTotp({ id: 1, totp_enabled: 1 }), true)
})
test('verifyCode accepts a current code and rejects a wrong/absent one', () => {
const { base32 } = totp.generateSecret('alice')
const good = speakeasy.totp({ secret: base32, encoding: 'base32' })
assert.equal(totp.verifyCode(base32, good), true)
assert.equal(totp.verifyCode(base32, '000000'), false)
assert.equal(totp.verifyCode(base32, ''), false)
assert.equal(totp.verifyCode(null, good), false)
})
test('generateSecret yields a base32 secret and an otpauth URL', () => {
const s = totp.generateSecret('bob')
assert.ok(s.base32 && s.base32.length >= 16)
assert.match(s.otpauthUrl, /^otpauth:\/\/totp\//)
})
test('qrDataUrl renders the otpauth URL to a PNG data URL', async () => {
const s = totp.generateSecret('carol')
const dataUrl = await totp.qrDataUrl(s.otpauthUrl)
assert.match(dataUrl, /^data:image\/png;base64,/)
})
// The password-verified challenge must never work as a real session token.
test('TOTP challenge token is not accepted as a session', () => {
const token = signTotpChallenge({ id: 42 })
// Valid as a challenge...
const challenge = verifyTotpChallenge(token)
assert.equal(challenge.id, 42)
// ...but rejected as a session (stage-tagged) when presented as a cookie/bearer.
const req = { cookies: {}, headers: { authorization: `Bearer ${token}` } }
assert.equal(getUserFromRequest(req), null)
})
test('a normal session token is not accepted as a TOTP challenge', () => {
const { signToken } = require('../src/utils/auth')
const session = signToken({ id: 7, username: 'x', role: 'admin' })
assert.equal(verifyTotpChallenge(session), null)
})