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:
192
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
192
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||
export default function AccountAdmin() {
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Enrollment state.
|
||||
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
async function beginSetup() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
setSetup(await api.admin.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEnable() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication is now enabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not enable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpDisable(code.trim())
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication has been disabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not disable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = account?.totp_enabled
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 560 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Two-factor authentication
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Add a time-based one-time code (TOTP) from an authenticator app as a second step at login.
|
||||
Optional, and only affects your own account.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
fontSize: '0.82rem',
|
||||
color: enabled ? '#7fd0a4' : 'var(--muted)',
|
||||
marginBottom: 22,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
background: enabled ? '#7fd0a4' : 'var(--dim)',
|
||||
}}
|
||||
/>
|
||||
{enabled ? 'Enabled' : 'Not enabled'}
|
||||
</div>
|
||||
|
||||
{/* Enable flow */}
|
||||
{!enabled && !setup && (
|
||||
<div>
|
||||
<button onClick={beginSetup} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && setup && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.
|
||||
</p>
|
||||
<img
|
||||
src={setup.qr}
|
||||
alt="TOTP QR code"
|
||||
width={180}
|
||||
height={180}
|
||||
style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }}
|
||||
/>
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={confirmEnable} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||
</button>
|
||||
<button onClick={() => setSetup(null)} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable flow */}
|
||||
{enabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
Enter a current code from your authenticator to turn two-factor off.
|
||||
</p>
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user