mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +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
151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""Backwards-compatibility regression tests for the MFA rollout (Issue #18).
|
|
|
|
These tests don't hit a real database — they exercise the authoritative
|
|
contract surfaces (login response shape, auth_middleware behaviour) using
|
|
mocks where needed so the suite stays fast and deterministic.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
class TestAuthMiddlewareUnchanged:
|
|
"""auth_middleware MUST NOT look for MFA claims — Madde 2 of the plan."""
|
|
|
|
def test_decoder_imports_without_mfa_dependencies(self):
|
|
import auth_middleware
|
|
# The middleware's verification function exists and is callable.
|
|
assert callable(getattr(auth_middleware, "get_current_user_from_token", None))
|
|
|
|
def test_middleware_source_has_no_mfa_claim_check(self):
|
|
"""The middleware source must not reference ``mfa`` claims directly."""
|
|
with open(
|
|
os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"auth_middleware.py",
|
|
),
|
|
"r",
|
|
encoding="utf-8",
|
|
) as fh:
|
|
source = fh.read()
|
|
# Allow incidental occurrences (e.g. comments); but never a claim lookup.
|
|
assert "payload.get('mfa'" not in source
|
|
assert 'payload.get("mfa"' not in source
|
|
assert "claims['mfa'" not in source
|
|
assert 'claims["mfa"' not in source
|
|
|
|
|
|
class TestMfaModelsCoexistWithUserModels:
|
|
def test_models_user_module_unchanged_pydantic_shape(self):
|
|
from models import user as user_mod
|
|
# Ensure that User / UserUpdate / LoginRequest still load and still
|
|
# don't expose mfa-related fields (kept in models.mfa).
|
|
for cls in (user_mod.User, user_mod.UserUpdate, user_mod.LoginRequest):
|
|
fields = set(cls.model_fields.keys())
|
|
assert not {"mfa_enabled", "mfa_required", "mfa_token"} & fields, (
|
|
f"{cls.__name__} unexpectedly exposes MFA field; should stay byte-identical."
|
|
)
|
|
|
|
def test_models_mfa_module_exposes_expected_models(self):
|
|
from models import mfa as mfa_mod
|
|
for name in (
|
|
"MfaVerifyRequest",
|
|
"MfaEnrollStartResponse",
|
|
"MfaEnrollConfirmRequest",
|
|
"MfaEnrollConfirmResponse",
|
|
"MfaDisableRequest",
|
|
"MfaRegenerateBackupRequest",
|
|
"MfaRegenerateBackupResponse",
|
|
"MfaAdminResetRequest",
|
|
"MfaAdminResetAllRequest",
|
|
"MfaStatusResponse",
|
|
):
|
|
assert hasattr(mfa_mod, name), f"Missing model: {name}"
|
|
|
|
|
|
class TestRouterIncluded:
|
|
def test_main_includes_mfa_router(self):
|
|
with open(
|
|
os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"main.py",
|
|
),
|
|
"r",
|
|
encoding="utf-8",
|
|
) as fh:
|
|
source = fh.read()
|
|
assert "from routers.mfa import router as mfa_router" in source
|
|
assert "app.include_router(mfa_router)" in source
|
|
|
|
|
|
class TestLoginResponseShapeForNonMfaUser:
|
|
"""When MFA columns are missing or mfa_enabled=FALSE, /login returns the
|
|
pre-MFA response shape — no ``mfa_required`` / ``mfa_token`` keys leak through.
|
|
"""
|
|
|
|
def test_login_without_mfa_returns_legacy_shape(self):
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
|
|
client = TestClient(app)
|
|
|
|
async def _fetch_mfa_state_none(conn, user_id):
|
|
return None
|
|
|
|
async def _no_log(*args, **kwargs):
|
|
return None
|
|
|
|
fake_user = {
|
|
"id": 1,
|
|
"username": "admin",
|
|
"email": "admin@example.com",
|
|
"password_hash": "$2b$12$placeholder",
|
|
"is_active": True,
|
|
"role": "admin",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
"last_login_at": None,
|
|
}
|
|
|
|
mock_conn = MagicMock()
|
|
mock_conn.fetchrow = AsyncMock(return_value=fake_user)
|
|
mock_conn.fetch = AsyncMock(return_value=[])
|
|
mock_conn.execute = AsyncMock(return_value=None)
|
|
|
|
async def _get_conn():
|
|
return mock_conn
|
|
|
|
async def _close(conn):
|
|
return None
|
|
|
|
with patch(
|
|
"routers.auth.get_database_connection", _get_conn
|
|
), patch("routers.auth.close_database_connection", _close), patch(
|
|
"routers.auth._fetch_mfa_state", _fetch_mfa_state_none
|
|
), patch("routers.auth.log_user_activity", _no_log), patch(
|
|
"bcrypt.checkpw", return_value=True
|
|
):
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
json={"username": "admin", "password": "anything"},
|
|
)
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert "access_token" in body
|
|
assert "token_type" in body
|
|
assert "expires_in" in body
|
|
assert "user" in body
|
|
assert "roles" in body
|
|
assert "permissions" in body
|
|
# CRITICAL — pre-MFA contract must not be polluted with MFA fields.
|
|
assert "mfa_required" not in body
|
|
assert "mfa_token" not in body
|
|
assert "methods" not in body
|