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

239 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MFA (TOTP + backup codes) service layer — Issue #18, v1.6.0.
Owns the cryptographic and persistence-shape concerns of multi-factor auth:
- TOTP secret generation / verification with replay protection (RFC 6238)
- Backup code generation, hashing (bcrypt) and atomic single-use consumption
- Fernet-based encryption of TOTP secrets at rest
Strictly no logging of secrets — only metadata (lengths, counts) is logged.
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import secrets as _secrets
import time
from typing import List, Optional, Tuple
from urllib.parse import quote
import bcrypt
import pyotp
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from config import SECRET_KEY
logger = logging.getLogger(__name__)
# RFC 6238 parameters — kept conservative for widest authenticator app compatibility.
TOTP_DIGITS = 6
TOTP_PERIOD = 30
TOTP_DIGEST = "sha1"
TOTP_VALID_WINDOW_STEPS = 1 # ±1 step (±30s) tolerance
# Backup code spec (Plan section 5).
# Alphabet drops the confusing pairs: 0/O, 1/I, L. Resulting size is 31, which
# still yields 31**8 ≈ 8.5×10^11 combinations per half — far beyond brute-force.
BACKUP_CODE_COUNT = 10
BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
BACKUP_CODE_HALF_LEN = 4 # XXXX-YYYY
# OTP URI defaults.
DEFAULT_ISSUER = "HAProxy OpenManager"
ACCOUNT_LABEL_DOMAIN_FALLBACK = "haproxy-openmanager"
# ---------------------------------------------------------------------------
# Fernet key resolution
# ---------------------------------------------------------------------------
_fernet_instance: Optional[Fernet] = None
def _resolve_fernet_key() -> bytes:
"""Resolve the Fernet key, preferring the explicit env var.
Falls back to HKDF over SECRET_KEY with a versioned info string so a future
rotation can be expressed by bumping the version suffix.
"""
explicit = os.getenv("MFA_ENCRYPTION_KEY", "").strip()
if explicit:
try:
Fernet(explicit.encode())
return explicit.encode()
except Exception as exc:
logger.error("MFA_ENCRYPTION_KEY env var present but invalid: %s", exc)
# fall through to HKDF derivation rather than crashing the app
logger.warning(
"MFA_ENCRYPTION_KEY env var not set or invalid; deriving from SECRET_KEY (v1). "
"Set an explicit MFA_ENCRYPTION_KEY in production to enable key rotation."
)
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"mfa-totp-secret-v1",
)
derived = hkdf.derive(SECRET_KEY.encode("utf-8"))
return base64.urlsafe_b64encode(derived)
def _get_fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(_resolve_fernet_key())
return _fernet_instance
def reset_fernet_for_tests() -> None:
"""Test-only hook to force re-resolution of the Fernet key after env mutation."""
global _fernet_instance
_fernet_instance = None
# ---------------------------------------------------------------------------
# TOTP secrets
# ---------------------------------------------------------------------------
def generate_totp_secret() -> str:
"""Return a fresh base32 TOTP secret (32 chars)."""
return pyotp.random_base32()
def encrypt_secret(secret_plain: str) -> str:
"""Fernet-encrypt the base32 secret. Returns str for direct DB storage."""
token = _get_fernet().encrypt(secret_plain.encode("utf-8"))
return token.decode("utf-8")
def decrypt_secret(secret_encrypted: str) -> Optional[str]:
"""Decrypt a previously stored secret. Returns None when the token can't be
decrypted (e.g. key rotated without re-enroll). Never raises to the caller.
"""
try:
return _get_fernet().decrypt(secret_encrypted.encode("utf-8")).decode("utf-8")
except InvalidToken:
logger.warning("Failed to decrypt MFA secret (invalid Fernet token)")
return None
except Exception as exc:
logger.error("Unexpected error decrypting MFA secret: %s", exc)
return None
def build_otpauth_uri(account_label: str, secret_plain: str, issuer: str = DEFAULT_ISSUER) -> str:
"""Build an otpauth:// URI that all major authenticator apps accept.
Format: otpauth://totp/<issuer>:<account>?secret=<b32>&issuer=<issuer>&algorithm=SHA1&digits=6&period=30
"""
issuer_q = quote(issuer, safe="")
label = f"{issuer}:{account_label}"
label_q = quote(label, safe=":@")
return (
f"otpauth://totp/{label_q}?secret={secret_plain}"
f"&issuer={issuer_q}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_PERIOD}"
)
def build_account_label(username: str, hostname_hint: Optional[str] = None) -> str:
"""Compose the per-user otpauth label, respecting env > hostname > fallback."""
domain = (
os.getenv("MFA_ACCOUNT_LABEL_DOMAIN", "").strip()
or (hostname_hint or "").strip()
or ACCOUNT_LABEL_DOMAIN_FALLBACK
)
return f"{username}@{domain}"
def verify_totp_with_replay_guard(
secret_plain: str,
code: str,
last_used_step: Optional[int],
) -> Tuple[bool, Optional[int]]:
"""Verify a 6-digit TOTP code with explicit per-step replay protection.
Returns (success, step_consumed). Caller persists the consumed step on success.
Implementation notes:
- pyotp.TOTP.at(seconds_since_epoch) — to target step N we pass step*PERIOD.
- secrets.compare_digest is used for constant-time comparison.
- Replay guard rejects codes whose step is <= the previously consumed step.
"""
if not secret_plain or not code:
return (False, None)
code = code.strip()
if len(code) != TOTP_DIGITS or not code.isdigit():
return (False, None)
totp = pyotp.TOTP(secret_plain, digits=TOTP_DIGITS, interval=TOTP_PERIOD, digest=TOTP_DIGEST)
now = int(time.time())
current_step = now // TOTP_PERIOD
for offset in (0, -1, 1):
step = current_step + offset
expected = totp.at(step * TOTP_PERIOD)
if len(expected) == len(code) and _secrets.compare_digest(expected, code):
if last_used_step is not None and step <= last_used_step:
return (False, None)
return (True, step)
return (False, None)
# ---------------------------------------------------------------------------
# Backup codes
# ---------------------------------------------------------------------------
def generate_backup_codes(count: int = BACKUP_CODE_COUNT) -> List[str]:
"""Return ``count`` plain-text backup codes formatted as ``XXXX-YYYY``."""
codes: List[str] = []
for _ in range(count):
left = "".join(_secrets.choice(BACKUP_CODE_ALPHABET) for _ in range(BACKUP_CODE_HALF_LEN))
right = "".join(_secrets.choice(BACKUP_CODE_ALPHABET) for _ in range(BACKUP_CODE_HALF_LEN))
codes.append(f"{left}-{right}")
return codes
def normalize_backup_code(user_input: str) -> str:
"""Canonical form for comparison: uppercase, strip dashes/spaces."""
if not user_input:
return ""
return user_input.strip().upper().replace("-", "").replace(" ", "")
async def _hash_one_backup_code(code_plain: str) -> str:
"""Bcrypt-hash a single backup code on a worker thread."""
normalized = normalize_backup_code(code_plain)
hashed = await asyncio.to_thread(bcrypt.hashpw, normalized.encode("utf-8"), bcrypt.gensalt())
return hashed.decode("utf-8")
async def hash_backup_codes(codes_plain: List[str]) -> List[str]:
"""Hash backup codes in parallel (each bcrypt op runs in its own thread)."""
return await asyncio.gather(*(_hash_one_backup_code(c) for c in codes_plain))
async def check_backup_code(user_input: str, code_hash: str) -> bool:
"""Run a single bcrypt verify on the worker pool."""
normalized = normalize_backup_code(user_input)
if not normalized:
return False
return await asyncio.to_thread(
bcrypt.checkpw, normalized.encode("utf-8"), code_hash.encode("utf-8")
)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def generate_challenge_token() -> str:
"""64-char hex challenge token for /api/auth/login → /mfa-verify hand-off."""
return _secrets.token_hex(32)