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
391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""
|
|
Production-Ready Error Handling Middleware
|
|
Provides global exception handling, request/response logging, and error tracking
|
|
"""
|
|
|
|
import time
|
|
import logging
|
|
import traceback
|
|
from typing import Callable, Any
|
|
from fastapi import Request, Response, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.types import ASGIApp
|
|
|
|
from utils.logging_config import (
|
|
get_correlation_id, correlation_id_context, get_error_tracker,
|
|
log_api_request, log_api_response, log_with_correlation
|
|
)
|
|
|
|
logger = logging.getLogger("haproxy_openmanager.error_handler")
|
|
|
|
class GlobalExceptionHandler:
|
|
"""Global exception handler for comprehensive error management"""
|
|
|
|
@staticmethod
|
|
def create_error_response(
|
|
status_code: int,
|
|
message: str,
|
|
error_type: str = None,
|
|
correlation_id: str = None,
|
|
details: Any = None
|
|
) -> JSONResponse:
|
|
"""Create standardized error response"""
|
|
|
|
error_response = {
|
|
"error": {
|
|
"message": message,
|
|
"type": error_type or "ApplicationError",
|
|
"timestamp": time.time(),
|
|
"correlation_id": correlation_id or get_correlation_id()
|
|
}
|
|
}
|
|
|
|
if details and isinstance(details, dict):
|
|
error_response["error"]["details"] = details
|
|
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=error_response
|
|
)
|
|
|
|
@staticmethod
|
|
async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
|
"""Handle FastAPI HTTP exceptions"""
|
|
correlation_id = get_correlation_id()
|
|
|
|
# Log HTTP exception with more detail for debugging
|
|
log_level = "ERROR" if exc.status_code >= 500 else "WARNING"
|
|
log_with_correlation(
|
|
logger, log_level,
|
|
f"HTTP Exception: {exc.status_code} - {exc.detail}",
|
|
status_code=exc.status_code,
|
|
path=str(request.url.path),
|
|
method=request.method,
|
|
client_ip=request.client.host if request.client else "unknown",
|
|
correlation_id=correlation_id
|
|
)
|
|
|
|
# For 400 errors, also log at INFO level to ensure visibility
|
|
if exc.status_code == 400:
|
|
logger.info(f"🚫 CLIENT ERROR 400: {exc.detail} | Path: {request.url.path} | Method: {request.method} | IP: {request.client.host if request.client else 'unknown'}")
|
|
|
|
return GlobalExceptionHandler.create_error_response(
|
|
status_code=exc.status_code,
|
|
message=exc.detail,
|
|
error_type="HTTPException",
|
|
correlation_id=correlation_id
|
|
)
|
|
|
|
@staticmethod
|
|
async def handle_validation_error(request: Request, exc: Exception) -> JSONResponse:
|
|
"""Handle validation errors (Pydantic, FastAPI) with enhanced debugging for agent heartbeats"""
|
|
correlation_id = get_correlation_id()
|
|
|
|
# Extract validation details
|
|
if hasattr(exc, 'errors'):
|
|
validation_errors = exc.errors()
|
|
error_details = {
|
|
"validation_errors": [
|
|
{
|
|
"field": " -> ".join(str(loc) for loc in error.get("loc", [])),
|
|
"message": error.get("msg", ""),
|
|
"type": error.get("type", "")
|
|
}
|
|
for error in validation_errors
|
|
]
|
|
}
|
|
else:
|
|
error_details = {"raw_error": str(exc)}
|
|
|
|
# ENHANCED: For heartbeat endpoint, try to extract RAW body for debugging
|
|
raw_body_preview = None
|
|
agent_name = "unknown"
|
|
if "/heartbeat" in str(request.url.path):
|
|
try:
|
|
# Try to get raw body (might fail if already consumed)
|
|
raw_body = await request.body()
|
|
if raw_body:
|
|
raw_body_str = raw_body.decode('utf-8', errors='replace')
|
|
# Extract agent name from JSON if possible
|
|
import json
|
|
try:
|
|
body_json = json.loads(raw_body_str)
|
|
agent_name = body_json.get('name', 'unknown')
|
|
except:
|
|
# Try simple regex to extract name
|
|
import re
|
|
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', raw_body_str)
|
|
if name_match:
|
|
agent_name = name_match.group(1)
|
|
|
|
# Log first 500 characters for debugging (don't log full body - too large)
|
|
raw_body_preview = raw_body_str[:500] if len(raw_body_str) > 500 else raw_body_str
|
|
error_details["raw_body_preview"] = raw_body_preview
|
|
error_details["body_size_bytes"] = len(raw_body)
|
|
except Exception as body_error:
|
|
logger.debug(f"Could not extract raw body for debugging: {body_error}")
|
|
|
|
# Build a sanitized log summary. The raw `str(exc)` from Pydantic
|
|
# contains the user-supplied `input` value for each failed field —
|
|
# which leaks secrets like TOTP codes, backup codes, mfa_token, and
|
|
# passwords to plaintext logs. Use only field NAMES + types here;
|
|
# `validation_details` (already sanitized to {field, message, type})
|
|
# is attached separately for downstream structured logging.
|
|
_field_names = [
|
|
err.get("field", "unknown")
|
|
for err in error_details.get("validation_errors", [])
|
|
]
|
|
_err_count = len(error_details.get("validation_errors", []))
|
|
log_message = (
|
|
f"Validation error: {_err_count} field(s) failed validation: "
|
|
f"[{', '.join(_field_names)}]"
|
|
)
|
|
if agent_name != "unknown":
|
|
log_message = (
|
|
f"Agent '{agent_name}' heartbeat validation error: "
|
|
f"{_err_count} field(s) failed: [{', '.join(_field_names)}]"
|
|
)
|
|
|
|
# Log validation error with enhanced details
|
|
log_with_correlation(
|
|
logger, "WARNING",
|
|
log_message,
|
|
path=str(request.url.path),
|
|
method=request.method,
|
|
client_ip=request.client.host if request.client else "unknown",
|
|
validation_details=error_details,
|
|
agent_name=agent_name if agent_name != "unknown" else None
|
|
)
|
|
|
|
# CRITICAL: For JSON decode errors at specific position, log exact context
|
|
for error in error_details.get("validation_errors", []):
|
|
if error.get("type") == "json_invalid" and "body ->" in error.get("field", ""):
|
|
logger.error(
|
|
f"CRITICAL JSON PARSE ERROR for agent '{agent_name}': {error.get('message')} "
|
|
f"at position {error.get('field')} | "
|
|
f"Body preview (first 500 chars): {raw_body_preview[:500] if raw_body_preview else 'N/A'}"
|
|
)
|
|
|
|
return GlobalExceptionHandler.create_error_response(
|
|
status_code=422,
|
|
message="Validation error in request data",
|
|
error_type="ValidationError",
|
|
correlation_id=correlation_id,
|
|
details=error_details
|
|
)
|
|
|
|
@staticmethod
|
|
async def handle_database_error(request: Request, exc: Exception) -> JSONResponse:
|
|
"""Handle database-related errors"""
|
|
correlation_id = get_correlation_id()
|
|
error_tracker = get_error_tracker()
|
|
|
|
# Track database error
|
|
error_tracker.track_error(exc, {
|
|
"category": "database",
|
|
"path": str(request.url.path),
|
|
"method": request.method
|
|
})
|
|
|
|
# Log database error with full traceback
|
|
log_with_correlation(
|
|
logger, "ERROR",
|
|
f"Database error: {str(exc)}",
|
|
path=str(request.url.path),
|
|
method=request.method,
|
|
client_ip=request.client.host if request.client else "unknown",
|
|
error_traceback=traceback.format_exc()
|
|
)
|
|
|
|
return GlobalExceptionHandler.create_error_response(
|
|
status_code=500,
|
|
message="Database operation failed",
|
|
error_type="DatabaseError",
|
|
correlation_id=correlation_id,
|
|
details={"category": "database", "recoverable": True}
|
|
)
|
|
|
|
@staticmethod
|
|
async def handle_generic_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
"""Handle all other unhandled exceptions"""
|
|
correlation_id = get_correlation_id()
|
|
error_tracker = get_error_tracker()
|
|
|
|
# Track generic error
|
|
error_tracker.track_error(exc, {
|
|
"category": "application",
|
|
"path": str(request.url.path),
|
|
"method": request.method
|
|
})
|
|
|
|
# Log generic error with full context
|
|
log_with_correlation(
|
|
logger, "ERROR",
|
|
f"Unhandled exception: {str(exc)}",
|
|
path=str(request.url.path),
|
|
method=request.method,
|
|
client_ip=request.client.host if request.client else "unknown",
|
|
error_type=type(exc).__name__,
|
|
error_traceback=traceback.format_exc()
|
|
)
|
|
|
|
return GlobalExceptionHandler.create_error_response(
|
|
status_code=500,
|
|
message="Internal server error",
|
|
error_type=type(exc).__name__,
|
|
correlation_id=correlation_id,
|
|
details={"category": "application", "recoverable": False}
|
|
)
|
|
|
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""Middleware for comprehensive request/response logging and error handling"""
|
|
|
|
def __init__(self, app: ASGIApp, exclude_paths: list = None):
|
|
super().__init__(app)
|
|
self.exclude_paths = exclude_paths or ["/api/health/", "/docs", "/redoc", "/openapi.json"]
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
# Skip logging for excluded paths
|
|
if any(request.url.path.startswith(path) for path in self.exclude_paths):
|
|
return await call_next(request)
|
|
|
|
# Generate correlation ID for this request
|
|
correlation_id = get_correlation_id()
|
|
correlation_id_context.set(correlation_id)
|
|
|
|
# Start timing
|
|
start_time = time.time()
|
|
|
|
# Extract client information
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
user_agent = request.headers.get("user-agent", "unknown")
|
|
|
|
# Log incoming request
|
|
log_api_request(
|
|
logger,
|
|
method=request.method,
|
|
path=str(request.url.path),
|
|
client_ip=client_ip,
|
|
user_agent=user_agent,
|
|
query_params=dict(request.query_params) if request.query_params else None
|
|
)
|
|
|
|
try:
|
|
# Process request
|
|
response = await call_next(request)
|
|
|
|
# Calculate duration
|
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
|
|
# Log successful response
|
|
log_api_response(
|
|
logger,
|
|
method=request.method,
|
|
path=str(request.url.path),
|
|
status_code=response.status_code,
|
|
duration_ms=duration_ms,
|
|
client_ip=client_ip
|
|
)
|
|
|
|
# Add correlation ID to response headers
|
|
response.headers["X-Correlation-ID"] = correlation_id
|
|
|
|
return response
|
|
|
|
except HTTPException as exc:
|
|
# Handle HTTP exceptions
|
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
response = await GlobalExceptionHandler.handle_http_exception(request, exc)
|
|
response.headers["X-Correlation-ID"] = correlation_id
|
|
|
|
log_api_response(
|
|
logger,
|
|
method=request.method,
|
|
path=str(request.url.path),
|
|
status_code=exc.status_code,
|
|
duration_ms=duration_ms,
|
|
client_ip=client_ip
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as exc:
|
|
# Handle all other exceptions
|
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
|
|
# Categorize exception type
|
|
if "database" in str(exc).lower() or "connection" in str(exc).lower():
|
|
response = await GlobalExceptionHandler.handle_database_error(request, exc)
|
|
elif hasattr(exc, 'errors'): # Validation errors
|
|
response = await GlobalExceptionHandler.handle_validation_error(request, exc)
|
|
else:
|
|
response = await GlobalExceptionHandler.handle_generic_exception(request, exc)
|
|
|
|
response.headers["X-Correlation-ID"] = correlation_id
|
|
|
|
log_api_response(
|
|
logger,
|
|
method=request.method,
|
|
path=str(request.url.path),
|
|
status_code=response.status_code,
|
|
duration_ms=duration_ms,
|
|
client_ip=client_ip,
|
|
error=True
|
|
)
|
|
|
|
return response
|
|
|
|
class PerformanceMonitoringMiddleware(BaseHTTPMiddleware):
|
|
"""Middleware for performance monitoring and slow request detection"""
|
|
|
|
def __init__(self, app: ASGIApp, slow_request_threshold_ms: float = 1000):
|
|
super().__init__(app)
|
|
self.slow_request_threshold_ms = slow_request_threshold_ms
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
start_time = time.time()
|
|
|
|
response = await call_next(request)
|
|
|
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
|
|
# Log slow requests
|
|
if duration_ms > self.slow_request_threshold_ms:
|
|
log_with_correlation(
|
|
logger, "WARNING",
|
|
f"Slow request detected: {request.method} {request.url.path}",
|
|
method=request.method,
|
|
path=str(request.url.path),
|
|
duration_ms=duration_ms,
|
|
threshold_ms=self.slow_request_threshold_ms,
|
|
client_ip=request.client.host if request.client else "unknown"
|
|
)
|
|
|
|
# Add performance headers
|
|
response.headers["X-Response-Time"] = f"{duration_ms}ms"
|
|
|
|
return response
|
|
|
|
# Error statistics endpoint data
|
|
_error_stats = {"requests": 0, "errors": 0, "error_types": {}}
|
|
|
|
def get_error_statistics() -> dict:
|
|
"""Get current error statistics"""
|
|
global _error_stats
|
|
error_tracker = get_error_tracker()
|
|
|
|
return {
|
|
"total_requests": _error_stats["requests"],
|
|
"total_errors": _error_stats["errors"],
|
|
"error_rate": round(_error_stats["errors"] / max(_error_stats["requests"], 1) * 100, 2),
|
|
"error_types": error_tracker.get_error_summary(),
|
|
"timestamp": time.time()
|
|
}
|
|
|
|
def increment_request_stats(is_error: bool = False):
|
|
"""Increment request statistics"""
|
|
global _error_stats
|
|
_error_stats["requests"] += 1
|
|
if is_error:
|
|
_error_stats["errors"] += 1 |