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:
@@ -1,6 +1,6 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const PUBLIC_COLS = 'id, username, role, created_at, last_login_at'
|
||||
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at'
|
||||
|
||||
async function insertUser({ username, passwordHash, role = 'admin' }) {
|
||||
const res = await query(
|
||||
@@ -54,6 +54,20 @@ 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,
|
||||
@@ -64,4 +78,7 @@ module.exports = {
|
||||
countUsers,
|
||||
countAdmins,
|
||||
touchLastLogin,
|
||||
setTotpSecret,
|
||||
enableTotp,
|
||||
disableTotp,
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ const usersDb = require('./users.db')
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
// Strip the password hash before sending a user anywhere.
|
||||
// Strip secrets (password hash, TOTP secret) before sending a user anywhere.
|
||||
function sanitize(user) {
|
||||
if (!user) return null
|
||||
const { password_hash, ...safe } = user
|
||||
const { password_hash, totp_secret, ...safe } = user
|
||||
return safe
|
||||
}
|
||||
|
||||
@@ -25,6 +25,24 @@ async function getById(id) {
|
||||
return sanitize(await usersDb.findById(id))
|
||||
}
|
||||
|
||||
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
|
||||
// to a client; sanitize() strips the secret from anything user-facing.
|
||||
async function getRawById(id) {
|
||||
return usersDb.findById(id)
|
||||
}
|
||||
|
||||
async function setTotpSecret(id, secret) {
|
||||
return usersDb.setTotpSecret(id, secret)
|
||||
}
|
||||
|
||||
async function enableTotp(id) {
|
||||
return usersDb.enableTotp(id)
|
||||
}
|
||||
|
||||
async function disableTotp(id) {
|
||||
return usersDb.disableTotp(id)
|
||||
}
|
||||
|
||||
async function validatePassword(user, password) {
|
||||
if (!user || !user.password_hash) return false
|
||||
return bcrypt.compare(password, user.password_hash)
|
||||
@@ -63,6 +81,7 @@ module.exports = {
|
||||
createUser,
|
||||
getRawByUsername,
|
||||
getById,
|
||||
getRawById,
|
||||
validatePassword,
|
||||
list,
|
||||
update,
|
||||
@@ -70,4 +89,7 @@ module.exports = {
|
||||
count,
|
||||
countAdmins,
|
||||
recordLogin,
|
||||
setTotpSecret,
|
||||
enableTotp,
|
||||
disableTotp,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user