feat: migrate to Pydantic V2 and implement rate limiting middleware

- Migrate settings.py to Pydantic V2 (SettingsConfigDict, validation_alias)
- Update config models to use @field_validator with @classmethod
- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
- Migrate FastAPI app from @app.on_event to lifespan context manager
- Implement comprehensive rate limiting middleware with:
  * Endpoint-specific rate limits (login: 5/min, register: 3/min)
  * IP-based and user-based tracking
  * Authenticated user multiplier (2x limits)
  * Bypass paths for health, docs, static, websocket endpoints
  * Rate limit headers in responses
- Add 13 comprehensive tests for rate limiting (all passing)
- Update instructions.md to mark completed tasks
- Fix asyncio.create_task usage in anime_service.py

All 714 tests passing. No deprecation warnings.
This commit is contained in:
2025-10-23 22:03:15 +02:00
parent 6a6ae7e059
commit 17e5a551e1
23 changed files with 949 additions and 269 deletions

View File

@@ -2,18 +2,25 @@ import secrets
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings from environment variables."""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
jwt_secret_key: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
env="JWT_SECRET_KEY",
validation_alias="JWT_SECRET_KEY",
)
password_salt: str = Field(
default="default-salt",
validation_alias="PASSWORD_SALT"
)
password_salt: str = Field(default="default-salt", env="PASSWORD_SALT")
master_password_hash: Optional[str] = Field(
default=None, env="MASTER_PASSWORD_HASH"
default=None,
validation_alias="MASTER_PASSWORD_HASH"
)
# ⚠️ WARNING: DEVELOPMENT ONLY - NEVER USE IN PRODUCTION ⚠️
# This field allows setting a plaintext master password via environment
@@ -21,32 +28,50 @@ class Settings(BaseSettings):
# deployments, use MASTER_PASSWORD_HASH instead and NEVER set this field.
master_password: Optional[str] = Field(
default=None,
env="MASTER_PASSWORD",
validation_alias="MASTER_PASSWORD",
description=(
"**DEVELOPMENT ONLY** - Plaintext master password. "
"NEVER enable in production. Use MASTER_PASSWORD_HASH instead."
),
)
token_expiry_hours: int = Field(
default=24, env="SESSION_TIMEOUT_HOURS"
default=24,
validation_alias="SESSION_TIMEOUT_HOURS"
)
anime_directory: str = Field(
default="",
validation_alias="ANIME_DIRECTORY"
)
log_level: str = Field(
default="INFO",
validation_alias="LOG_LEVEL"
)
anime_directory: str = Field(default="", env="ANIME_DIRECTORY")
log_level: str = Field(default="INFO", env="LOG_LEVEL")
# Additional settings from .env
database_url: str = Field(
default="sqlite:///./data/aniworld.db", env="DATABASE_URL"
default="sqlite:///./data/aniworld.db",
validation_alias="DATABASE_URL"
)
cors_origins: str = Field(
default="http://localhost:3000",
env="CORS_ORIGINS",
validation_alias="CORS_ORIGINS",
)
api_rate_limit: int = Field(
default=100,
validation_alias="API_RATE_LIMIT"
)
api_rate_limit: int = Field(default=100, env="API_RATE_LIMIT")
default_provider: str = Field(
default="aniworld.to", env="DEFAULT_PROVIDER"
default="aniworld.to",
validation_alias="DEFAULT_PROVIDER"
)
provider_timeout: int = Field(
default=30,
validation_alias="PROVIDER_TIMEOUT"
)
retry_attempts: int = Field(
default=3,
validation_alias="RETRY_ATTEMPTS"
)
provider_timeout: int = Field(default=30, env="PROVIDER_TIMEOUT")
retry_attempts: int = Field(default=3, env="RETRY_ATTEMPTS")
@property
def allowed_origins(self) -> list[str]:
@@ -67,9 +92,5 @@ class Settings(BaseSettings):
]
return [origin.strip() for origin in raw.split(",") if origin.strip()]
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()