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,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 }
|
||||
|
||||
Reference in New Issue
Block a user