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

@@ -9,15 +9,28 @@ require('dotenv').config()
const apiRouter = require('./router/api.router')
const createLogger = require('./utils/logger')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
const httpLog = createLogger('http')
const errLog = createLogger('error')
const app = express()
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
app.set('trust proxy', 1)
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
// never a blanket `true` (which would let clients spoof their IP). Must run
// before any middleware that reads req.ip.
applyTrustProxy(app)
// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
// the bot guard so it logs scanner/junk source IPs too.
app.use(trustProxyDebug)
// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
// obvious scanner probes are 404'd immediately without reaching real handlers.
app.use(botScore.guard)
// Security headers. CSP is left off here and will be tuned for the React SPA in
// the frontend phase; the rest of helmet's protections stay enabled.

View File

@@ -0,0 +1,228 @@
// ── Bot / scanner scoring and IP banning ──────────────────────────────────
//
// This app has no WordPress, Drupal, phpMyAdmin, .env exposure, etc. Any hit on
// those well-known scanner targets is therefore pure bot signal. Two separate
// jobs happen here, and it matters that they stay separate:
//
// 1. Junk-path 404: every hit to a known scanner path is 404'd immediately and
// UNCONDITIONALLY — independent of any IP score or ban state. Much of the
// scanning traffic here comes through Cloudflare edge ranges (104.23.x,
// 162.158.x, 172.68-71.x), i.e. a large rotating pool of source IPs, so we
// must never give a fresh IP a "free pass" on a junk path while its score
// warms up. The 404 is the primary, always-on defense.
//
// 2. Per-IP temp-ban: scoring accumulates per IP and, past a threshold, bans
// that IP from ALL routes for a while. This exists mainly to protect the
// real /admin login from credential stuffing once a scanner pivots from
// probing junk to attacking login — NOT to stop the scanning itself (fresh
// IPs are cheap for this actor, so an IP ban can't win that race). Because
// of that we bias toward a slightly LOWER threshold rather than a high one
// tuned to avoid false positives from a small/stable IP pool.
//
// State is a single-instance in-memory Map — fine for one Node process. Scores
// decay after a quiet period so a transient burst does not ban an IP forever.
//
// All time-based logic takes an optional `now` argument (defaulting to
// Date.now()) so the decay/ban windows are deterministic to test.
const log = require('../utils/logger')('botscore')
// Score at/above which an IP is banned from ALL routes. Deliberately on the low
// side (see job #2 above): fresh IPs are cheap for this actor, so we'd rather
// ban an attacking IP a little early than tune high to protect a stable pool.
const BAN_THRESHOLD = 80
// How long a ban lasts.
const BAN_MS = 60 * 60 * 1000 // 1 hour
// Quiet period after which a non-banned IP's accumulated score resets to 0.
const QUIET_MS = 30 * 60 * 1000 // 30 min
// Points added for a failed /admin login (wired in from the auth controller).
const LOGIN_FAIL_POINTS = 34
// Points for a tripped honeypot — an unambiguous bot, ban on sight.
const HONEYPOT_POINTS = BAN_THRESHOLD
// Weighted scanner paths, matched as a prefix against the lowercased request
// path, FIRST match wins — so more specific paths must precede their prefixes
// (e.g. /wp-admin/install.php before /wp-admin). Heavier weights = more damning.
//
// /wp-admin/install.php is by far the most-hit junk path in the real Pangolin
// access logs (from many rotating IPs), so it carries the single highest weight:
// a lone hit exceeds the ban threshold on its own — effectively a 1-hit ban —
// and outweighs every other individual path.
const PATH_WEIGHTS = [
['/wp-admin/install.php', 200], // top offender in prod logs — near 1-hit ban
['/.env', 100],
['/.git', 100],
['/.aws', 100],
['/wp-login.php', 100],
['/xmlrpc.php', 100],
['/wp-admin', 50],
['/administrator', 50],
['/phpmyadmin', 50],
['/mysql', 50],
['/wp-content', 40],
['/wp-includes', 40],
['/wp-json', 40],
['/user/login', 40], // Drupal
['/console', 40],
['/actuator', 40], // Spring Boot
['/vendor/phpunit', 100],
['/cgi-bin', 40],
]
// ip -> { score, lastSeen, bannedUntil }
const store = new Map()
// Return the scanner weight for a request path (0 if it is a legitimate path).
function scoreForPath(pathname) {
const p = String(pathname || '').toLowerCase()
for (const [prefix, weight] of PATH_WEIGHTS) {
if (p === prefix || p.startsWith(prefix)) return weight
}
return 0
}
function getEntry(ip) {
let e = store.get(ip)
if (!e) {
e = { score: 0, lastSeen: 0, bannedUntil: 0 }
store.set(ip, e)
}
return e
}
function isBanned(ip, now = Date.now()) {
const e = store.get(ip)
return Boolean(e && e.bannedUntil > now)
}
// Add points to an IP's score. Applies quiet-period decay first, then bans the
// IP if the new score crosses the threshold. Returns the updated entry.
function addScore(ip, points, now = Date.now(), reason = 'scan') {
const e = getEntry(ip)
// Decay: if the IP has been quiet longer than QUIET_MS (and is not currently
// banned), forget its accumulated score before adding the new hit.
if (e.bannedUntil <= now && e.lastSeen && now - e.lastSeen > QUIET_MS) {
e.score = 0
}
e.score += points
e.lastSeen = now
if (e.score >= BAN_THRESHOLD && e.bannedUntil <= now) {
e.bannedUntil = now + BAN_MS
log.warn('IP banned', { ip, score: e.score, reason, banMs: BAN_MS })
}
return e
}
// Points for a failed real login — called from the auth controller.
function recordLoginFailure(ip, now = Date.now()) {
return addScore(ip, LOGIN_FAIL_POINTS, now, 'login-fail')
}
// A tripped honeypot: instant ban-worthy score.
function recordHoneypot(ip, now = Date.now()) {
return addScore(ip, HONEYPOT_POINTS, now, 'honeypot')
}
// Early middleware: mounted before routing so banned IPs never reach a real
// handler. Everything here 404s (never 403) so we never confirm a path or a ban.
function guard(req, res, next) {
const ip = req.ip
const now = Date.now()
// (1) Known junk/scanner path → 404 FIRST, unconditionally. This is evaluated
// and returned before any ban check, so the 404 is fully independent of this
// IP's score/ban state: a scanner cycling through fresh Cloudflare IPs gets no
// free pass on a junk path. Scoring still runs (it accrues toward a /admin ban
// if the IP is reused), but the 404 does not depend on it.
const points = scoreForPath(req.path)
if (points > 0) {
const e = addScore(ip, points, now, 'scan')
log.warn('scanner path hit', { ip, path: req.path, points, score: e.score })
return notFound(res)
}
// (2) Non-junk path → block only if this IP is already banned (the
// credential-stuffing guard for the real /admin login), else let it through.
if (isBanned(ip, now)) {
log.debug('blocked banned IP', { ip, path: req.path })
return notFound(res)
}
return next()
}
// Uniform 404 — mirrors the SPA/API "Not found" shape without leaking anything.
function notFound(res) {
return res.status(404).json({ message: 'Not found' })
}
// ── Cleanup sweep ──────────────────────────────────────────────────────────
// Every unique IP that hits a scored path adds an entry and nothing else evicts
// it, so the store would grow unbounded. Periodically drop entries that are no
// longer meaningful: NOT banned and quiet longer than QUIET_MS (their score
// would already reset to 0 on next touch anyway). Banned entries, and entries
// still inside their quiet decay window, are left untouched. Returns the count
// removed. The eviction age reuses QUIET_MS; SWEEP_INTERVAL_MS is only cadence.
const SWEEP_INTERVAL_MS = 10 * 60 * 1000 // 10 min
function sweep(now = Date.now()) {
let removed = 0
for (const [ip, e] of store) {
if (e.bannedUntil <= now && now - e.lastSeen > QUIET_MS) {
store.delete(ip)
removed++
}
}
if (removed > 0) log.debug('store sweep', { removed, remaining: store.size })
return removed
}
let sweepTimer = null
function startSweeper() {
if (sweepTimer) return sweepTimer
sweepTimer = setInterval(() => sweep(), SWEEP_INTERVAL_MS)
// Never let the sweep timer alone keep the event loop alive (tests, shutdown).
if (sweepTimer.unref) sweepTimer.unref()
return sweepTimer
}
function stopSweeper() {
if (sweepTimer) {
clearInterval(sweepTimer)
sweepTimer = null
}
}
// Start sweeping on load — this is a single long-lived process.
startSweeper()
// Test/ops helpers.
function _reset() {
store.clear()
}
function _snapshot(ip) {
const e = store.get(ip)
return e ? { ...e } : null
}
module.exports = {
guard,
scoreForPath,
addScore,
isBanned,
recordLoginFailure,
recordHoneypot,
sweep,
startSweeper,
stopSweeper,
_reset,
_snapshot,
// Exported for tests / tuning.
BAN_THRESHOLD,
BAN_MS,
QUIET_MS,
LOGIN_FAIL_POINTS,
HONEYPOT_POINTS,
SWEEP_INTERVAL_MS,
}

View File

@@ -0,0 +1,94 @@
// ── Login brute-force protection ───────────────────────────────────────────
//
// Three independent layers guard the login endpoint:
//
// 1. slowDown — express-slow-down adds an increasing delay to each request
// once a few have been made in the window (before the hard cap
// bites), so a scripted burst is throttled but a human typing a
// wrong password twice barely notices.
// 2. loginLimiter (in rateLimit.js) — a hard 10-per-15-min cap per IP.
// 3. backoffGuard — a per-IP exponential backoff tracked in a SEPARATE store
// from the rate limiter, keyed on *failed* attempts. Because it
// is separate it persists across the rate limiter's window
// reset: an IP that keeps failing keeps getting locked out for
// longer, independent of the sliding 15-min window.
//
// The backoff store is in-memory (single instance). Times take an optional `now`
// so the escalation/decay is deterministic to test.
const slowDown = require('express-slow-down')
const log = require('../utils/logger')('loginprotect')
// Progressive delay: no delay for the first few attempts, then +0.5s each,
// capped so a request never hangs too long.
const slowLogin = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 3,
delayMs: (used) => (used - 3) * 500,
maxDelayMs: 20 * 1000,
})
// Exponential backoff on consecutive failures.
const BASE_MS = 1000 // first failure locks ~1s
const MAX_MS = 15 * 60 * 1000 // cap a single lock at 15 min
const RESET_MS = 30 * 60 * 1000 // forget the streak after this much quiet
// ip -> { count, blockedUntil, lastFailure }
const store = new Map()
// Record a failed login and lengthen this IP's lockout. Returns ms locked.
function recordFailure(ip, now = Date.now()) {
let e = store.get(ip)
if (!e || now - e.lastFailure > RESET_MS) {
e = { count: 0, blockedUntil: 0, lastFailure: 0 }
store.set(ip, e)
}
e.count += 1
e.lastFailure = now
const delay = Math.min(BASE_MS * 2 ** (e.count - 1), MAX_MS)
e.blockedUntil = now + delay
log.warn('login failure recorded', { ip, count: e.count, lockMs: delay })
return delay
}
// Clear an IP's failure streak after a successful login.
function recordSuccess(ip) {
store.delete(ip)
}
// How long (ms) this IP is still locked out for, 0 if not locked.
function retryAfterMs(ip, now = Date.now()) {
const e = store.get(ip)
if (!e || e.blockedUntil <= now) return 0
return e.blockedUntil - now
}
// Middleware: reject while the IP is in its backoff window. Generic message —
// never reveals whether the username or the password was the problem.
function backoffGuard(req, res, next) {
const wait = retryAfterMs(req.ip)
if (wait > 0) {
res.set('Retry-After', String(Math.ceil(wait / 1000)))
log.warn('login blocked by backoff', { ip: req.ip, retryAfterMs: wait })
return res.status(429).json({ message: 'Too many login attempts. Please try again later.' })
}
return next()
}
// Test helpers.
function _reset() {
store.clear()
}
module.exports = {
slowLogin,
backoffGuard,
recordFailure,
recordSuccess,
retryAfterMs,
_reset,
BASE_MS,
MAX_MS,
RESET_MS,
}

View File

@@ -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,
}

View File

@@ -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,
}

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)

View File

@@ -2,6 +2,7 @@ require('dotenv').config()
const http = require('http')
const app = require('./app')
const botScore = require('./middleware/botScore')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -47,6 +48,7 @@ function setupShutdown(server) {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
botScore.stopSweeper() // stop the bot-store cleanup interval
server.close(() => log.info('http server closed'))
try {
await close()

View File

@@ -7,6 +7,8 @@ const users = require('../model/users/users.model')
const JWT_SECRET = process.env.JWT_SECRET
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
if (!JWT_SECRET) {
log.warn('JWT_SECRET is not set — set it in .env before going to production')
@@ -25,6 +27,19 @@ function verifyToken(token) {
}
}
// Short-lived token issued after the password step for users with TOTP enabled.
// It is NOT a session: it carries stage:'totp' so getUserFromRequest rejects it,
// and it is only accepted by verifyTotpChallenge to gate the second factor.
function signTotpChallenge(user) {
return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL })
}
function verifyTotpChallenge(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.stage !== 'totp') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
@@ -71,11 +86,15 @@ function extractToken(req) {
return null
}
// Returns the decoded user or null without rejecting the request.
// Returns the decoded user or null without rejecting the request. Stage-tagged
// tokens (e.g. the TOTP challenge) are explicitly NOT sessions, so an attacker
// can't present a half-authenticated challenge token as a full login.
function getUserFromRequest(req) {
const token = extractToken(req)
if (!token) return null
return verifyToken(token)
const decoded = verifyToken(token)
if (!decoded || decoded.stage) return null
return decoded
}
// Gate middleware for protected (admin) routes. Re-validates the token against
@@ -110,6 +129,8 @@ module.exports = {
COOKIE_NAME,
signToken,
verifyToken,
signTotpChallenge,
verifyTotpChallenge,
setAuthCookie,
clearAuthCookie,
getUserFromRequest,

43
server/src/utils/totp.js Normal file
View 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 }

View File

@@ -0,0 +1,102 @@
// ── Trust proxy configuration ──────────────────────────────────────────────
//
// Real request path for this deployment:
//
// client → Pangolin → newt tunnel agent ("ptero", separate VM)
// → this app (its own VM), over the LAN
//
// The hop that actually opens the TCP connection to this app is ptero, so from
// Express's point of view ptero is THE trusted proxy and its LAN IP is the
// right-most/most-recently-added entry to reconcile against. The real client IP
// arrives in X-Forwarded-For. req.ip / req.secure must reflect the real client
// because the rate limiter, exponential backoff, bot-scoring ban, and activity
// log all key on req.ip — so this is a prerequisite for every other control.
//
// Recommended production value: pin TRUST_PROXY to ptero's LAN IP exactly. That
// is stricter than a hop count: Express will only honour XFF on connections that
// actually come from ptero, so nothing else on the LAN can inject a forwarded
// header. See TRUST_PROXY in .env.example for how to set it.
//
// IMPORTANT ASSUMPTION: pinning ptero's IP assumes ptero holds a STATIC IP
// (a DHCP reservation in Omada). If that reservation does not exist, a lease
// change would silently move ptero to a new IP and every XFF would stop being
// trusted — req.ip would collapse to ptero's (new) address for all clients,
// breaking rate limiting/bans. Verify the reservation before relying on this,
// and use DEBUG_TRUST_PROXY (below) to re-check the observed proxy IP without a
// code redeploy if it ever needs to change.
//
// We deliberately DO NOT use a blanket `true`. `true` trusts every hop and takes
// the left-most (client-supplied, spoofable) XFF entry, letting an attacker forge
// their apparent IP to dodge rate limits / bans.
//
// TRUST_PROXY accepts:
// - unset / '' -> 1 (single proxy hop fallback)
// - an integer -> that many trusted hops (e.g. "2")
// - "false" -> false (no proxy; direct connections only)
// - "loopback" etc. -> the express preset string, passed through
// - a CSV of IPs / CIDRs -> that list (e.g. ptero's LAN IP: "10.0.0.42")
//
// `true` is intentionally rejected (coerced to 1) with a warning, so it can't be
// set by accident.
const log = require('./logger')('trustproxy')
function truthyEnv(v) {
return ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase())
}
const PRESETS = new Set(['loopback', 'linklocal', 'uniquelocal'])
function parseTrustProxy(raw = process.env.TRUST_PROXY) {
const val = (raw == null ? '' : String(raw)).trim()
if (val === '') return 1 // default: one hop (Pangolin)
if (val.toLowerCase() === 'false') return false
if (val.toLowerCase() === 'true') {
log.warn('TRUST_PROXY=true is unsafe (trusts spoofable client XFF); using 1 hop instead')
return 1
}
// Pure integer → hop count.
if (/^\d+$/.test(val)) return Number(val)
// Single express preset keyword.
if (PRESETS.has(val.toLowerCase())) return val.toLowerCase()
// Otherwise treat as a comma-separated list of trusted IPs / CIDRs (and/or
// preset keywords), which Express accepts as an array.
const list = val
.split(',')
.map((s) => s.trim())
.filter(Boolean)
return list.length === 1 ? list[0] : list
}
// Apply the setting to an Express app and log what was chosen.
function applyTrustProxy(app, raw = process.env.TRUST_PROXY) {
const setting = parseTrustProxy(raw)
app.set('trust proxy', setting)
log.info('trust proxy configured', { setting: Array.isArray(setting) ? setting.join(',') : setting })
return setting
}
// Temporary diagnostic middleware, OFF by default. Set DEBUG_TRUST_PROXY=1 to
// log, per request, the raw peer address and forwarded header alongside the IP
// Express resolved — so the real proxy IP (ptero) can be re-verified in-place
// without a code change if it ever moves. Mounted before the bot guard so it
// still fires for scanner/junk requests (whose source IPs are what we want to
// see). Turn it back off once verified; it is noisy.
function trustProxyDebug(req, res, next) {
if (truthyEnv(process.env.DEBUG_TRUST_PROXY)) {
log.info('trust-proxy debug', {
remoteAddress: req.socket && req.socket.remoteAddress,
xForwardedFor: req.headers['x-forwarded-for'] || null,
resolvedIp: req.ip,
method: req.method,
path: req.originalUrl,
})
}
return next()
}
module.exports = { parseTrustProxy, applyTrustProxy, trustProxyDebug }