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>
60 lines
1.6 KiB
JavaScript
60 lines
1.6 KiB
JavaScript
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
|
import { api } from '../api/client.js'
|
|
|
|
const AuthContext = createContext(null)
|
|
|
|
export function AuthProvider({ children }) {
|
|
const [user, setUser] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const data = await api.me()
|
|
setUser(data.user)
|
|
} catch {
|
|
setUser(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
}, [refresh])
|
|
|
|
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
|
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
|
|
const login = useCallback(async (username, password, extra) => {
|
|
const data = await api.login(username, password, extra)
|
|
if (data.user) setUser(data.user)
|
|
return data
|
|
}, [])
|
|
|
|
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
|
const loginTotp = useCallback(async (challenge, code) => {
|
|
const data = await api.loginTotp(challenge, code)
|
|
setUser(data.user)
|
|
return data.user
|
|
}, [])
|
|
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
await api.logout()
|
|
} finally {
|
|
setUser(null)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useAuth() {
|
|
const ctx = useContext(AuthContext)
|
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
|
return ctx
|
|
}
|