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

128 lines
4.5 KiB
Python

"""
Rate Limiting Middleware for Production Security
Protects API endpoints from abuse and DDoS attacks
"""
import os
import time
import logging
from typing import Callable
from fastapi import Request, HTTPException, status
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from database.connection import redis_client
logger = logging.getLogger(__name__)
# Resolve the Redis storage URI from REDIS_URL (set by docker-compose / k8s
# ConfigMap). Falls back to the compose service hostname for backward
# compatibility when REDIS_URL is unset.
_REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379").rstrip("/")
_LIMITER_STORAGE_URI = f"{_REDIS_URL}/0" if "/" not in _REDIS_URL.split("//", 1)[-1] else _REDIS_URL
limiter = Limiter(
key_func=get_remote_address,
storage_uri=_LIMITER_STORAGE_URI,
default_limits=["1000/hour"], # Default global limit
retry_after=lambda name, t: int(t) + 10
)
# Custom rate limit exceeded handler
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
"""Custom handler for rate limit exceeded"""
logger.warning(f"Rate limit exceeded for IP: {get_remote_address(request)}")
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"error": "Rate limit exceeded",
"message": f"Too many requests. Limit: {exc.detail}",
"retry_after": exc.retry_after,
"ip": get_remote_address(request)
}
)
# Advanced rate limiting for sensitive endpoints
class AdvancedRateLimiter:
"""Advanced rate limiting with different limits for different endpoint types"""
@staticmethod
def get_auth_limiter():
"""Stricter limits for authentication endpoints"""
return limiter.limit("10/minute")
@staticmethod
def get_config_limiter():
"""Moderate limits for configuration changes"""
return limiter.limit("100/hour")
@staticmethod
def get_read_limiter():
"""Higher limits for read-only endpoints"""
return limiter.limit("500/hour")
@staticmethod
def get_apply_limiter():
"""Very strict limits for apply changes (critical operations)"""
return limiter.limit("10/hour")
# IP-based suspicious activity detection
class SecurityMonitor:
"""Monitor and block suspicious IP addresses"""
@staticmethod
async def is_ip_blocked(ip: str) -> bool:
"""Check if IP is blocked"""
try:
return bool(redis_client.get(f"blocked_ip:{ip}"))
except:
return False
@staticmethod
async def block_ip(ip: str, duration: int = 3600):
"""Block IP address for specified duration (default 1 hour)"""
try:
redis_client.setex(f"blocked_ip:{ip}", duration, "blocked")
logger.warning(f"IP {ip} blocked for {duration} seconds")
except Exception as e:
logger.error(f"Failed to block IP {ip}: {e}")
@staticmethod
async def track_failed_attempts(ip: str) -> int:
"""Track failed authentication attempts"""
try:
key = f"failed_attempts:{ip}"
count = redis_client.incr(key)
if count == 1:
redis_client.expire(key, 900) # 15 minutes
# Auto-block after 5 failed attempts
if count >= 5:
await SecurityMonitor.block_ip(ip, 3600) # Block for 1 hour
logger.warning(f"IP {ip} auto-blocked after {count} failed attempts")
return count
except Exception as e:
logger.error(f"Failed to track attempts for IP {ip}: {e}")
return 0
# Middleware for IP blocking
async def ip_blocking_middleware(request: Request, call_next: Callable):
"""Middleware to check for blocked IPs"""
client_ip = get_remote_address(request)
if await SecurityMonitor.is_ip_blocked(client_ip):
logger.warning(f"Blocked IP {client_ip} attempted access")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={
"error": "IP blocked",
"message": "Your IP address has been temporarily blocked due to suspicious activity",
"ip": client_ip
}
)
return await call_next(request)