mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 23:55:13 +00:00
bd6a31cb0d
Adds opt-in TOTP-based Multi-Factor Authentication that is fully
backwards compatible with existing logins. Operators choose to enable
MFA per account; nothing changes for users who do not opt in.
Highlights
==========
* RFC 6238 TOTP (6 digits, 30s period, SHA1) with ±30s skew tolerance,
compatible with Microsoft / Google Authenticator, Authy, Duo, 1Password.
* Per-step replay protection (`mfa_last_used_totp_step`) so a captured
code cannot be reused inside the same window.
* Fernet-encrypted TOTP secrets at rest, key resolution via
`MFA_ENCRYPTION_KEY` env (HKDF-derived from `SECRET_KEY` as fallback).
* 10 single-use, bcrypt-hashed backup codes per user, formatted
`XXXX-YYYY` from a confusion-free alphabet (no 0/O/1/I/L).
* Two-step login flow: `POST /api/auth/login` returns `mfa_required`
+ `mfa_token`, then `POST /api/auth/login/mfa-verify` accepts a TOTP
code OR a backup code. JWT is minted only after MFA succeeds.
* Self-service: users enable / disable MFA from their own row in the
Users page; admins reset (single user or bulk) but never enable on
behalf of someone else (matches AWS IAM / GitHub / Google Workspace).
* Bulk emergency reset CLI: `scripts/admin-mfa-reset-all.sh`.
Security hardening
==================
* Atomic transactions with `SELECT … FOR UPDATE` on `mfa_pending_logins`
and `users` rows so concurrent verify / enroll calls cannot race.
* `/api/mfa/enroll/start` refuses re-enrollment when MFA is already on
(prevents silent secret rotation via a stolen JWT).
* Pydantic `ValidationError` messages are sanitized before reaching the
audit log so request bodies (TOTP / backup codes in flight) never
appear in plaintext.
* Slowapi rate limits are per-USER, not per-IP, with a trusted-proxy
XFF strategy so a single ingress address cannot exhaust the bucket
for thousands of operators (`MFA_TRUSTED_PROXY_CIDRS`,
`MFA_RATE_LIMIT_*` env-overridable).
* Login query now scopes to `is_active = TRUE` so a soft-deleted row
with the same username can no longer occlude the active user
(also closes a small account-enumeration side channel).
Database
========
Additive migrations (idempotent `ADD COLUMN IF NOT EXISTS`,
`CREATE TABLE IF NOT EXISTS`):
- users: mfa_enabled, mfa_method, mfa_secret_encrypted,
mfa_enrolled_at, mfa_last_used_at, mfa_last_used_totp_step
- mfa_backup_codes (user_id ON DELETE CASCADE)
- mfa_pending_logins (user_id ON DELETE CASCADE, challenge_token,
attempts, expires_at)
- mfa_pending_enrollments (user_id ON DELETE CASCADE)
Frontend
========
* Login page becomes a 3-phase state machine
(credentials → MFA → submitting); legacy single-step login is
preserved for users who haven't enrolled.
* New MFAEnrollModal (3-step wizard: QR + secret → verify → backup
codes) using `qrcode.react`.
* Users page shows MFA column + per-row enable/disable/reset actions.
Admins viewing other users with MFA off see a non-actionable info
icon explaining that only the user themselves can enable MFA.
Deployment
==========
* `MFA_ENCRYPTION_KEY` is added to `k8s/manifests/03-secrets.yaml` as
a placeholder; `SECRET_KEY` is also placeholder-ized so both are
injected by the existing pipeline pattern (sed-replace + apply).
* No new build-time env vars are required for the frontend. The SPA
uses `window.location.host` for `/api/*` and is routed by the
existing nginx ingress configuration.
* `frontend/.dockerignore` ensures host `.env*` files cannot bleed
into the production bundle.
Tests
=====
* New unit suites:
- `test_mfa_service.py` (TOTP, encryption, backup codes)
- `test_mfa_backwards_compat.py` (regression — non-MFA flow unchanged)
- `test_mfa_rate_limits.py` (env override + dataclass immutability)
- `test_mfa_rate_limit_key.py` (JWT key, trusted-proxy XFF, fallbacks)
* All existing 1000+ unit tests continue to pass.
Documentation
=============
* README MFA section (overview, day-to-day operations, emergency
reset CLI, env variables, rate-limit tuning).
* `scripts/README.md` documents the bulk reset script.
Issue: #18
80 lines
2.9 KiB
Python
80 lines
2.9 KiB
Python
"""MFA rate-limit configuration (env-overridable).
|
|
|
|
Best-practice pattern:
|
|
- Secure-by-default values live in code (kept in sync with the threat model).
|
|
- Operations can override per-environment via env vars (ConfigMap on K8s)
|
|
WITHOUT a code change / re-release.
|
|
- All limits funnel through a single named constant so the decorator stays
|
|
declarative (``@limiter.limit(MFA_LIMITS.enroll_start)``).
|
|
|
|
Env-var precedence::
|
|
|
|
MFA_RATE_LIMIT_<NAME> > default in code
|
|
|
|
slowapi limit string syntax: ``<count>/<period>`` where period is
|
|
``second|minute|hour|day``. Example: ``"5/minute"``.
|
|
|
|
NOTE: slowapi binds limits at import time. A change to an env var requires a
|
|
backend restart (rolling restart on K8s, ``docker compose restart backend``
|
|
locally). This is consistent with how ``SECRET_KEY`` / ``MFA_ENCRYPTION_KEY``
|
|
behave.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# slowapi limit-string format guard. Keeps a typo from silently disabling
|
|
# rate-limiting at process start.
|
|
_LIMIT_RE = re.compile(r"^\d+/(second|minute|hour|day)$")
|
|
|
|
|
|
def _env(name: str, default: str) -> str:
|
|
"""Read ``MFA_RATE_LIMIT_<NAME>``; fall back to ``default``.
|
|
|
|
Validates the limit string. On bad input, logs a warning and returns the
|
|
secure default instead of crashing the process.
|
|
"""
|
|
value = os.getenv(f"MFA_RATE_LIMIT_{name}", default).strip()
|
|
if not _LIMIT_RE.match(value):
|
|
logger.warning(
|
|
"MFA_RATE_LIMIT_%s='%s' is not a valid slowapi limit string "
|
|
"(expected '<n>/<second|minute|hour|day>'); using default '%s'.",
|
|
name, value, default,
|
|
)
|
|
return default
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MfaRateLimits:
|
|
"""Aggregate of MFA endpoint rate-limit strings (slowapi format).
|
|
|
|
Defaults assume the rate-limit ``key_func`` is ``mfa_rate_limit_key``
|
|
(user-aware + ingress-aware), NOT raw IP. Per-user buckets are safe to
|
|
keep generous because a misbehaving user only burns their own quota and
|
|
cannot starve the rest of the org. If you re-key on raw IP, retighten
|
|
these values (see README + ``MFA_RATE_LIMIT_<NAME>`` env overrides).
|
|
"""
|
|
|
|
# Enrollment lifecycle — per-user buckets, large enough for org-wide rollout
|
|
enroll_start: str = _env("ENROLL_START", "10/minute")
|
|
enroll_confirm: str = _env("ENROLL_CONFIRM", "10/minute")
|
|
|
|
# Self-service maintenance
|
|
disable: str = _env("DISABLE", "10/minute")
|
|
regenerate_backup_codes: str = _env("REGENERATE_BACKUP_CODES", "5/hour")
|
|
|
|
# Admin operations (per-admin bucket; bulk reset stays tight because
|
|
# it is an emergency-only flow).
|
|
admin_reset: str = _env("ADMIN_RESET", "60/hour")
|
|
admin_reset_all: str = _env("ADMIN_RESET_ALL", "1/day")
|
|
|
|
|
|
# Module-level singleton — import this from routers/mfa.py.
|
|
MFA_LIMITS = MfaRateLimits()
|