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:
84
server/src/router/v1/admin/account.controller.js
Normal file
84
server/src/router/v1/admin/account.controller.js
Normal 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 }
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user