Files
taylanbakircioglu bd6a31cb0d feat: v1.6.0 — Multi-Factor Authentication (Issue #18)
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
2026-05-19 04:35:16 +03:00

94 lines
3.0 KiB
Python

"""Tests for middleware.mfa_rate_limits — env-driven MFA rate-limit config."""
import importlib
import logging
import pytest
@pytest.fixture
def reload_module(monkeypatch):
"""Helper: reload the module after env mutation so dataclass defaults
pick up the new values."""
def _reload(**env):
for key in list(globals().get('_OVERRIDDEN_ENVS', set())):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
from middleware import mfa_rate_limits as m
return importlib.reload(m)
return _reload
def test_defaults_when_no_env(reload_module, monkeypatch):
"""No env var set → secure defaults applied (user-aware key assumption)."""
for key in (
"MFA_RATE_LIMIT_ENROLL_START",
"MFA_RATE_LIMIT_ENROLL_CONFIRM",
"MFA_RATE_LIMIT_DISABLE",
"MFA_RATE_LIMIT_REGENERATE_BACKUP_CODES",
"MFA_RATE_LIMIT_ADMIN_RESET",
"MFA_RATE_LIMIT_ADMIN_RESET_ALL",
):
monkeypatch.delenv(key, raising=False)
m = reload_module()
assert m.MFA_LIMITS.enroll_start == "10/minute"
assert m.MFA_LIMITS.enroll_confirm == "10/minute"
assert m.MFA_LIMITS.disable == "10/minute"
assert m.MFA_LIMITS.regenerate_backup_codes == "5/hour"
assert m.MFA_LIMITS.admin_reset == "60/hour"
assert m.MFA_LIMITS.admin_reset_all == "1/day"
def test_env_override_per_endpoint(reload_module):
m = reload_module(
MFA_RATE_LIMIT_ENROLL_START="100/hour",
MFA_RATE_LIMIT_ADMIN_RESET_ALL="3/day",
)
assert m.MFA_LIMITS.enroll_start == "100/hour"
assert m.MFA_LIMITS.admin_reset_all == "3/day"
# Untouched values still default.
assert m.MFA_LIMITS.disable == "10/minute"
@pytest.mark.parametrize(
"bad",
[
"totally-bogus",
"5/lightyear",
"abc/minute",
"5",
"/minute",
"5//minute",
"",
],
)
def test_invalid_format_falls_back_to_default(reload_module, caplog, bad):
with caplog.at_level(logging.WARNING, logger="middleware.mfa_rate_limits"):
m = reload_module(MFA_RATE_LIMIT_ENROLL_START=bad)
# Falls back to the secure default for enroll_start.
assert m.MFA_LIMITS.enroll_start == "10/minute"
assert any("not a valid slowapi limit string" in r.message for r in caplog.records)
def test_whitespace_around_value_is_tolerated(reload_module):
m = reload_module(MFA_RATE_LIMIT_DISABLE=" 30/minute ")
assert m.MFA_LIMITS.disable == "30/minute"
@pytest.mark.parametrize(
"valid",
["1/second", "100/minute", "1000/hour", "10/day"],
)
def test_all_valid_periods_accepted(reload_module, valid):
m = reload_module(MFA_RATE_LIMIT_DISABLE=valid)
assert m.MFA_LIMITS.disable == valid
def test_dataclass_is_frozen(reload_module):
"""Frozen dataclass guards against accidental mutation after import."""
m = reload_module()
with pytest.raises((AttributeError, Exception)):
m.MFA_LIMITS.disable = "999/second" # type: ignore[misc]