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:
15
.env.example
15
.env.example
@@ -28,6 +28,21 @@ JWT_EXPIRES_IN=1d
|
|||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
|
|
||||||
|
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||||
|
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||||
|
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
|
||||||
|
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
|
||||||
|
# Integer hop count or "false" also accepted; a blanket "true" is rejected
|
||||||
|
# (coerced to 1) to prevent X-Forwarded-For spoofing.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
|
||||||
|
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||||
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
|
# Optional TOTP two-factor (opt-in per user).
|
||||||
|
TOTP_ISSUER=UOMysticmoon
|
||||||
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
# First admin bootstrap — created only if no users exist yet.
|
# First admin bootstrap — created only if no users exist yet.
|
||||||
# Set, run once, then you can blank these out.
|
# Set, run once, then you can blank these out.
|
||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
|||||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +72,7 @@ export default function App() {
|
|||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ function safeParse(text) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
// ----- auth -----
|
// ----- auth -----
|
||||||
me: () => req('/auth/me'),
|
me: () => req('/auth/me'),
|
||||||
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
// `extra` carries the honeypot field (and any future login fields).
|
||||||
|
login: (username, password, extra = {}) =>
|
||||||
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
loginTotp: (challenge, code) =>
|
||||||
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
|
|
||||||
// ----- public -----
|
// ----- public -----
|
||||||
@@ -108,6 +112,12 @@ export const api = {
|
|||||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- account security (self-service 2FA) -----
|
||||||
|
getAccount: () => req('/admin/account'),
|
||||||
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,17 @@ export function AuthProvider({ children }) {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const login = useCallback(async (username, password) => {
|
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
||||||
const data = await api.login(username, password)
|
// 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)
|
setUser(data.user)
|
||||||
return data.user
|
return data.user
|
||||||
}, [])
|
}, [])
|
||||||
@@ -37,7 +46,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const NAV = [
|
|||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
{ to: '/admin/users', label: 'Users' },
|
{ to: '/admin/users', label: 'Users' },
|
||||||
|
{ to: '/admin/account', label: 'Account' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
@@ -22,6 +23,7 @@ const TITLES = {
|
|||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|
||||||
const navBtnBase = {
|
const navBtnBase = {
|
||||||
|
|||||||
@@ -6,17 +6,36 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
|
|||||||
const BG =
|
const BG =
|
||||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
"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() {
|
export default function AdminLogin() {
|
||||||
const { user, login } = useAuth()
|
const { user, login, loginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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.
|
// Already signed in → go straight to the panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(dest, { replace: true })
|
||||||
@@ -27,7 +46,13 @@ export default function AdminLogin() {
|
|||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
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 })
|
navigate(dest, { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
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 (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
@@ -63,7 +106,7 @@ export default function AdminLogin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--line)',
|
border: '1px solid var(--line)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -73,27 +116,63 @@ export default function AdminLogin() {
|
|||||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
{stage === 'creds' ? (
|
||||||
<span className="field-label">Username</span>
|
<>
|
||||||
<input
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
type="text"
|
<span className="field-label">Username</span>
|
||||||
autoComplete="username"
|
<input
|
||||||
autoFocus
|
type="text"
|
||||||
value={username}
|
autoComplete="username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
autoFocus
|
||||||
className="input"
|
value={username}
|
||||||
/>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</label>
|
className="input"
|
||||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
/>
|
||||||
<span className="field-label">Password</span>
|
</label>
|
||||||
<input
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
type="password"
|
<span className="field-label">Password</span>
|
||||||
autoComplete="current-password"
|
<input
|
||||||
value={password}
|
type="password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
autoComplete="current-password"
|
||||||
className="input"
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</label>
|
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 && (
|
{error && (
|
||||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
<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"
|
className="btn btn-primary"
|
||||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
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>
|
</button>
|
||||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
<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.
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,30 @@ JWT_EXPIRES_IN=1d
|
|||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
|
|
||||||
|
# Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero"
|
||||||
|
# (separate VM) -> this app. ptero is the hop that connects to us, so pin
|
||||||
|
# TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on
|
||||||
|
# connections from ptero, and req.ip / req.secure reflect the real client (used
|
||||||
|
# by rate limiting, backoff, bot-ban, activity log).
|
||||||
|
# <ptero LAN IP> -> e.g. 10.0.0.42 (RECOMMENDED in prod; requires a static
|
||||||
|
# DHCP reservation for ptero in Omada — a lease change would
|
||||||
|
# silently break IP trust)
|
||||||
|
# an integer -> that many hops (fallback if you can't pin an IP)
|
||||||
|
# false -> no proxy (direct connections)
|
||||||
|
# NOTE: a blanket "true" is intentionally rejected (coerced to 1) — it would let
|
||||||
|
# clients spoof their IP via a forged X-Forwarded-For and dodge rate limits/bans.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Set to 1 to log each request's raw peer address + X-Forwarded-For + resolved
|
||||||
|
# req.ip, so you can verify/refresh ptero's IP without redeploying. Noisy —
|
||||||
|
# leave off in normal operation.
|
||||||
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
|
# Optional TOTP two-factor (opt-in per user).
|
||||||
|
TOTP_ISSUER=UOMysticmoon
|
||||||
|
# How long the "password verified, awaiting code" step stays valid.
|
||||||
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
# Created on first boot if the users table is empty
|
# Created on first boot if the users table is empty
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-admin-password
|
ADMIN_PASSWORD=change-me-admin-password
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
username VARCHAR(32) NOT NULL UNIQUE,
|
username VARCHAR(32) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(72) NOT NULL,
|
password_hash VARCHAR(72) NOT NULL,
|
||||||
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
|
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
|
||||||
|
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
||||||
|
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login_at DATETIME NULL
|
last_login_at DATETIME NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -122,6 +124,10 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
|||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
-- (The category foreign key is only added on fresh installs; on upgraded databases
|
-- (The category foreign key is only added on fresh installs; on upgraded databases
|
||||||
-- referential integrity for category_id is enforced in application code.)
|
-- referential integrity for category_id is enforced in application code.)
|
||||||
|
-- Opt-in TOTP two-factor columns for databases created before login hardening.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
||||||
|
|||||||
370
server/package-lock.json
generated
370
server/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-slow-down": "^3.1.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -22,7 +23,9 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"sanitize-html": "^2.17.5"
|
"qrcode": "^1.5.4",
|
||||||
|
"sanitize-html": "^2.17.5",
|
||||||
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4"
|
||||||
@@ -56,6 +59,30 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -92,6 +119,12 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base32.js": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/basic-auth": {
|
"node_modules/basic-auth": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||||
@@ -240,6 +273,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -265,6 +307,35 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
@@ -361,6 +432,15 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -398,6 +478,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -506,6 +592,12 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -645,6 +737,39 @@
|
|||||||
"express": ">= 4.11"
|
"express": ">= 4.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-slow-down": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-0gZ1HHow8H83z1/+81DdWB60RSGHI0mJB0ZM1m5P6/BexORFcA8P1TgU0NKEwOiRmxyCIoktZYqmA+1UCc83+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express-rate-limit": "8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": "4 || 5 || ^5.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-slow-down/node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express-validator": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.2",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||||
@@ -689,6 +814,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -731,6 +869,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -900,6 +1047,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -932,6 +1088,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -1022,6 +1187,18 @@
|
|||||||
"dayjs": "^1.11.7"
|
"dayjs": "^1.11.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -1383,6 +1560,42 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse-srcset": {
|
"node_modules/parse-srcset": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
@@ -1398,6 +1611,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -1423,6 +1645,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -1471,6 +1702,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -1538,6 +1786,21 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1636,6 +1899,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -1736,6 +2005,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/speakeasy": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base32.js": "0.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -1762,6 +2043,32 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1880,6 +2187,67 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "nodemon src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
"test": "echo \"no tests yet\" && exit 0"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"express",
|
"express",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-slow-down": "^3.1.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -31,7 +32,9 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"sanitize-html": "^2.17.5"
|
"qrcode": "^1.5.4",
|
||||||
|
"sanitize-html": "^2.17.5",
|
||||||
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4"
|
||||||
|
|||||||
@@ -9,15 +9,28 @@ require('dotenv').config()
|
|||||||
|
|
||||||
const apiRouter = require('./router/api.router')
|
const apiRouter = require('./router/api.router')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||||
|
const botScore = require('./middleware/botScore')
|
||||||
|
|
||||||
const httpLog = createLogger('http')
|
const httpLog = createLogger('http')
|
||||||
const errLog = createLogger('error')
|
const errLog = createLogger('error')
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
|
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
|
||||||
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
|
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
|
||||||
app.set('trust proxy', 1)
|
// 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
|
// 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.
|
// the frontend phase; the rest of helmet's protections stay enabled.
|
||||||
|
|||||||
228
server/src/middleware/botScore.js
Normal file
228
server/src/middleware/botScore.js
Normal 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,
|
||||||
|
}
|
||||||
94
server/src/middleware/loginProtection.js
Normal file
94
server/src/middleware/loginProtection.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { query } = require('../../utils/db')
|
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' }) {
|
async function insertUser({ username, passwordHash, role = 'admin' }) {
|
||||||
const res = await query(
|
const res = await query(
|
||||||
@@ -54,6 +54,20 @@ async function touchLastLogin(id) {
|
|||||||
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [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 = {
|
module.exports = {
|
||||||
insertUser,
|
insertUser,
|
||||||
findByUsername,
|
findByUsername,
|
||||||
@@ -64,4 +78,7 @@ module.exports = {
|
|||||||
countUsers,
|
countUsers,
|
||||||
countAdmins,
|
countAdmins,
|
||||||
touchLastLogin,
|
touchLastLogin,
|
||||||
|
setTotpSecret,
|
||||||
|
enableTotp,
|
||||||
|
disableTotp,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ const usersDb = require('./users.db')
|
|||||||
|
|
||||||
const SALT_ROUNDS = 10
|
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) {
|
function sanitize(user) {
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
const { password_hash, ...safe } = user
|
const { password_hash, totp_secret, ...safe } = user
|
||||||
return safe
|
return safe
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,24 @@ async function getById(id) {
|
|||||||
return sanitize(await usersDb.findById(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) {
|
async function validatePassword(user, password) {
|
||||||
if (!user || !user.password_hash) return false
|
if (!user || !user.password_hash) return false
|
||||||
return bcrypt.compare(password, user.password_hash)
|
return bcrypt.compare(password, user.password_hash)
|
||||||
@@ -63,6 +81,7 @@ module.exports = {
|
|||||||
createUser,
|
createUser,
|
||||||
getRawByUsername,
|
getRawByUsername,
|
||||||
getById,
|
getById,
|
||||||
|
getRawById,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
list,
|
list,
|
||||||
update,
|
update,
|
||||||
@@ -70,4 +89,7 @@ module.exports = {
|
|||||||
count,
|
count,
|
||||||
countAdmins,
|
countAdmins,
|
||||||
recordLogin,
|
recordLogin,
|
||||||
|
setTotpSecret,
|
||||||
|
enableTotp,
|
||||||
|
disableTotp,
|
||||||
}
|
}
|
||||||
|
|||||||
84
server/src/router/v1/admin/account.controller.js
Normal file
84
server/src/router/v1/admin/account.controller.js
Normal 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 }
|
||||||
@@ -6,6 +6,7 @@ const multer = require('multer')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const ctrl = require('./admin.controller')
|
const ctrl = require('./admin.controller')
|
||||||
|
const account = require('./account.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -19,6 +20,23 @@ adminRouter.use(noindex, isLoggedIn)
|
|||||||
// management, site mode, and settings are restricted to the admin role.
|
// management, site mode, and settings are restricted to the admin role.
|
||||||
const adminOnly = requireRole('admin')
|
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) ───────────────────────────────
|
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||||
const UPLOAD_DIR =
|
const UPLOAD_DIR =
|
||||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||||
|
|||||||
@@ -1,35 +1,105 @@
|
|||||||
const users = require('../../../model/users/users.model')
|
const users = require('../../../model/users/users.model')
|
||||||
const activity = require('../../../model/activity/activity.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')
|
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) {
|
async function login(req, res) {
|
||||||
const { username, password } = req.body
|
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 {
|
try {
|
||||||
const user = await users.getRawByUsername(username)
|
const user = await users.getRawByUsername(username)
|
||||||
const ok = user && (await users.validatePassword(user, password))
|
const ok = user && (await users.validatePassword(user, password))
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
botScore.recordLoginFailure(req.ip)
|
||||||
|
loginProtection.recordFailure(req.ip)
|
||||||
log.warn('login failed', { username, ip: 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)
|
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
|
||||||
const token = signToken(user)
|
// hand back a short-lived, signed "password verified" challenge and require
|
||||||
setAuthCookie(req, res, token)
|
// the code. If TOTP is off, log them straight in.
|
||||||
await activity.log({ req, userId: user.id, action: 'auth.login' })
|
if (needsTotp(user)) {
|
||||||
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
|
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({
|
return issueSession(req, res, user)
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('login error', err)
|
log.error('login error', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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)
|
clearAuthCookie(req, res)
|
||||||
return res.json({ message: 'Logged out.' })
|
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 }
|
||||||
|
|||||||
@@ -1,21 +1,42 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { body } = require('express-validator')
|
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 { isLoggedIn } = require('../../../utils/auth')
|
||||||
const { loginLimiter } = require('../../../middleware/rateLimit')
|
const { loginLimiter } = require('../../../middleware/rateLimit')
|
||||||
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
const authRouter = express.Router()
|
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(
|
authRouter.post(
|
||||||
'/login',
|
'/login',
|
||||||
loginLimiter,
|
...loginGuards,
|
||||||
body('username').isString().trim().notEmpty(),
|
body('username').isString().trim().notEmpty(),
|
||||||
body('password').isString().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,
|
validate,
|
||||||
login,
|
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.post('/logout', logout)
|
||||||
authRouter.get('/me', isLoggedIn, me)
|
authRouter.get('/me', isLoggedIn, me)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ require('dotenv').config()
|
|||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
|
||||||
const app = require('./app')
|
const app = require('./app')
|
||||||
|
const botScore = require('./middleware/botScore')
|
||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
@@ -47,6 +48,7 @@ function setupShutdown(server) {
|
|||||||
if (closing) return
|
if (closing) return
|
||||||
closing = true
|
closing = true
|
||||||
log.warn(`${signal} received — shutting down gracefully`)
|
log.warn(`${signal} received — shutting down gracefully`)
|
||||||
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||||
server.close(() => log.info('http server closed'))
|
server.close(() => log.info('http server closed'))
|
||||||
try {
|
try {
|
||||||
await close()
|
await close()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const users = require('../model/users/users.model')
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET
|
const JWT_SECRET = process.env.JWT_SECRET
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
||||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
|
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) {
|
if (!JWT_SECRET) {
|
||||||
log.warn('JWT_SECRET is not set — set it in .env before going to production')
|
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).
|
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
|
||||||
function cookieMaxAge() {
|
function cookieMaxAge() {
|
||||||
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
||||||
@@ -71,11 +86,15 @@ function extractToken(req) {
|
|||||||
return null
|
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) {
|
function getUserFromRequest(req) {
|
||||||
const token = extractToken(req)
|
const token = extractToken(req)
|
||||||
if (!token) return null
|
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
|
// Gate middleware for protected (admin) routes. Re-validates the token against
|
||||||
@@ -110,6 +129,8 @@ module.exports = {
|
|||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
signToken,
|
signToken,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
|
signTotpChallenge,
|
||||||
|
verifyTotpChallenge,
|
||||||
setAuthCookie,
|
setAuthCookie,
|
||||||
clearAuthCookie,
|
clearAuthCookie,
|
||||||
getUserFromRequest,
|
getUserFromRequest,
|
||||||
|
|||||||
43
server/src/utils/totp.js
Normal file
43
server/src/utils/totp.js
Normal 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 }
|
||||||
102
server/src/utils/trustProxy.js
Normal file
102
server/src/utils/trustProxy.js
Normal 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 }
|
||||||
21
server/test/_helper.js
Normal file
21
server/test/_helper.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Test helper: start a throwaway Express app on an ephemeral port and return its
|
||||||
|
// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no
|
||||||
|
// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT
|
||||||
|
// touch the database.
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
async function startApp(configure) {
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json())
|
||||||
|
configure(app)
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||||
|
})
|
||||||
|
const { port } = server.address()
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startApp }
|
||||||
192
server/test/botScore.test.js
Normal file
192
server/test/botScore.test.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const botScore = require('../src/middleware/botScore')
|
||||||
|
const { startApp } = require('./_helper')
|
||||||
|
|
||||||
|
beforeEach(() => botScore._reset())
|
||||||
|
|
||||||
|
test('scoreForPath: scanner paths score, legitimate app paths do not', () => {
|
||||||
|
assert.ok(botScore.scoreForPath('/wp-admin') > 0)
|
||||||
|
assert.ok(botScore.scoreForPath('/wp-login.php') > 0)
|
||||||
|
assert.ok(botScore.scoreForPath('/.env') > 0)
|
||||||
|
assert.ok(botScore.scoreForPath('/xmlrpc.php') > 0)
|
||||||
|
// The real admin path and API are NOT scanner signal.
|
||||||
|
assert.equal(botScore.scoreForPath('/admin'), 0)
|
||||||
|
assert.equal(botScore.scoreForPath('/admin/login'), 0)
|
||||||
|
assert.equal(botScore.scoreForPath('/api/v1/auth/login'), 0)
|
||||||
|
assert.equal(botScore.scoreForPath('/'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/wp-admin/install.php is the single highest-weighted path (near 1-hit ban)', () => {
|
||||||
|
const install = botScore.scoreForPath('/wp-admin/install.php')
|
||||||
|
// Higher than the generic /wp-admin prefix (more specific entry wins first)...
|
||||||
|
assert.ok(install > botScore.scoreForPath('/wp-admin'))
|
||||||
|
// ...and higher than every other scanner path.
|
||||||
|
for (const p of ['/.env', '/.git', '/wp-login.php', '/xmlrpc.php', '/phpmyadmin', '/wp-json']) {
|
||||||
|
assert.ok(install > botScore.scoreForPath(p), `install.php should outweigh ${p}`)
|
||||||
|
}
|
||||||
|
// A single hit alone meets/exceeds the ban threshold → effectively a 1-hit ban.
|
||||||
|
assert.ok(install >= botScore.BAN_THRESHOLD)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('install.php 404s on a first-time-seen IP (before any ban would trigger)', async () => {
|
||||||
|
const freshIp = '203.0.113.80'
|
||||||
|
// Precondition: this IP has never been seen, so it is NOT banned yet.
|
||||||
|
assert.equal(botScore.isBanned(freshIp), false)
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.use(botScore.guard)
|
||||||
|
a.get('/', (req, res) => res.json({ ok: true }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// The very first hit to the junk path must 404 — the 404 comes from the
|
||||||
|
// junk-path rule, independent of the (currently empty) ban state.
|
||||||
|
const res = await fetch(`${app.url}/wp-admin/install.php`, {
|
||||||
|
headers: { 'X-Forwarded-For': freshIp },
|
||||||
|
})
|
||||||
|
assert.equal(res.status, 404)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a low-weight junk path 404s on first hit WITHOUT a ban (404 is ban-independent)', async () => {
|
||||||
|
const freshIp = '203.0.113.81'
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.use(botScore.guard)
|
||||||
|
a.get('/', (req, res) => res.json({ ok: true }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// /wp-content scores 40 (< threshold 80): the hit 404s but does NOT ban,
|
||||||
|
// proving the immediate 404 does not depend on the IP having crossed the
|
||||||
|
// ban threshold.
|
||||||
|
const res = await fetch(`${app.url}/wp-content/uploads/x.php`, {
|
||||||
|
headers: { 'X-Forwarded-For': freshIp },
|
||||||
|
})
|
||||||
|
assert.equal(res.status, 404)
|
||||||
|
assert.equal(botScore.isBanned(freshIp), false)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('addScore bans once the threshold is crossed', () => {
|
||||||
|
const ip = '198.51.100.1'
|
||||||
|
const now = 1_000_000
|
||||||
|
assert.equal(botScore.isBanned(ip, now), false)
|
||||||
|
botScore.addScore(ip, botScore.BAN_THRESHOLD - 1, now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now), false) // just under
|
||||||
|
botScore.addScore(ip, 1, now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now), true) // at threshold
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a single .env probe (weight 100) is an instant ban', () => {
|
||||||
|
const ip = '198.51.100.9'
|
||||||
|
const now = 5_000
|
||||||
|
botScore.addScore(ip, botScore.scoreForPath('/.env'), now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login failures + one scan hit ban faster than either alone', () => {
|
||||||
|
const ip = '198.51.100.2'
|
||||||
|
const now = 2_000_000
|
||||||
|
// Two failed logins alone: below threshold, not banned.
|
||||||
|
botScore.recordLoginFailure(ip, now)
|
||||||
|
botScore.recordLoginFailure(ip, now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now), false)
|
||||||
|
// Add one medium scanner hit (wp-admin, 50) → crosses threshold.
|
||||||
|
botScore.addScore(ip, botScore.scoreForPath('/wp-admin'), now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('score decays to zero after a quiet period', () => {
|
||||||
|
const ip = '198.51.100.3'
|
||||||
|
const t0 = 10_000
|
||||||
|
botScore.addScore(ip, 50, t0) // below threshold
|
||||||
|
// Long quiet gap, then another 50 — should NOT ban because the first decayed.
|
||||||
|
const later = t0 + botScore.QUIET_MS + 1
|
||||||
|
botScore.addScore(ip, 50, later)
|
||||||
|
assert.equal(botScore.isBanned(ip, later), false)
|
||||||
|
assert.equal(botScore._snapshot(ip).score, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ban persists for the ban window and lifts after it', () => {
|
||||||
|
const ip = '198.51.100.4'
|
||||||
|
const now = 3_000_000
|
||||||
|
botScore.addScore(ip, botScore.BAN_THRESHOLD, now)
|
||||||
|
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS - 1), true)
|
||||||
|
assert.equal(botScore.isBanned(ip, now + botScore.BAN_MS + 1), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sweep removes stale unbanned entries but keeps banned and recent ones', () => {
|
||||||
|
const t0 = 100_000_000
|
||||||
|
// (a) stale + unbanned: scored below threshold, then goes quiet past QUIET_MS.
|
||||||
|
botScore.addScore('10.0.0.1', 50, t0)
|
||||||
|
// (b) banned + quiet: banned now, and its lastSeen is old at sweep time — must
|
||||||
|
// survive because the ban is still active.
|
||||||
|
botScore.addScore('10.0.0.2', botScore.BAN_THRESHOLD, t0)
|
||||||
|
// (c) recently active: scored just before the sweep, still inside QUIET_MS.
|
||||||
|
const sweepAt = t0 + botScore.QUIET_MS + 1
|
||||||
|
botScore.addScore('10.0.0.3', 50, sweepAt)
|
||||||
|
|
||||||
|
const removed = botScore.sweep(sweepAt)
|
||||||
|
|
||||||
|
assert.equal(removed, 1) // only the stale unbanned entry
|
||||||
|
assert.equal(botScore._snapshot('10.0.0.1'), null) // (a) evicted
|
||||||
|
assert.notEqual(botScore._snapshot('10.0.0.2'), null) // (b) banned → survives
|
||||||
|
assert.equal(botScore.isBanned('10.0.0.2', sweepAt), true)
|
||||||
|
assert.notEqual(botScore._snapshot('10.0.0.3'), null) // (c) recent → survives
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sweep keeps an unbanned entry that is exactly at the quiet boundary', () => {
|
||||||
|
const t0 = 200_000_000
|
||||||
|
botScore.addScore('10.0.1.1', 40, t0)
|
||||||
|
// now - lastSeen === QUIET_MS (not strictly greater) → not yet evictable.
|
||||||
|
const removed = botScore.sweep(t0 + botScore.QUIET_MS)
|
||||||
|
assert.equal(removed, 0)
|
||||||
|
assert.notEqual(botScore._snapshot('10.0.1.1'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: scanner junk path returns 404, legit path passes through', async () => {
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.use(botScore.guard)
|
||||||
|
a.get('/', (req, res) => res.json({ ok: true }))
|
||||||
|
a.get('/admin', (req, res) => res.json({ ok: 'admin' }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const scan = await fetch(`${app.url}/wp-login.php`, { headers: { 'X-Forwarded-For': '203.0.113.20' } })
|
||||||
|
assert.equal(scan.status, 404)
|
||||||
|
|
||||||
|
const ok = await fetch(`${app.url}/admin`, { headers: { 'X-Forwarded-For': '203.0.113.21' } })
|
||||||
|
assert.equal(ok.status, 200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guard: once banned, an IP gets 404 on ALL routes', async () => {
|
||||||
|
const bannedIp = '203.0.113.30'
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.use(botScore.guard)
|
||||||
|
a.get('/', (req, res) => res.json({ ok: true }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// One .env probe → instant ban for this IP.
|
||||||
|
const probe = await fetch(`${app.url}/.env`, { headers: { 'X-Forwarded-For': bannedIp } })
|
||||||
|
assert.equal(probe.status, 404)
|
||||||
|
|
||||||
|
// Now a normal path from the same IP is also 404.
|
||||||
|
const blocked = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': bannedIp } })
|
||||||
|
assert.equal(blocked.status, 404)
|
||||||
|
|
||||||
|
// A different IP still gets through.
|
||||||
|
const other = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': '203.0.113.31' } })
|
||||||
|
assert.equal(other.status, 200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
76
server/test/honeypot.test.js
Normal file
76
server/test/honeypot.test.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds the pool.
|
||||||
|
// The only code path here that reaches the database (the empty-honeypot case →
|
||||||
|
// username lookup) then fails fast with ECONNREFUSED instead of opening a real
|
||||||
|
// pooled connection that would keep this test process alive and hang the runner.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const authCtrl = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const botScore = require('../src/middleware/botScore')
|
||||||
|
const lp = require('../src/middleware/loginProtection')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
// Release the DB pool so the process can exit cleanly even if a connection was
|
||||||
|
// created during module load.
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
// Minimal res double capturing status/json; set() is a no-op for headers.
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botScore._reset()
|
||||||
|
lp._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honeypot field name matches what the client renders', () => {
|
||||||
|
assert.equal(authCtrl.HONEYPOT_FIELD, 'company')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a filled honeypot fails generically and bans the IP', async () => {
|
||||||
|
const ip = '203.0.113.70'
|
||||||
|
const req = {
|
||||||
|
ip,
|
||||||
|
body: { username: 'admin', password: 'whatever', [authCtrl.HONEYPOT_FIELD]: 'Acme Corp' },
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await authCtrl.login(req, res)
|
||||||
|
|
||||||
|
// Generic failure — never says the honeypot was the reason.
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.match(res.body.message, /incorrect username or password/i)
|
||||||
|
assert.doesNotMatch(res.body.message, /honeypot|bot|company/i)
|
||||||
|
|
||||||
|
// Scored as an unambiguous bot: instant ban + backoff started.
|
||||||
|
assert.equal(botScore.isBanned(ip), true)
|
||||||
|
assert.ok(lp.retryAfterMs(ip) > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty honeypot does NOT trigger bot scoring (branch not taken)', async () => {
|
||||||
|
const ip = '203.0.113.71'
|
||||||
|
const req = { ip, body: { username: 'admin', password: 'whatever', [authCtrl.HONEYPOT_FIELD]: '' } }
|
||||||
|
const res = mockRes()
|
||||||
|
// With an empty honeypot the code proceeds to the DB lookup, which has no
|
||||||
|
// connection in this unit test and is caught → 500. The point of this test is
|
||||||
|
// only that the honeypot branch did not fire, so the IP is not banned.
|
||||||
|
await authCtrl.login(req, res)
|
||||||
|
assert.equal(botScore.isBanned(ip), false)
|
||||||
|
})
|
||||||
96
server/test/loginProtection.test.js
Normal file
96
server/test/loginProtection.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const lp = require('../src/middleware/loginProtection')
|
||||||
|
const { loginLimiter } = require('../src/middleware/rateLimit')
|
||||||
|
const { startApp } = require('./_helper')
|
||||||
|
|
||||||
|
beforeEach(() => lp._reset())
|
||||||
|
|
||||||
|
test('recordFailure escalates the lockout exponentially', () => {
|
||||||
|
const ip = '198.51.100.50'
|
||||||
|
const now = 1_000_000
|
||||||
|
const first = lp.recordFailure(ip, now)
|
||||||
|
const second = lp.recordFailure(ip, now)
|
||||||
|
const third = lp.recordFailure(ip, now)
|
||||||
|
assert.equal(first, lp.BASE_MS) // 2^0
|
||||||
|
assert.equal(second, lp.BASE_MS * 2) // 2^1
|
||||||
|
assert.equal(third, lp.BASE_MS * 4) // 2^2
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lockout is capped at MAX_MS', () => {
|
||||||
|
const ip = '198.51.100.51'
|
||||||
|
const now = 1_000_000
|
||||||
|
let last = 0
|
||||||
|
for (let i = 0; i < 40; i++) last = lp.recordFailure(ip, now)
|
||||||
|
assert.equal(last, lp.MAX_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retryAfterMs reflects the active lockout and clears after it elapses', () => {
|
||||||
|
const ip = '198.51.100.52'
|
||||||
|
const now = 2_000_000
|
||||||
|
lp.recordFailure(ip, now) // locks BASE_MS
|
||||||
|
assert.ok(lp.retryAfterMs(ip, now) > 0)
|
||||||
|
assert.equal(lp.retryAfterMs(ip, now + lp.BASE_MS + 1), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recordSuccess clears the failure streak', () => {
|
||||||
|
const ip = '198.51.100.53'
|
||||||
|
const now = 2_000_000
|
||||||
|
lp.recordFailure(ip, now)
|
||||||
|
lp.recordSuccess(ip)
|
||||||
|
assert.equal(lp.retryAfterMs(ip, now), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('streak resets after a long quiet period (does not grow forever)', () => {
|
||||||
|
const ip = '198.51.100.54'
|
||||||
|
const t0 = 3_000_000
|
||||||
|
lp.recordFailure(ip, t0)
|
||||||
|
lp.recordFailure(ip, t0)
|
||||||
|
// Come back after RESET_MS+ of quiet: next failure starts the streak over.
|
||||||
|
const later = t0 + lp.RESET_MS + 1
|
||||||
|
const delay = lp.recordFailure(ip, later)
|
||||||
|
assert.equal(delay, lp.BASE_MS) // back to 2^0
|
||||||
|
})
|
||||||
|
|
||||||
|
test('backoffGuard returns a generic 429 while locked out', async () => {
|
||||||
|
// Pre-lock this IP, then confirm the guard blocks it with a generic message.
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
lp.recordFailure('203.0.113.40') // lock the test client IP
|
||||||
|
const res = await fetch(`${app.url}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Forwarded-For': '203.0.113.40' },
|
||||||
|
})
|
||||||
|
assert.equal(res.status, 429)
|
||||||
|
const body = await res.json()
|
||||||
|
assert.match(body.message, /too many login attempts/i)
|
||||||
|
// Message must not reveal whether username or password was the problem.
|
||||||
|
assert.doesNotMatch(body.message, /password|username/i)
|
||||||
|
assert.ok(res.headers.get('retry-after'))
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hard rate limiter caps attempts per IP (429 after the cap)', async () => {
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
a.set('trust proxy', 1)
|
||||||
|
a.post('/login', loginLimiter, (req, res) => res.json({ ok: true }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const headers = { 'X-Forwarded-For': '203.0.113.41' }
|
||||||
|
let sawLimit = false
|
||||||
|
// Cap is 10/15min; the 11th should be blocked.
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const res = await fetch(`${app.url}/login`, { method: 'POST', headers })
|
||||||
|
if (res.status === 429) sawLimit = true
|
||||||
|
}
|
||||||
|
assert.ok(sawLimit, 'expected the hard limiter to return 429 after the cap')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
65
server/test/totp.test.js
Normal file
65
server/test/totp.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Set before requiring auth.js (reads JWT_SECRET at load) and db.js (builds the
|
||||||
|
// pool at load). Pointing the DB at a closed port stops the pool from eagerly
|
||||||
|
// opening idle connections that would keep this test process alive — none of
|
||||||
|
// these tests touch the database.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const speakeasy = require('speakeasy')
|
||||||
|
|
||||||
|
const totp = require('../src/utils/totp')
|
||||||
|
const { needsTotp } = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const { signTotpChallenge, verifyTotpChallenge, getUserFromRequest } = require('../src/utils/auth')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
test('needsTotp: disabled user does not require a second factor', () => {
|
||||||
|
assert.equal(needsTotp({ id: 1, totp_enabled: 0 }), false)
|
||||||
|
assert.equal(needsTotp({ id: 1 }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('needsTotp: enabled user requires a second factor', () => {
|
||||||
|
assert.equal(needsTotp({ id: 1, totp_enabled: 1 }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verifyCode accepts a current code and rejects a wrong/absent one', () => {
|
||||||
|
const { base32 } = totp.generateSecret('alice')
|
||||||
|
const good = speakeasy.totp({ secret: base32, encoding: 'base32' })
|
||||||
|
assert.equal(totp.verifyCode(base32, good), true)
|
||||||
|
assert.equal(totp.verifyCode(base32, '000000'), false)
|
||||||
|
assert.equal(totp.verifyCode(base32, ''), false)
|
||||||
|
assert.equal(totp.verifyCode(null, good), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateSecret yields a base32 secret and an otpauth URL', () => {
|
||||||
|
const s = totp.generateSecret('bob')
|
||||||
|
assert.ok(s.base32 && s.base32.length >= 16)
|
||||||
|
assert.match(s.otpauthUrl, /^otpauth:\/\/totp\//)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('qrDataUrl renders the otpauth URL to a PNG data URL', async () => {
|
||||||
|
const s = totp.generateSecret('carol')
|
||||||
|
const dataUrl = await totp.qrDataUrl(s.otpauthUrl)
|
||||||
|
assert.match(dataUrl, /^data:image\/png;base64,/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The password-verified challenge must never work as a real session token.
|
||||||
|
test('TOTP challenge token is not accepted as a session', () => {
|
||||||
|
const token = signTotpChallenge({ id: 42 })
|
||||||
|
// Valid as a challenge...
|
||||||
|
const challenge = verifyTotpChallenge(token)
|
||||||
|
assert.equal(challenge.id, 42)
|
||||||
|
// ...but rejected as a session (stage-tagged) when presented as a cookie/bearer.
|
||||||
|
const req = { cookies: {}, headers: { authorization: `Bearer ${token}` } }
|
||||||
|
assert.equal(getUserFromRequest(req), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a normal session token is not accepted as a TOTP challenge', () => {
|
||||||
|
const { signToken } = require('../src/utils/auth')
|
||||||
|
const session = signToken({ id: 7, username: 'x', role: 'admin' })
|
||||||
|
assert.equal(verifyTotpChallenge(session), null)
|
||||||
|
})
|
||||||
89
server/test/trustProxy.test.js
Normal file
89
server/test/trustProxy.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { parseTrustProxy, applyTrustProxy } = require('../src/utils/trustProxy')
|
||||||
|
const { startApp } = require('./_helper')
|
||||||
|
|
||||||
|
test('parseTrustProxy: default (unset/empty) is a single hop', () => {
|
||||||
|
assert.equal(parseTrustProxy(''), 1)
|
||||||
|
assert.equal(parseTrustProxy(undefined), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseTrustProxy: integer hop count', () => {
|
||||||
|
assert.equal(parseTrustProxy('2'), 2)
|
||||||
|
assert.equal(parseTrustProxy('0'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseTrustProxy: "false" disables proxy trust', () => {
|
||||||
|
assert.equal(parseTrustProxy('false'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseTrustProxy: blanket "true" is rejected and coerced to 1 (anti-spoof)', () => {
|
||||||
|
assert.equal(parseTrustProxy('true'), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseTrustProxy: CSV of IPs/CIDRs becomes an array; single stays a string', () => {
|
||||||
|
assert.deepEqual(parseTrustProxy('10.0.0.0/8, 172.18.0.1'), ['10.0.0.0/8', '172.18.0.1'])
|
||||||
|
assert.equal(parseTrustProxy('172.18.0.1'), '172.18.0.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applyTrustProxy: with 1 hop, req.ip reflects X-Forwarded-For client', async () => {
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
applyTrustProxy(a, '1')
|
||||||
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.7' } })
|
||||||
|
const body = await res.json()
|
||||||
|
assert.equal(body.ip, '203.0.113.7')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applyTrustProxy: pinned to the peer IP, XFF from that peer is trusted', async () => {
|
||||||
|
// Mirrors the production setup: TRUST_PROXY = ptero's LAN IP. Here the test
|
||||||
|
// client's peer address is loopback, so pin to loopback and confirm XFF wins.
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
applyTrustProxy(a, '127.0.0.1')
|
||||||
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.9' } })
|
||||||
|
const body = await res.json()
|
||||||
|
assert.equal(body.ip, '203.0.113.9')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applyTrustProxy: pinned to a DIFFERENT IP, XFF from this peer is NOT trusted', async () => {
|
||||||
|
// If ptero's IP is pinned but the connection comes from some other host, its
|
||||||
|
// X-Forwarded-For is ignored — nothing else on the LAN can spoof a client IP.
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
applyTrustProxy(a, '10.11.12.13') // not the loopback peer this test connects from
|
||||||
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.9' } })
|
||||||
|
const body = await res.json()
|
||||||
|
assert.notEqual(body.ip, '203.0.113.9')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applyTrustProxy: with false, a forged X-Forwarded-For is ignored', async () => {
|
||||||
|
const app = await startApp((a) => {
|
||||||
|
applyTrustProxy(a, 'false')
|
||||||
|
a.get('/ip', (req, res) => res.json({ ip: req.ip }))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${app.url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.7' } })
|
||||||
|
const body = await res.json()
|
||||||
|
// The spoofed client IP must NOT be trusted — req.ip stays the loopback peer.
|
||||||
|
assert.notEqual(body.ip, '203.0.113.7')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user