You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
325 lines
13 KiB
Python
325 lines
13 KiB
Python
import logging
|
|
import asyncio
|
|
import time
|
|
from src.context import set_request_id, reset_request_id, get_request_id
|
|
from contextvars import ContextVar
|
|
from os import path
|
|
from typing import Final, Optional
|
|
from uuid import uuid1
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, status
|
|
from src.auth.service import JWTBearer
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import ValidationError
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
from slowapi.middleware import SlowAPIMiddleware
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.ext.asyncio import async_scoped_session
|
|
from sqlalchemy.orm import scoped_session
|
|
from starlette.middleware.base import (BaseHTTPMiddleware,
|
|
RequestResponseEndpoint)
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import FileResponse, Response, StreamingResponse
|
|
from starlette.routing import compile_path
|
|
from starlette.staticfiles import StaticFiles
|
|
|
|
import uuid as _uuid_module
|
|
|
|
from src.api import api_router
|
|
from src.database.core import async_session, engine, async_collector_session
|
|
from src.enums import ResponseStatus
|
|
from src.exceptions import handle_exception
|
|
from src.app_logging import configure_logging
|
|
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
|
|
# from src.rate_limiter import limiter
|
|
from fastapi.exceptions import RequestValidationError
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from src.csrf_protect import csrf_protect
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# we configure the logging level and format
|
|
configure_logging()
|
|
|
|
REQUEST_TIMEOUT_SECONDS = 20
|
|
|
|
# we create the ASGI for the app
|
|
app = FastAPI(
|
|
openapi_url="",
|
|
title="Optimum OH API",
|
|
description="Welcome to Optimum OH's API documentation!",
|
|
version="0.1.0",
|
|
)
|
|
|
|
# Sync handler for RateLimitExceeded — SlowAPIMiddleware falls back to default
|
|
# when the registered handler is async, so this must be a plain sync function.
|
|
def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
|
|
error_id = str(_uuid_module.uuid4())
|
|
request.state.log_status_code = 429
|
|
request.state.log_error_id = error_id
|
|
request.state.log_error_type = "Too Many Requests"
|
|
request.state.log_error_message = "Please try again later"
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"data": None, "message": f"Please try again later. Error ID: {error_id}", "status": ResponseStatus.ERROR},
|
|
)
|
|
|
|
# we define the exception handlers
|
|
app.add_exception_handler(Exception, handle_exception)
|
|
app.add_exception_handler(HTTPException, handle_exception)
|
|
app.add_exception_handler(StarletteHTTPException, handle_exception)
|
|
app.add_exception_handler(RequestValidationError, handle_exception)
|
|
app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler)
|
|
|
|
# app.state.limiter = limiter
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
app.add_middleware(SlowAPIMiddleware)
|
|
app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
|
|
# credentials: "include",
|
|
|
|
|
|
@app.route('/slow', methods=["GET"])
|
|
async def slow_endpoint(request: Request):
|
|
await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10)
|
|
return JSONResponse(content={"message": "This should timeout"}, status_code=200)
|
|
|
|
@app.post('/testcsrf', dependencies=[Depends(JWTBearer()), Depends(csrf_protect)])
|
|
async def test_csrf(request: Request):
|
|
return JSONResponse(
|
|
content={"data": None, "message": "CSRF token is valid", "status": ResponseStatus.SUCCESS},
|
|
status_code=200,
|
|
)
|
|
|
|
|
|
def security_headers_middleware(app: FastAPI):
|
|
is_production = False
|
|
|
|
# CSP rules
|
|
csp_policy = {
|
|
"default-src": "'self'",
|
|
"script-src": "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
|
|
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
|
|
"img-src": "'self' data: https: blob:",
|
|
"font-src": "'self' https://fonts.gstatic.com data:",
|
|
"connect-src": "'self' https://api.your-domain.com wss://ws.your-domain.com",
|
|
"frame-src": "'none'",
|
|
"object-src": "'none'",
|
|
"base-uri": "'self'",
|
|
"form-action": "'self'",
|
|
}
|
|
|
|
# Feature / Permissions Policy
|
|
feature_policy = {
|
|
"geolocation": "'none'",
|
|
"midi": "'none'",
|
|
"notifications": "'none'",
|
|
"push": "'none'",
|
|
"sync-xhr": "'none'",
|
|
"microphone": "'none'",
|
|
"camera": "'none'",
|
|
"magnetometer": "'none'",
|
|
"gyroscope": "'none'",
|
|
"speaker": "'none'",
|
|
"vibrate": "'none'",
|
|
"fullscreen": "'self'",
|
|
"payment": "'none'",
|
|
}
|
|
|
|
csp_header_value = "; ".join(f"{k} {v}" for k, v in csp_policy.items())
|
|
feature_header_value = "; ".join(f"{k}={v}" for k, v in feature_policy.items())
|
|
|
|
# Middleware definition
|
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next):
|
|
response: Response = await call_next(request)
|
|
|
|
if is_production:
|
|
response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Content-Security-Policy"] = csp_header_value
|
|
response.headers["Permissions-Policy"] = feature_header_value
|
|
else:
|
|
# Relaxed settings for development
|
|
response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *"
|
|
# You can skip some headers here for local testing
|
|
|
|
return response
|
|
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
security_headers_middleware(app)
|
|
|
|
|
|
# app.add_middleware(RequestValidationMiddleware)
|
|
# app.add_middleware(
|
|
# CSRFProtectMiddleware,
|
|
# excluded_paths=["/healthcheck"]
|
|
# )
|
|
|
|
|
|
|
|
|
|
@app.middleware("http")
|
|
async def db_session_middleware(request: Request, call_next):
|
|
request_id = str(uuid1())
|
|
|
|
# Per-request ID scopes the SQLAlchemy session.
|
|
# see: https://github.com/tiangolo/fastapi/issues/726
|
|
ctx_token = set_request_id(request_id)
|
|
request.state.request_id = request_id
|
|
|
|
try:
|
|
session = async_scoped_session(async_session, scopefunc=get_request_id)
|
|
request.state.db = session()
|
|
|
|
collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id)
|
|
request.state.collector_db = collector_session()
|
|
|
|
start_time = time.time()
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception as exc:
|
|
# Non-HTTP exceptions (SQLAlchemy, httpx, ValueError, etc.) are NOT caught by
|
|
# ExceptionMiddleware because app.add_exception_handler(Exception, ...) registers
|
|
# with ServerErrorMiddleware, not ExceptionMiddleware. If we let such exceptions
|
|
# escape here, db_session_middleware exits via finally WITHOUT logging, then
|
|
# ServerErrorMiddleware calls handle_exception (giving the 500 response) and
|
|
# re-raises, causing uvicorn to emit an all-null JSON log entry.
|
|
# Catching here ensures: proper error response, structured log, no re-raise.
|
|
response = await handle_exception(request, exc)
|
|
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
|
|
# ── Determine status_code ─────────────────────────────────────────────
|
|
# RequestTimeoutMiddleware flags the scope when a 408 bypasses exception handlers
|
|
timed_out = request.scope.get("x_timed_out", False)
|
|
if timed_out:
|
|
status_code = 408
|
|
error_id = request.scope.get("x_timeout_error_id", None)
|
|
error_type = "Request Timeout"
|
|
error_message = "Request timeout over 20 seconds"
|
|
else:
|
|
status_code = getattr(request.state, "log_status_code", None) or response.status_code
|
|
error_id = getattr(request.state, "log_error_id", None)
|
|
error_type = getattr(request.state, "log_error_type", None)
|
|
error_message = getattr(request.state, "log_error_message", None)
|
|
|
|
# ── Fallback error labels for responses that skip exception handler ───
|
|
_FALLBACK_ERROR_TYPES = {
|
|
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
404: "Not Found", 408: "Request Timeout", 409: "Conflict",
|
|
422: "Unprocessable Entity", 429: "Too Many Requests",
|
|
500: "Internal Server Error",
|
|
}
|
|
_FALLBACK_ERROR_MESSAGES = {
|
|
400: "Bad request", 401: "Invalid authorization token",
|
|
403: "Forbidden Access", 404: "Resource not found",
|
|
408: "Request timeout", 409: "This record already exists",
|
|
422: "Input validation failed", 429: "Please try again later",
|
|
500: "An unexpected error occurred",
|
|
}
|
|
if status_code >= 400 and not error_type:
|
|
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f"HTTP {status_code}")
|
|
if status_code >= 400 and not error_message:
|
|
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, "Request failed")
|
|
|
|
# ── User info ─────────────────────────────────────────────────────────
|
|
from src.context import get_user_id, set_user_id
|
|
user_id = get_user_id()
|
|
user_obj = getattr(request.state, "user", None)
|
|
if user_obj and not user_id:
|
|
if isinstance(user_obj, dict):
|
|
u_id = user_obj.get("user_id")
|
|
else:
|
|
u_id = getattr(user_obj, "user_id", None)
|
|
if u_id:
|
|
user_id = str(u_id)
|
|
set_user_id(user_id)
|
|
|
|
# ── Client IP ─────────────────────────────────────────────────────────
|
|
headers = request.headers
|
|
if "X-Real-IP" in headers:
|
|
user_ip = headers["X-Real-IP"]
|
|
elif "X-Forwarded-For" in headers:
|
|
user_ip = headers["X-Forwarded-For"].split(",")[0].strip()
|
|
else:
|
|
user_ip = request.client.host if request.client else None
|
|
|
|
# ── Full path with query string ───────────────────────────────────────
|
|
path = request.url.path
|
|
if request.url.query:
|
|
path = f"{path}?{request.url.query}"
|
|
|
|
# ── Emit single structured log ────────────────────────────────────────
|
|
result = "Request completed" if status_code < 400 else "Request failed"
|
|
if status_code >= 500:
|
|
log_level = logging.ERROR
|
|
elif status_code >= 400:
|
|
log_level = logging.WARNING
|
|
else:
|
|
log_level = logging.INFO
|
|
|
|
log.log(
|
|
log_level,
|
|
f"{request.method} {path} {status_code}",
|
|
extra={
|
|
"method": request.method,
|
|
"path": path,
|
|
"status_code": status_code,
|
|
"duration_ms": duration_ms,
|
|
"request_id": request_id,
|
|
"user_id": user_id,
|
|
"user_ip": user_ip,
|
|
"error_id": error_id,
|
|
"result": result,
|
|
"error_type": error_type if status_code >= 400 else None,
|
|
"error_message": error_message if status_code >= 400 else None,
|
|
},
|
|
)
|
|
finally:
|
|
await request.state.db.close()
|
|
await request.state.collector_db.close()
|
|
|
|
reset_request_id(ctx_token)
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def add_security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["Strict-Transport-Security"] = (
|
|
"max-age=31536000 ; includeSubDomains"
|
|
)
|
|
return response
|
|
|
|
|
|
# class MetricsMiddleware(BaseHTTPMiddleware):
|
|
# async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
# method = request.method
|
|
# endpoint = request.url.path
|
|
# tags = {"method": method, "endpoint": endpoint}
|
|
|
|
# try:
|
|
# start = time.perf_counter()
|
|
# response = await call_next(request)
|
|
# elapsed_time = time.perf_counter() - start
|
|
# tags.update({"status_code": response.status_code})
|
|
# metric_provider.counter("server.call.counter", tags=tags)
|
|
# metric_provider.timer("server.call.elapsed", value=elapsed_time, tags=tags)
|
|
# log.debug(f"server.call.elapsed.{endpoint}: {elapsed_time}")
|
|
# except Exception as e:
|
|
# metric_provider.counter("server.call.exception.counter", tags=tags)
|
|
# raise e from None
|
|
# return response
|
|
|
|
|
|
# app.add_middleware(ExceptionMiddleware)
|
|
|
|
app.include_router(api_router)
|