mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 15:45:11 +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
128 lines
4.5 KiB
Python
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) |