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:
@@ -12,6 +12,7 @@ const NAV = [
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/users', label: 'Users' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
]
|
||||
|
||||
const TITLES = {
|
||||
@@ -22,6 +23,7 @@ const TITLES = {
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
|
||||
@@ -6,17 +6,36 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||
// the server's HONEYPOT_FIELD ('company').
|
||||
const honeypotStyle = {
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: 'auto',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { user, login } = useAuth()
|
||||
const { user, login, loginTotp } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/admin'
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Two-factor step state.
|
||||
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
// Already signed in → go straight to the panel.
|
||||
useEffect(() => {
|
||||
if (user) navigate(dest, { replace: true })
|
||||
@@ -27,7 +46,13 @@ export default function AdminLogin() {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
const data = await login(username, password, { company })
|
||||
if (data.totpRequired) {
|
||||
setChallenge(data.challenge)
|
||||
setStage('totp')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||
@@ -35,6 +60,24 @@ export default function AdminLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitTotp(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await loginTotp(challenge, code)
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.status === 401 && /expired/i.test(err.message)
|
||||
? 'Your verification session expired. Please sign in again.'
|
||||
: 'Invalid verification code.',
|
||||
)
|
||||
setBusy(false)
|
||||
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -63,7 +106,7 @@ export default function AdminLogin() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
@@ -73,27 +116,63 @@ export default function AdminLogin() {
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
{stage === 'creds' ? (
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Authentication code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Enter the code from your authenticator app.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
@@ -107,7 +186,7 @@ export default function AdminLogin() {
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Protected area — not indexed. Sessions expire after 1 day.
|
||||
|
||||
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