Add rate-limiting for authentication endpoints and health check feature

- Introduced rate-limiting to `/auth/*` routes with configurable limits using `SlowAPI`.
- Added `/health` endpoint for service monitoring and load balancer health checks.
- Updated `requirements.txt` to include `SlowAPI` for rate limiting.
- Implemented tests for rate-limiting and health check functionality.
- Enhanced configuration and security with updated environment variables, pinned dependencies, and validation adjustments.
- Provided example usage and extended coverage in testing.
This commit is contained in:
Felipe Cardoso
2025-10-29 23:59:29 +01:00
parent f163ffbb83
commit 5bed14b6b0
6 changed files with 492 additions and 11 deletions

View File

@@ -2,8 +2,10 @@
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, Body
from fastapi import APIRouter, Depends, HTTPException, status, Body, Request
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.orm import Session
from app.api.dependencies.auth import get_current_user
@@ -22,9 +24,14 @@ from app.services.auth_service import AuthService, AuthenticationError
router = APIRouter()
logger = logging.getLogger(__name__)
# Initialize limiter for this router
limiter = Limiter(key_func=get_remote_address)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, operation_id="register")
@limiter.limit("5/minute")
async def register_user(
request: Request,
user_data: UserCreate,
db: Session = Depends(get_db)
) -> Any:
@@ -52,7 +59,9 @@ async def register_user(
@router.post("/login", response_model=Token, operation_id="login")
@limiter.limit("10/minute")
async def login(
request: Request,
login_data: LoginRequest,
db: Session = Depends(get_db)
) -> Any:
@@ -101,7 +110,9 @@ async def login(
@router.post("/login/oauth", response_model=Token, operation_id='login_oauth')
@limiter.limit("10/minute")
async def login_oauth(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
) -> Any:
@@ -148,7 +159,9 @@ async def login_oauth(
@router.post("/refresh", response_model=Token, operation_id="refresh_token")
@limiter.limit("30/minute")
async def refresh_token(
request: Request,
refresh_data: RefreshTokenRequest,
db: Session = Depends(get_db)
) -> Any:
@@ -184,7 +197,9 @@ async def refresh_token(
@router.post("/change-password", status_code=status.HTTP_200_OK, operation_id="change_password")
@limiter.limit("5/minute")
async def change_password(
request: Request,
current_password: str = Body(..., embed=True),
new_password: str = Body(..., embed=True),
current_user: User = Depends(get_current_user),
@@ -220,7 +235,9 @@ async def change_password(
@router.get("/me", response_model=UserResponse, operation_id="get_current_user_info")
@limiter.limit("60/minute")
async def get_current_user_info(
request: Request,
current_user: User = Depends(get_current_user)
) -> Any:
"""

View File

@@ -22,7 +22,6 @@ class Settings(BaseSettings):
POSTGRES_PORT: str = "5432"
POSTGRES_DB: str = "app"
DATABASE_URL: Optional[str] = None
REFRESH_TOKEN_EXPIRE_DAYS: int = 60
db_pool_size: int = 20 # Default connection pool size
db_max_overflow: int = 50 # Maximum overflow connections
db_pool_timeout: int = 30 # Seconds to wait for a connection
@@ -48,7 +47,7 @@ class Settings(BaseSettings):
# JWT configuration
SECRET_KEY: str = Field(
default="your_secret_key_here",
default="dev_only_insecure_key_change_in_production_32chars_min",
min_length=32,
description="JWT signing key. MUST be changed in production. Generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
)

View File

@@ -1,17 +1,27 @@
import logging
from datetime import datetime
from typing import Dict, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from fastapi import FastAPI, status, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text
from app.api.main import api_router
from app.core.config import settings
from app.core.database import get_db
scheduler = AsyncIOScheduler()
logger = logging.getLogger(__name__)
# Initialize rate limiter
limiter = Limiter(key_func=get_remote_address)
logger.info(f"Starting app!!!")
app = FastAPI(
title=settings.PROJECT_NAME,
@@ -19,6 +29,10 @@ app = FastAPI(
openapi_url=f"{settings.API_V1_STR}/openapi.json"
)
# Add rate limiter state to app
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
@@ -45,4 +59,58 @@ async def root():
"""
@app.get(
"/health",
summary="Health Check",
description="Check the health status of the API and its dependencies",
response_description="Health status information",
tags=["Health"],
operation_id="health_check"
)
async def health_check() -> JSONResponse:
"""
Health check endpoint for monitoring and load balancers.
Returns:
JSONResponse: Health status with the following information:
- status: Overall health status ("healthy" or "unhealthy")
- timestamp: Current server timestamp (ISO 8601 format)
- version: API version
- environment: Current environment (development, staging, production)
- database: Database connectivity status
"""
health_status: Dict[str, Any] = {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat() + "Z",
"version": settings.VERSION,
"environment": settings.ENVIRONMENT,
"checks": {}
}
response_status = status.HTTP_200_OK
# Database health check
try:
db = next(get_db())
db.execute(text("SELECT 1"))
health_status["checks"]["database"] = {
"status": "healthy",
"message": "Database connection successful"
}
db.close()
except Exception as e:
health_status["status"] = "unhealthy"
health_status["checks"]["database"] = {
"status": "unhealthy",
"message": f"Database connection failed: {str(e)}"
}
response_status = status.HTTP_503_SERVICE_UNAVAILABLE
logger.error(f"Health check failed - database error: {e}")
return JSONResponse(
status_code=response_status,
content=health_status
)
app.include_router(api_router, prefix=settings.API_V1_STR)