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

View File

@@ -0,0 +1,84 @@
// Self-service account security for the logged-in user (any role). Mounted under
// the admin router (so isLoggedIn has already run and req.user is the fresh DB
// row), but NOT behind the admin-only gate — editors manage their own 2FA too.
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const totp = require('../../../utils/totp')
const log = require('../../../utils/logger')('account')
// Current user's security status (does not expose the secret).
async function getAccount(req, res) {
return res.json({
id: req.user.id,
username: req.user.username,
role: req.user.role,
totp_enabled: Boolean(req.user.totp_enabled),
})
}
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
// otpauth URL + a QR data URL for the user to scan. Overwrites any pending,
// not-yet-confirmed secret. Refuses if TOTP is already enabled.
async function totpSetup(req, res) {
try {
if (req.user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled. Disable it first to re-enroll.' })
}
const { base32, otpauthUrl } = totp.generateSecret(req.user.username)
await users.setTotpSecret(req.user.id, base32)
const qr = await totp.qrDataUrl(otpauthUrl)
log.info('totp setup started', { id: req.user.id, username: req.user.username })
return res.json({ otpauthUrl, qr })
} catch (err) {
log.error('totpSetup', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Step 2: confirm one code against the pending secret, then flip totp_enabled on.
async function totpEnable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_secret) {
return res.status(400).json({ message: 'Start setup before enabling two-factor.' })
}
if (user.totp_enabled) {
return res.status(409).json({ message: 'Two-factor is already enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.enableTotp(user.id)
await activity.log({ req, action: 'account.totp.enable' })
log.info('totp enabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: true })
} catch (err) {
log.error('totpEnable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Turn TOTP off. Require a current code to prove the requester still controls the
// authenticator (so a walk-up on an open session can't quietly remove 2FA).
async function totpDisable(req, res) {
try {
const user = await users.getRawById(req.user.id)
if (!user || !user.totp_enabled) {
return res.status(400).json({ message: 'Two-factor is not enabled.' })
}
if (!totp.verifyCode(user.totp_secret, req.body.code)) {
return res.status(400).json({ message: 'That code is not valid. Try again.' })
}
await users.disableTotp(user.id)
await activity.log({ req, action: 'account.totp.disable' })
log.info('totp disabled', { id: user.id, username: user.username })
return res.json({ totp_enabled: false })
} catch (err) {
log.error('totpDisable', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getAccount, totpSetup, totpEnable, totpDisable }

View File

@@ -6,6 +6,7 @@ const multer = require('multer')
const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const account = require('./account.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -19,6 +20,23 @@ adminRouter.use(noindex, isLoggedIn)
// management, site mode, and settings are restricted to the admin role.
const adminOnly = requireRole('admin')
// ── Account security (self-service, any logged-in role) ───────────────
// Not behind adminOnly: an editor manages their own 2FA too.
adminRouter.get('/account', account.getAccount)
adminRouter.post('/account/totp/setup', account.totpSetup)
adminRouter.post(
'/account/totp/enable',
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpEnable,
)
adminRouter.post(
'/account/totp/disable',
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')

View File

@@ -1,35 +1,105 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const { signToken, setAuthCookie, clearAuthCookie } = require('../../../utils/auth')
const {
signToken,
setAuthCookie,
clearAuthCookie,
signTotpChallenge,
verifyTotpChallenge,
} = require('../../../utils/auth')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const log = require('../../../utils/logger')('auth')
// Honeypot input name — must match the hidden field rendered on the login form.
// Chosen to look like a real field so naive bots fill it; real users never see it.
const HONEYPOT_FIELD = 'company'
// One generic failure response for every "you don't get in" case (wrong user,
// wrong password, tripped honeypot). Never reveals which was wrong.
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
// True when this user must complete a second factor before getting a session.
function needsTotp(user) {
return Boolean(user && user.totp_enabled)
}
// Issue the real session: sign the JWT, set the cookie, clear the IP's failure
// backoff, and record the login.
async function issueSession(req, res, user) {
loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id)
const token = signToken(user)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
return res.json({ user: { id: user.id, username: user.username, role: user.role } })
}
async function login(req, res) {
const { username, password } = req.body
// Honeypot: a populated hidden field means a bot. Fail generically, but score
// it hard — this is an unambiguous signal, unlike a mistyped password.
if (req.body[HONEYPOT_FIELD]) {
botScore.recordHoneypot(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('honeypot login hit', { ip: req.ip, username })
return res.status(401).json(GENERIC_FAIL)
}
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('login failed', { username, ip: req.ip })
return res.status(401).json({ message: 'Incorrect username or password.' })
return res.status(401).json(GENERIC_FAIL)
}
await users.recordLogin(user.id)
const token = signToken(user)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
// hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in.
if (needsTotp(user)) {
const challenge = signTotpChallenge(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge })
}
return res.json({
user: { id: user.id, username: user.username, role: user.role },
})
return issueSession(req, res, user)
} catch (err) {
log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function logout(req, res) {
// Second step for TOTP users: verify the challenge token + code, then issue the
// session. A wrong code counts as a failed attempt (backoff + bot score).
async function loginTotp(req, res) {
const { challenge, code } = req.body
const decoded = verifyTotpChallenge(challenge)
if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
try {
const user = await users.getRawById(decoded.id)
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, code)) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
return issueSession(req, res, user)
} catch (err) {
log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
function logout(req, res) {
clearAuthCookie(req, res)
return res.json({ message: 'Logged out.' })
}
@@ -44,4 +114,4 @@ async function me(req, res) {
}
}
module.exports = { login, logout, me }
module.exports = { login, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }

View File

@@ -1,21 +1,42 @@
const express = require('express')
const { body } = require('express-validator')
const { login, logout, me } = require('./auth.controller')
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const authRouter = express.Router()
// Login protection order (cheapest rejection first):
// backoffGuard → per-IP exponential lockout on repeated failures
// slowLogin → progressive per-request delay within the window
// loginLimiter → hard 10-per-15-min cap
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
authRouter.post(
'/login',
loginLimiter,
...loginGuards,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
// Honeypot must be absent/empty for humans; bots that fill it are caught in
// the controller. Accept-but-ignore here so a filled value still reaches it.
body(HONEYPOT_FIELD).optional(),
validate,
login,
)
// Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post(
'/login/totp',
...loginGuards,
body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
loginTotp,
)
authRouter.post('/logout', logout)
authRouter.get('/me', isLoggedIn, me)