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:
43
server/src/utils/totp.js
Normal file
43
server/src/utils/totp.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// ── TOTP (RFC 6238) helpers ────────────────────────────────────────────────
|
||||
//
|
||||
// Thin wrapper around speakeasy so the controllers stay small and the verify
|
||||
// logic is unit-testable in isolation. TOTP is opt-in per user: we generate a
|
||||
// base32 secret, show the user a QR (otpauth URL) to add to their authenticator,
|
||||
// confirm one code before enabling, and verify a code at login for users who
|
||||
// have it enabled.
|
||||
|
||||
const speakeasy = require('speakeasy')
|
||||
const QRCode = require('qrcode')
|
||||
|
||||
const ISSUER = process.env.TOTP_ISSUER || 'UOMysticmoon'
|
||||
|
||||
// Generate a new secret. Returns the base32 secret to persist plus the otpauth
|
||||
// URL to encode in a QR code.
|
||||
function generateSecret(username) {
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 20,
|
||||
name: `${ISSUER} (${username})`,
|
||||
issuer: ISSUER,
|
||||
})
|
||||
return { base32: secret.base32, otpauthUrl: secret.otpauth_url }
|
||||
}
|
||||
|
||||
// Render an otpauth URL to a PNG data URL for <img src>.
|
||||
async function qrDataUrl(otpauthUrl) {
|
||||
return QRCode.toDataURL(otpauthUrl)
|
||||
}
|
||||
|
||||
// Verify a user-supplied 6-digit code against a stored base32 secret. A window
|
||||
// of 1 tolerates minor clock skew (±30s). Returns false for missing inputs
|
||||
// rather than throwing.
|
||||
function verifyCode(base32Secret, token) {
|
||||
if (!base32Secret || !token) return false
|
||||
return speakeasy.totp.verify({
|
||||
secret: base32Secret,
|
||||
encoding: 'base32',
|
||||
token: String(token).trim(),
|
||||
window: 1,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { generateSecret, qrDataUrl, verifyCode, ISSUER }
|
||||
Reference in New Issue
Block a user