fix(security): harden 2FA setup, OIDC default role, and rate-limit fallback (v0.17.3-beta)

Address the actionable findings from an adversarial review:

- 2FA enrollment now requires the current password (/totp/setup), so a
  hijacked session or XSS can't enable TOTP and lock the real user out.
  SSO-only accounts (no local password) are told to set one first. Frontend
  collects the password before starting setup.
- OIDC config rejects default_role='admin': auto-provisioning unmatched users
  straight to admin is almost never intended — admin must come from an explicit
  group → role mapping.
- Rate limiter no longer fails fully open on a Redis outage. Redis calls are
  bounded by an 800ms timeout (the client uses maxRetriesPerRequest:null, so
  commands would otherwise hang, not throw), and on failure a per-process
  in-memory sliding-window fallback keeps /login and other limited endpoints
  throttled. Verified with Redis stopped: attempts throttle (429) instead of
  hanging or passing unlimited.

Findings intentionally not actioned: the "API-token auth is fire-and-forget"
report was a false positive (Express awaits next(), which is called inside the
promise), and the webhook DB-pool and JWT-revocation items are either
overstated or a separate architectural decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-29 15:21:04 -07:00
co-authored by Claude Opus 4.8
parent b3ba2b98dc
commit 28701cfc67
9 changed files with 103 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.17.2-beta",
"version": "0.17.3-beta",
"private": true,
"type": "module",
"scripts": {
+16 -3
View File
@@ -137,6 +137,7 @@ export default function SettingsPage() {
// ─── TOTP / 2FA ────────────────────────────────────────────────────────────
const [totpSetupData, setTotpSetupData] = useState<{ secret: string; uri: string; qr: string } | null>(null);
const [totpConfirmCode, setTotpConfirmCode] = useState('');
const [totpSetupPassword, setTotpSetupPassword] = useState('');
const [totpDisablePassword, setTotpDisablePassword] = useState('');
const [totpMsg, setTotpMsg] = useState('');
const [totpError, setTotpError] = useState('');
@@ -148,8 +149,8 @@ export default function SettingsPage() {
});
const startTotpSetupMutation = useMutation({
mutationFn: () => authApi.totpSetup(),
onSuccess: (res) => { setTotpSetupData(res.data); setTotpConfirmCode(''); setTotpError(''); },
mutationFn: () => authApi.totpSetup(totpSetupPassword),
onSuccess: (res) => { setTotpSetupData(res.data); setTotpConfirmCode(''); setTotpSetupPassword(''); setTotpError(''); },
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setTotpError(msg || 'Failed to start TOTP setup');
@@ -1157,14 +1158,26 @@ export default function SettingsPage() {
</div>
) : (
/* Not enabled, not in setup */
<div className="space-y-3">
<div>
<label className="label">Confirm your password to set up 2FA</label>
<input
type="password"
className="input max-w-xs"
value={totpSetupPassword}
onChange={(e) => setTotpSetupPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<button
onClick={() => startTotpSetupMutation.mutate()}
disabled={startTotpSetupMutation.isPending}
disabled={!totpSetupPassword || startTotpSetupMutation.isPending}
className="btn-primary"
>
<ShieldCheck className="w-4 h-4" />
Set up 2FA
</button>
</div>
)}
</div>
</div>
+2 -2
View File
@@ -54,8 +54,8 @@ export const authApi = {
),
totpVerify: (totp_token: string, code: string) =>
api.post<{ token: string; user: import('../types').User }>('/auth/totp/verify', { totp_token, code }),
totpSetup: () =>
api.post<{ secret: string; uri: string; qr: string }>('/auth/totp/setup'),
totpSetup: (password: string) =>
api.post<{ secret: string; uri: string; qr: string }>('/auth/totp/setup', { password }),
totpConfirm: (code: string) =>
api.post<{ ok: boolean }>('/auth/totp/confirm', { code }),
totpDisable: (password: string) =>
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.17.2 Beta';
export const APP_VERSION = 'v0.17.3 Beta';