From 5c555da38636a6b1e585d77eaa7c31351ac27321 Mon Sep 17 00:00:00 2001 From: Emiliano Gandini Outeda Date: Fri, 18 Sep 2026 00:34:04 -0300 Subject: [PATCH 1/3] feat: wire boilerplate to crudauth follow-ups Rebuild the crudauth integration on top of current main and crudauth 0.7.0: - configure the shared password policy and drive the signup schema from the same PASSWORD_* settings so both enforce the same rules; - use crudauth's built-in OAuth router (configurable paths, JSON responses, browser-bound state) instead of the hand-rolled Google routes; - apply crudauth's per-request, tier/path-aware rate limiting with a router-level dependency, replacing the in-tree middleware/provider; - inject the shared Redis clients into crudauth and the cache backend; - delete the now-dead infrastructure/rate_limit package, its tests, and the vestigial RATE_LIMITER_BACKEND / FAIL_OPEN / MEMCACHED settings; - update tests and docs. --- backend/.env.example | 19 +- backend/pyproject.toml | 2 +- backend/src/infrastructure/app_factory.py | 18 +- backend/src/infrastructure/auth/oauth.py | 46 --- backend/src/infrastructure/auth/routes.py | 181 +-------- backend/src/infrastructure/auth/setup.py | 57 ++- .../infrastructure/cache/backends/redis.py | 5 +- .../src/infrastructure/cache/initialize.py | 7 +- backend/src/infrastructure/config/settings.py | 24 +- .../src/infrastructure/rate_limit/__init__.py | 35 -- .../rate_limit/backends/__init__.py | 26 -- .../rate_limit/backends/memcached.py | 179 --------- .../rate_limit/backends/redis.py | 192 --------- backend/src/infrastructure/rate_limit/base.py | 252 ------------ .../infrastructure/rate_limit/exceptions.py | 156 -------- .../infrastructure/rate_limit/initialize.py | 75 ---- .../infrastructure/rate_limit/middleware.py | 151 ------- .../src/infrastructure/rate_limit/provider.py | 369 ------------------ .../src/infrastructure/rate_limit/utils.py | 3 - backend/src/infrastructure/redis.py | 38 ++ .../security/production_validator.py | 2 +- backend/src/interfaces/admin/views/users.py | 2 + backend/src/interfaces/api/v1/__init__.py | 12 +- backend/src/modules/user/constants.py | 13 +- backend/src/modules/user/schemas.py | 26 +- backend/src/modules/user/service.py | 2 + backend/tests/conftest.py | 25 +- .../tests/integration/auth/test_endpoints.py | 270 +------------ .../infrastructure/rate_limit/__init__.py | 0 .../rate_limit/backends/__init__.py | 0 .../rate_limit/backends/test_fail_open.py | 159 -------- .../rate_limit/backends/test_memcached.py | 136 ------- .../rate_limit/backends/test_redis.py | 137 ------- .../rate_limit/test_fail_open_middleware.py | 101 ----- .../rate_limit/test_fail_open_provider.py | 137 ------- .../rate_limit/test_middleware.py | 210 ---------- .../rate_limit/test_provider.py | 177 --------- .../security/test_production_validator.py | 4 +- .../unit/infrastructure/test_app_factory.py | 19 +- docs/changelog.md | 2 +- docs/cli/plugins.md | 2 +- docs/getting-started/configuration.md | 17 +- docs/index.md | 2 +- docs/user-guide/api/index.md | 2 +- docs/user-guide/authentication/index.md | 6 +- docs/user-guide/authentication/permissions.md | 2 +- docs/user-guide/authentication/sessions.md | 2 +- docs/user-guide/configuration/docker-setup.md | 4 +- .../configuration/environment-variables.md | 13 +- docs/user-guide/configuration/index.md | 1 - docs/user-guide/development.md | 4 +- docs/user-guide/production.md | 4 +- docs/user-guide/project-structure.md | 6 +- docs/user-guide/rate-limiting/index.md | 230 +++-------- uv.lock | 172 +++++++- 55 files changed, 457 insertions(+), 3279 deletions(-) delete mode 100644 backend/src/infrastructure/auth/oauth.py delete mode 100644 backend/src/infrastructure/rate_limit/__init__.py delete mode 100644 backend/src/infrastructure/rate_limit/backends/__init__.py delete mode 100644 backend/src/infrastructure/rate_limit/backends/memcached.py delete mode 100644 backend/src/infrastructure/rate_limit/backends/redis.py delete mode 100644 backend/src/infrastructure/rate_limit/base.py delete mode 100644 backend/src/infrastructure/rate_limit/exceptions.py delete mode 100644 backend/src/infrastructure/rate_limit/initialize.py delete mode 100644 backend/src/infrastructure/rate_limit/middleware.py delete mode 100644 backend/src/infrastructure/rate_limit/provider.py delete mode 100644 backend/src/infrastructure/rate_limit/utils.py create mode 100644 backend/src/infrastructure/redis.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/__init__.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/backends/__init__.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/backends/test_fail_open.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/backends/test_memcached.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/backends/test_redis.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/test_fail_open_middleware.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/test_fail_open_provider.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/test_middleware.py delete mode 100644 backend/tests/unit/infrastructure/rate_limit/test_provider.py diff --git a/backend/.env.example b/backend/.env.example index 375ba4c9..99e1eaf0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -64,19 +64,13 @@ CACHE_REDIS_POOL_SIZE=10 # =================================== # Rate Limiting Configuration # =================================== +# Provided by crudauth (Redis-backed). Limits are resolved per request from the +# user's tier and path, falling back to the defaults below. RATE_LIMITER_ENABLED=true -# Options: memcached, redis -RATE_LIMITER_BACKEND=redis -RATE_LIMITER_FAIL_OPEN=true DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 -# Rate Limiter Memcached settings (when using RATE_LIMITER_BACKEND=memcached) -RATE_LIMITER_MEMCACHED_HOST=localhost -RATE_LIMITER_MEMCACHED_PORT=11211 -RATE_LIMITER_MEMCACHED_POOL_SIZE=10 - -# Rate Limiter Redis settings (when using RATE_LIMITER_BACKEND=redis) +# Rate Limiter Redis settings # Uses DB 1 by default to separate from cache (which uses DB 0) # For Docker Compose: use 'redis' (the service name) # For local development without Docker: use 'localhost' @@ -166,6 +160,13 @@ CSRF_ENABLED=true # IP for login lockout. 0 = direct (socket peer); set 1 behind a single nginx/Caddy. TRUSTED_PROXY_HOPS=0 +# Password policy (crudauth applies this to every password-writing path) +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_DIGIT=true +PASSWORD_REQUIRE_SPECIAL=true + # =================================== # Admin Interface (SQLAdmin) # =================================== diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2f3211f3..c4e0334f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "aiosqlite>=0.21.0", "alembic>=1.16.4", "asyncpg>=0.30.0", - "crudauth[all]>=0.6.0,<0.7.0", + "crudauth[all]>=0.7.0,<0.8.0", "faker>=37.1.0", "fastapi[standard]>=0.115.8", "fastcrud>=0.21.0", diff --git a/backend/src/infrastructure/app_factory.py b/backend/src/infrastructure/app_factory.py index 232ba43f..1696daa2 100644 --- a/backend/src/infrastructure/app_factory.py +++ b/backend/src/infrastructure/app_factory.py @@ -29,8 +29,7 @@ from .database.initialize import close_database from .database.session import create_tables from .middleware import ClientCacheMiddleware, SecurityHeadersMiddleware -from .rate_limit.initialize import close_rate_limiter, initialize_rate_limiter -from .rate_limit.middleware import RateLimiterMiddleware +from .redis import cache_redis_client, rate_limiter_redis_client logger = logging.getLogger(__name__) @@ -65,8 +64,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: teardown.push_async_callback(close_cache) if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: - await initialize_rate_limiter() - teardown.push_async_callback(close_rate_limiter) + teardown.push_async_callback(rate_limiter_redis_client.aclose) + + # The cache backend owns ``cache_redis_client`` when it's redis-backed; + # otherwise the module-level client still needs releasing. + if not ( + isinstance(settings, CacheSettings) + and settings.CACHE_ENABLED + and settings.CACHE_BACKEND == "redis" + ): + teardown.push_async_callback(cache_redis_client.aclose) teardown.push_async_callback(auth.shutdown) await auth.initialize() @@ -270,9 +277,6 @@ def create_application( application.include_router(router) - if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: - application.add_middleware(RateLimiterMiddleware) - if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED and hasattr(settings, "CLIENT_CACHE_ENABLED"): if settings.CLIENT_CACHE_ENABLED: client_cache_max_age = getattr(settings, "CLIENT_CACHE_MAX_AGE", 60) diff --git a/backend/src/infrastructure/auth/oauth.py b/backend/src/infrastructure/auth/oauth.py deleted file mode 100644 index 7377964f..00000000 --- a/backend/src/infrastructure/auth/oauth.py +++ /dev/null @@ -1,46 +0,0 @@ -"""crudauth OAuth building blocks for the boilerplate's own OAuth routes. - -Runs the existing ``/oauth/google`` routes on crudauth's hardened OAuth -(PKCE + signed state + verified-email account linking) without mounting crudauth's -own oauth router - which would change the URLs. We construct the provider, a -per-request state store, and the account-linking service here and drive them from -the route handlers in ``routes.py``. -""" - -from crudauth.oauth import OAuthAccountService, OAuthProviderFactory -from crudauth.storage import get_session_storage - -from ...modules.user.constants import NAME_MAX_LENGTH -from ..config.settings import settings -from .setup import _session_redis_url, _use_redis, auth - -OAUTH_STATE_TTL_SECONDS = 1800 - -_redirect_base = settings.OAUTH_REDIRECT_BASE_URL.rstrip("/") - - -def _build_provider(name: str, client_id: str, client_secret: str): - return OAuthProviderFactory.create_provider( - name, - client_id=client_id, - client_secret=client_secret, - redirect_uri=f"{_redirect_base}/api/v1/auth/oauth/callback/{name}", - ) - - -# Only Google has a wired route; add a "github" entry here (and its routes) to enable it. -oauth_providers = { - "google": _build_provider("google", settings.OAUTH_GOOGLE_CLIENT_ID, settings.OAUTH_GOOGLE_CLIENT_SECRET), -} - -oauth_state_storage = get_session_storage( - "redis" if _use_redis else "memory", - prefix="oauth_state:", - expiration=OAUTH_STATE_TTL_SECONDS, - redis_url=_session_redis_url if _use_redis else None, -) - -oauth_account_service = OAuthAccountService( - repo=auth.repo, - new_user_fields=lambda ctx: {"name": ctx.suggested_name[:NAME_MAX_LENGTH]}, -) diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index 0d8a4a79..7b783673 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -1,20 +1,14 @@ from typing import Annotated, Any -from urllib.parse import urlsplit from crudauth import Principal from crudauth.exceptions import UnauthorizedException -from crudauth.oauth import OAuthState from crudauth.ratelimit import KeyBy -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Depends, Query, Request, Response -from ...modules.common.constants import GENERIC_ERROR_MESSAGE from ...modules.user.crud import crud_users -from ...modules.user.enums import OAuthProvider from ..dependencies import AsyncSessionDep, OAuth2FormDep from ..logging import get_logger from .dependencies import get_current_principal, get_optional_principal -from .oauth import OAUTH_STATE_TTL_SECONDS, oauth_account_service, oauth_providers, oauth_state_storage from .setup import auth as crud_auth logger = get_logger() @@ -22,18 +16,6 @@ router = APIRouter(tags=["Authentication"]) -def _safe_redirect_path(redirect_uri: str | None) -> str | None: - """Allow only same-origin relative paths as post-auth redirect targets.""" - if not redirect_uri or not redirect_uri.startswith("/") or redirect_uri.startswith("//"): - return None - if "\\" in redirect_uri or any(ord(char) < 0x20 for char in redirect_uri): - return None - parts = urlsplit(redirect_uri) - if parts.scheme or parts.netloc: - return None - return redirect_uri - - @router.post( "/login", summary="User Login", @@ -181,169 +163,14 @@ async def refresh_csrf_token( raise UnauthorizedException("Not authenticated") ttl_seconds = sessions.timeout_seconds_for(session.metadata) - csrf_token = await sessions.regenerate_csrf_token( - user_id=session.user_id, session_id=session_id, expiration_seconds=ttl_seconds - ) + csrf_token = await sessions.regenerate_csrf_token(session_id, expiration_seconds=ttl_seconds) sessions.set_csrf_cookie(response, csrf_token, max_age=ttl_seconds) return {"csrf_token": csrf_token} -@router.get( - "/oauth/google", - summary="Initiate Google OAuth Login", - description=""" - Starts the OAuth 2.0 authentication flow with Google. - - This endpoint generates the authorization URL that the user should be - redirected to in order to authenticate with Google. The flow includes: - - Creation of a state parameter for CSRF protection - - Generation of PKCE code challenge (for enhanced security) - - Setting appropriate OAuth scopes for profile access - - After successful authentication with Google, the user will be redirected - back to this application's callback endpoint. - - An optional redirect_uri can be specified to control where the user - is sent after the entire authentication process completes. Only - relative paths (starting with "/") are accepted. - """, - responses={ - 200: {"description": "Authorization URL generated successfully"}, - 500: {"description": "Failed to initiate Google login"}, - }, - response_description="The Google authorization URL to redirect the user to", -) -async def oauth_google_login( - request: Request, - redirect_uri: str | None = Query(None), -) -> dict[str, str]: - """Initiate the Google OAuth flow: build the authorization URL and stash state + PKCE.""" - try: - auth_data = oauth_providers["google"].get_authorization_url() - state_obj = OAuthState( - state=auth_data["state"], - provider=OAuthProvider.GOOGLE.value, - redirect_to=_safe_redirect_path(redirect_uri), - code_verifier=auth_data.get("code_verifier"), - ) - await oauth_state_storage.create(state_obj, session_id=auth_data["state"], expiration=OAUTH_STATE_TTL_SECONDS) - return {"url": auth_data["url"]} - except Exception as e: - logger.error(f"Error initiating Google OAuth: {str(e)}", exc_info=True) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to initiate Google login") - - -@router.get( - "/oauth/callback/google", - summary="Google OAuth Callback Handler", - description=""" - Processes the authentication callback from Google OAuth. - - This endpoint handles the authorization code returned by Google after - the user has successfully authenticated. The process includes: - - Validating the state parameter to prevent CSRF attacks - - Exchanging the authorization code for access/refresh tokens - - Fetching the user profile from Google - - Creating or updating the user account in the system - - Establishing a new session for the authenticated user - - Two response formats are supported: - - redirect: Redirects to the frontend with success/error parameters (default) - - json: Returns user information and tokens as a JSON response - - The json format is useful for mobile apps or single-page applications that - handle the OAuth flow programmatically. - """, - responses={ - 200: {"description": "Authentication successful (JSON response)"}, - 302: {"description": "Authentication successful (redirect response)"}, - 400: {"description": "Invalid OAuth state or other parameter"}, - 401: {"description": "Authentication failed"}, - 500: {"description": "Server error during authentication"}, - }, - response_description="Authentication result with session cookies set", -) -async def oauth_google_callback( - request: Request, - response: Response, - db: AsyncSessionDep, - code: str = Query(...), - state: str = Query(...), - response_format: str = Query("redirect", description="Response format, either 'redirect' or 'json'"), -): - """Handle the Google OAuth callback: verify state, link/create the user, start a session.""" - state_data = await oauth_state_storage.get(state, OAuthState) - - if not state_data: - logger.warning(f"Invalid OAuth state in callback: {state}") - if response_format == "json": - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}&reason=invalid_state", - status_code=status.HTTP_302_FOUND, - ) - - if state_data.provider != OAuthProvider.GOOGLE.value: - logger.warning(f"Provider mismatch in OAuth callback: expected google, got {state_data.provider}") - if response_format == "json": - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider mismatch") - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}&reason=provider_mismatch", - status_code=status.HTTP_302_FOUND, - ) - - try: - provider = oauth_providers["google"] - token_data = await provider.exchange_code(code, code_verifier=state_data.code_verifier) - user_info_raw = await provider.get_user_info(token_data["access_token"]) - user_info = await provider.process_user_info(user_info_raw) - - user, is_new_user = await oauth_account_service.get_or_create_user(user_info, db) - user_id = crud_auth.repo.user_id(user) - username = crud_auth.repo.get(user, "username") - - session_id, csrf_token = await crud_auth.sessions.create_session( - request, - user_id=user_id, - metadata={ - "login_type": "oauth", - "oauth_provider": OAuthProvider.GOOGLE.value, - "username": username, - "is_new_user": is_new_user, - }, - ) - crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) - - await oauth_state_storage.delete(state) - - if response_format == "json": - return { - "success": True, - "user": { - "id": user_id, - "username": username, - "email": crud_auth.repo.get(user, "email"), - "is_new_user": is_new_user, - }, - "csrf_token": csrf_token, - } - - redirect_to = _safe_redirect_path(state_data.redirect_to) or "/" - redirect = RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) - crud_auth.sessions.set_session_cookies(redirect, session_id, csrf_token) - return redirect - - except Exception as e: - logger.error(f"Error in Google OAuth callback: {str(e)}", exc_info=True) - - if response_format == "json": - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=GENERIC_ERROR_MESSAGE) - - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}", - status_code=status.HTTP_302_FOUND, - ) +if crud_auth.oauth is not None: + router.include_router(crud_auth.oauth_router) @router.get("/check-auth") diff --git a/backend/src/infrastructure/auth/setup.py b/backend/src/infrastructure/auth/setup.py index feb81c28..fecf3b25 100644 --- a/backend/src/infrastructure/auth/setup.py +++ b/backend/src/infrastructure/auth/setup.py @@ -11,16 +11,28 @@ the boilerplate has no email pipeline, and no route gates on sudo. """ -from crudauth import CookieConfig, CRUDAuth, SessionTransport -from crudauth.ratelimit import redis_rate_limiter +from crudauth import CookieConfig, CRUDAuth, OAuthCredentials, PasswordPolicy, Principal, SessionTransport +from crudauth.ratelimit import KeyBy, RateLimit, redis_rate_limiter +from fastapi import Request +from ...modules.rate_limit.crud import crud_rate_limits +from ...modules.rate_limit.schemas import RateLimitSelect +from ...modules.tier.crud import crud_tiers +from ...modules.tier.schemas import TierSelect from ...modules.user.models import User from ..config.settings import settings -from ..database.session import async_session +from ..database.session import async_session, local_session +from ..redis import rate_limiter_redis_client _session_redis_url = settings.SESSION_REDIS_URL _use_redis = settings.SESSION_BACKEND == "redis" +_oauth = {} +if settings.OAUTH_GOOGLE_CLIENT_ID and settings.OAUTH_GOOGLE_CLIENT_SECRET: + _oauth["google"] = OAuthCredentials( + client_id=settings.OAUTH_GOOGLE_CLIENT_ID, + client_secret=settings.OAUTH_GOOGLE_CLIENT_SECRET, + ) auth = CRUDAuth( session=async_session, @@ -37,6 +49,43 @@ cleanup_interval_minutes=settings.SESSION_CLEANUP_INTERVAL_MINUTES, ) ], - rate_limiter=redis_rate_limiter(redis_url=_session_redis_url) if _use_redis else None, + rate_limiter=redis_rate_limiter(client=rate_limiter_redis_client) if settings.RATE_LIMITER_ENABLED and _use_redis else None, trusted_proxy_hops=settings.TRUSTED_PROXY_HOPS, + password_policy=PasswordPolicy( + min_length=settings.PASSWORD_MIN_LENGTH, + require_uppercase=settings.PASSWORD_REQUIRE_UPPERCASE, + require_lowercase=settings.PASSWORD_REQUIRE_LOWERCASE, + require_digit=settings.PASSWORD_REQUIRE_DIGIT, + require_special=settings.PASSWORD_REQUIRE_SPECIAL, + ), + oauth=_oauth or None, + redirect_base_url=settings.OAUTH_REDIRECT_BASE_URL, + oauth_paths={ + "prefix": "/oauth", + "authorize_path": "/{provider}", + "callback_path": "/callback/{provider}", + }, + oauth_response_mode="json", ) + + +async def resolve_api_rate_limit(request: Request, principal: Principal | None) -> RateLimit | None: + """Resolve the configured tier/path limit for crudauth's limiter.""" + if not settings.RATE_LIMITER_ENABLED: + return None + + async with local_session() as db: + tier_id = auth.repo.get(principal.user, "tier_id") if principal and principal.user else None + if tier_id is not None: + tier = await crud_tiers.get(db=db, id=tier_id, schema_to_select=TierSelect) + if tier: + configured = await crud_rate_limits.get( + db=db, tier_id=tier["id"], path=request.url.path, schema_to_select=RateLimitSelect + ) + if configured: + return RateLimit(configured["limit"], configured["period"]) + + return RateLimit(settings.DEFAULT_RATE_LIMIT_LIMIT, settings.DEFAULT_RATE_LIMIT_PERIOD) + + +api_rate_limit_dependency = auth.rate_limit("api", resolve_api_rate_limit, key=KeyBy.USER_OR_IP) diff --git a/backend/src/infrastructure/cache/backends/redis.py b/backend/src/infrastructure/cache/backends/redis.py index 1edb6e02..ec9f7682 100644 --- a/backend/src/infrastructure/cache/backends/redis.py +++ b/backend/src/infrastructure/cache/backends/redis.py @@ -41,14 +41,14 @@ class RedisSettings(BaseModel): class RedisBackend(CacheBackend): """Redis implementation of the cache backend.""" - def __init__(self, settings: RedisSettings | None = None): + def __init__(self, settings: RedisSettings | None = None, client: Redis | None = None): """Initialize the Redis backend. Args: settings: Custom settings for Redis connection. If None, default settings are used. """ self.settings = settings or RedisSettings() - self.client = Redis( + self.client = client or Redis( host=self.settings.host, port=self.settings.port, db=self.settings.db, @@ -56,6 +56,7 @@ def __init__(self, settings: RedisSettings | None = None): socket_timeout=self.settings.connect_timeout, max_connections=self.settings.pool_size, ) + self._owns_client = client is None async def get(self, key: str) -> Any | None: """Get a value from the cache. diff --git a/backend/src/infrastructure/cache/initialize.py b/backend/src/infrastructure/cache/initialize.py index 825a8ba1..5dd44f93 100644 --- a/backend/src/infrastructure/cache/initialize.py +++ b/backend/src/infrastructure/cache/initialize.py @@ -2,6 +2,7 @@ from ..config import CacheBackend from ..config.settings import get_settings +from ..redis import cache_redis_client from . import MEMCACHED_INSTALLED, REDIS_INSTALLED from .provider import cache_provider @@ -48,7 +49,7 @@ async def initialize_cache() -> None: connect_timeout=settings.CACHE_REDIS_CONNECT_TIMEOUT, pool_size=settings.CACHE_REDIS_POOL_SIZE, ) - redis_backend = RedisBackend(settings=redis_settings) + redis_backend = RedisBackend(settings=redis_settings, client=cache_redis_client) cache_provider.register_backend(CacheBackend.REDIS.value, redis_backend, default=True) @@ -68,6 +69,4 @@ async def close_cache() -> None: await backend.client.close() elif settings.CACHE_BACKEND == CacheBackend.REDIS.value and REDIS_INSTALLED: - backend = cache_provider.get_backend(CacheBackend.REDIS.value) - if hasattr(backend, "client") and hasattr(backend.client, "close"): - await backend.client.close() + await cache_redis_client.aclose() diff --git a/backend/src/infrastructure/config/settings.py b/backend/src/infrastructure/config/settings.py index 8faf9d4e..0d52a388 100644 --- a/backend/src/infrastructure/config/settings.py +++ b/backend/src/infrastructure/config/settings.py @@ -140,23 +140,17 @@ class CacheSettings(BaseSettings): class RateLimiterSettings(BaseSettings): """Rate limiter settings. - This class defines settings for rate limiting connections and behavior across - the application. + Rate limiting is provided by crudauth, which is Redis-backed. These settings + configure the enable flag, the default per-path limits, and the Redis + connection the shared limiter client uses. Attributes: RATE_LIMITER_ENABLED: Whether to enable rate limiting. Default is True. - RATE_LIMITER_BACKEND: The rate limiter backend to use. Default is "memcached". - RATE_LIMITER_FAIL_OPEN: Whether to fail open (allow requests) when errors occur. Default is True. # Default rate limit settings DEFAULT_RATE_LIMIT_LIMIT: Default number of requests allowed. Default is 100. DEFAULT_RATE_LIMIT_PERIOD: Default period in seconds. Default is 60. - # Memcached settings - RATE_LIMITER_MEMCACHED_HOST: Memcached server hostname. Default is "localhost". - RATE_LIMITER_MEMCACHED_PORT: Memcached server port. Default is 11211. - RATE_LIMITER_MEMCACHED_POOL_SIZE: Maximum number of connections in the pool. Default is 10. - # Redis settings RATE_LIMITER_REDIS_HOST: Redis server hostname. Default is "localhost". RATE_LIMITER_REDIS_PORT: Redis server port. Default is 6379. @@ -167,16 +161,10 @@ class RateLimiterSettings(BaseSettings): """ RATE_LIMITER_ENABLED: bool = config("RATE_LIMITER_ENABLED", default=True, cast=bool) - RATE_LIMITER_BACKEND: str = config("RATE_LIMITER_BACKEND", default=CacheBackend.MEMCACHED.value) - RATE_LIMITER_FAIL_OPEN: bool = config("RATE_LIMITER_FAIL_OPEN", default=True, cast=bool) DEFAULT_RATE_LIMIT_LIMIT: int = config("DEFAULT_RATE_LIMIT_LIMIT", default=100, cast=int) DEFAULT_RATE_LIMIT_PERIOD: int = config("DEFAULT_RATE_LIMIT_PERIOD", default=60, cast=int) - RATE_LIMITER_MEMCACHED_HOST: str = config("RATE_LIMITER_MEMCACHED_HOST", default="localhost") - RATE_LIMITER_MEMCACHED_PORT: int = config("RATE_LIMITER_MEMCACHED_PORT", default=11211, cast=int) - RATE_LIMITER_MEMCACHED_POOL_SIZE: int = config("RATE_LIMITER_MEMCACHED_POOL_SIZE", default=10, cast=int) - RATE_LIMITER_REDIS_HOST: str = config("RATE_LIMITER_REDIS_HOST", default="localhost") RATE_LIMITER_REDIS_PORT: int = config("RATE_LIMITER_REDIS_PORT", default=6379, cast=int) RATE_LIMITER_REDIS_DB: int = config("RATE_LIMITER_REDIS_DB", default=1, cast=int) @@ -260,6 +248,12 @@ class AuthSettings(BaseSettings): # peer (no proxy). Set to 1 behind a single nginx/Caddy, 2 if Cloudflare is also in front. TRUSTED_PROXY_HOPS: int = config("TRUSTED_PROXY_HOPS", default=0, cast=int) + PASSWORD_MIN_LENGTH: int = config("PASSWORD_MIN_LENGTH", default=8, cast=int) + PASSWORD_REQUIRE_UPPERCASE: bool = config("PASSWORD_REQUIRE_UPPERCASE", default=True, cast=bool) + PASSWORD_REQUIRE_LOWERCASE: bool = config("PASSWORD_REQUIRE_LOWERCASE", default=True, cast=bool) + PASSWORD_REQUIRE_DIGIT: bool = config("PASSWORD_REQUIRE_DIGIT", default=True, cast=bool) + PASSWORD_REQUIRE_SPECIAL: bool = config("PASSWORD_REQUIRE_SPECIAL", default=True, cast=bool) + OAUTH_GOOGLE_CLIENT_ID: str = config("OAUTH_GOOGLE_CLIENT_ID", default="") OAUTH_GOOGLE_CLIENT_SECRET: str = config("OAUTH_GOOGLE_CLIENT_SECRET", default="") OAUTH_GITHUB_CLIENT_ID: str = config("OAUTH_GITHUB_CLIENT_ID", default="") diff --git a/backend/src/infrastructure/rate_limit/__init__.py b/backend/src/infrastructure/rate_limit/__init__.py deleted file mode 100644 index 8b8331ba..00000000 --- a/backend/src/infrastructure/rate_limit/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Rate limiter infrastructure. - -This module contains the rate limiting infrastructure components, including middleware -and backend implementations. -""" - -import importlib.util - -from .base import RateLimiterBackend -from .exceptions import RateLimiterBackendException, RateLimitException -from .initialize import close_rate_limiter, initialize_rate_limiter -from .middleware import RateLimiterMiddleware, _check_rate_limit, check_rate_limit -from .provider import get_count, increment_and_check, rate_limiter_provider, reset -from .utils import sanitize_path - -MEMCACHED_INSTALLED = importlib.util.find_spec("aiomcache") is not None -REDIS_INSTALLED = importlib.util.find_spec("redis") is not None - -__all__ = [ - "RateLimiterMiddleware", - "check_rate_limit", - "_check_rate_limit", - "RateLimitException", - "RateLimiterBackendException", - "rate_limiter_provider", - "increment_and_check", - "get_count", - "reset", - "initialize_rate_limiter", - "close_rate_limiter", - "sanitize_path", - "RateLimiterBackend", - "MEMCACHED_INSTALLED", - "REDIS_INSTALLED", -] diff --git a/backend/src/infrastructure/rate_limit/backends/__init__.py b/backend/src/infrastructure/rate_limit/backends/__init__.py deleted file mode 100644 index c9a54402..00000000 --- a/backend/src/infrastructure/rate_limit/backends/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Rate limiter backend implementations. - -This package contains implementations of rate limiter backends for different storage engines. -""" - -import importlib.util - -MEMCACHED_INSTALLED = importlib.util.find_spec("aiomcache") is not None -REDIS_INSTALLED = importlib.util.find_spec("redis") is not None - -if MEMCACHED_INSTALLED: - from .memcached import MemcachedBackend, MemcachedSettings # noqa: F401 - - __all__ = ["MemcachedBackend", "MemcachedSettings"] -else: - MemcachedBackendType: type | None = None - MemcachedSettingsType: type | None = None - __all__ = [] - -if REDIS_INSTALLED: - from .redis import RedisBackend, RedisSettings # noqa: F401 - - __all__.extend(["RedisBackend", "RedisSettings"]) -else: - RedisBackendType: type | None = None - RedisSettingsType: type | None = None diff --git a/backend/src/infrastructure/rate_limit/backends/memcached.py b/backend/src/infrastructure/rate_limit/backends/memcached.py deleted file mode 100644 index bac102c6..00000000 --- a/backend/src/infrastructure/rate_limit/backends/memcached.py +++ /dev/null @@ -1,179 +0,0 @@ -import hashlib -from datetime import UTC, datetime - -try: - import aiomcache -except ImportError: - raise ImportError( - "The aiomcache package is not installed. " - "Please install it with 'pip install aiomcache' or 'pip install -e \".[memcached]\"'" - ) - -from pydantic import BaseModel - -from ....modules.common.utils.logger import get_logger -from ..base import RateLimiterBackend -from ..exceptions import RateLimiterBackendException - -logger = get_logger(__name__) - - -class MemcachedSettings(BaseModel): - """Settings for Memcached connection. - - This class defines the configuration for connecting to a Memcached server. - - Attributes: - host: Memcached server hostname. Default is "localhost". - port: Memcached server port. Default is 11211. - pool_size: Maximum number of connections in the pool. Default is 10. - connect_timeout: Connection timeout in seconds. Default is 5. - Note: This parameter is not currently used by aiomcache.Client but is - kept for API consistency with other rate limiter backends. - """ - - host: str = "localhost" - port: int = 11211 - pool_size: int = 10 - connect_timeout: int = 5 - - -class MemcachedBackend(RateLimiterBackend): - """Memcached implementation of the rate limiter backend.""" - - def __init__(self, settings: MemcachedSettings | None = None, fail_open: bool = True): - """Initialize the Memcached backend. - - Args: - settings: Memcached connection settings. If None, default settings are used. - fail_open: Whether to fail open (allow requests) when rate limiting errors occur. - Default is True for safety. - """ - super().__init__(fail_open=fail_open) - self.settings = settings or MemcachedSettings() - try: - self.client = aiomcache.Client( - host=self.settings.host, - port=self.settings.port, - pool_size=self.settings.pool_size, - ) - except Exception as e: - logger.error(f"Failed to initialize Memcached client: {e}") - raise RateLimiterBackendException(f"Failed to initialize Memcached client: {e}") - - async def increment_and_check(self, key: str, limit: int, period: int) -> tuple[int, bool]: - """Increment the counter for a key and check if rate limit is exceeded. - - Args: - key: The rate limit key to increment. - limit: Maximum number of requests allowed in the period. - period: Time period in seconds. - - Returns: - Tuple of (current_count, is_rate_limited) where: - - current_count: The current count of requests - - is_rate_limited: True if the rate limit is exceeded, False otherwise - """ - try: - key_hash = hashlib.md5(key.encode()).hexdigest() - current_timestamp = int(datetime.now(UTC).timestamp()) - window_start = current_timestamp - (current_timestamp % period) - rate_limit_key = f"{key_hash}:{window_start}".encode() - - value = await self.client.get(rate_limit_key) - current_count = int(value.decode()) if value else 0 - - current_count += 1 - await self.client.set(rate_limit_key, str(current_count).encode(), exptime=period) - - is_rate_limited = current_count > limit - return current_count, is_rate_limited - - except Exception as e: - logger.error(f"Error checking rate limit for key {key}: {e}") - return 0, not self.fail_open - - async def get_count(self, key: str) -> int | None: - """Get the current count for a key. - - Args: - key: The rate limit key to check. - - Returns: - The current count or None if the key doesn't exist. - """ - try: - value = await self.client.get(key.encode()) - if value: - return int(value.decode()) - return None - except Exception as e: - logger.error(f"Error getting rate limit count for key {key}: {e}") - return None - - async def reset(self, key: str) -> None: - """Reset the counter for a key. - - Args: - key: The rate limit key to reset. - """ - try: - await self.client.delete(key.encode()) - except Exception as e: - logger.error(f"Error resetting rate limit for key {key}: {e}") - - async def increment(self, key: str, amount: int = 1, expiry: int = 300) -> int: - """Increment a counter by the given amount and set expiry. - - Args: - key: The key to increment - amount: Amount to increment by - expiry: Time in seconds for the key to expire - - Returns: - The new value after incrementing - """ - try: - key_bytes = key.encode() - value = await self.client.get(key_bytes) - current_count = int(value.decode()) if value else 0 - - new_count = current_count + amount - await self.client.set(key_bytes, str(new_count).encode(), exptime=expiry) - - return new_count - except Exception as e: - logger.error(f"Error incrementing count for key {key}: {e}") - return 0 - - async def delete(self, key: str) -> bool: - """Delete a key. - - Args: - key: The key to delete - - Returns: - True if deleted, False otherwise - """ - try: - await self.client.delete(key.encode()) - return True - except Exception as e: - logger.error(f"Error deleting key {key}: {e}") - return False - - async def ping(self) -> bool: - """Check if the rate limiter backend is available. - - Returns: - True if the backend is available, False otherwise. - """ - try: - test_key = b"rate_limiter_ping_test" - test_value = b"1" - await self.client.set(test_key, test_value, exptime=1) - result = await self.client.get(test_key) - return bool(result == test_value) - except Exception as e: - logger.error(f"Failed to ping Memcached server: {e}") - return False diff --git a/backend/src/infrastructure/rate_limit/backends/redis.py b/backend/src/infrastructure/rate_limit/backends/redis.py deleted file mode 100644 index 397ddb68..00000000 --- a/backend/src/infrastructure/rate_limit/backends/redis.py +++ /dev/null @@ -1,192 +0,0 @@ -from datetime import UTC, datetime - -try: - from redis.asyncio import Redis - from redis.exceptions import RedisError -except ImportError: - raise ImportError( - "The redis package is not installed. Please install it with 'pip install redis' or 'pip install -e \".[redis]\"'" - ) - -from pydantic import BaseModel - -from ....modules.common.utils.logger import get_logger -from ..base import RateLimiterBackend -from ..exceptions import RateLimiterBackendException - -logger = get_logger(__name__) - - -class RedisSettings(BaseModel): - """Settings for Redis connection. - - This class defines the configuration for connecting to a Redis server. - - Attributes: - host: Redis server hostname. Default is "localhost". - port: Redis server port. Default is 6379. - db: Redis database number. Default is 0. - password: Redis server password. Default is None. - connect_timeout: Connection timeout in seconds. Default is 5. - pool_size: Maximum number of connections in the pool. Default is 10. - """ - - host: str = "localhost" - port: int = 6379 - db: int = 0 - password: str | None = None - connect_timeout: int = 5 - pool_size: int = 10 - - -class RedisBackend(RateLimiterBackend): - """Redis implementation of the rate limiter backend.""" - - def __init__(self, settings: RedisSettings | None = None, fail_open: bool = True): - """Initialize the Redis backend. - - Args: - settings: Redis connection settings. If None, default settings are used. - fail_open: Whether to fail open (allow requests) when rate limiting errors occur. - Default is True for safety. - """ - super().__init__(fail_open=fail_open) - self.settings = settings or RedisSettings() - try: - self.client = Redis( - host=self.settings.host, - port=self.settings.port, - db=self.settings.db, - password=self.settings.password, - socket_timeout=self.settings.connect_timeout, - socket_connect_timeout=self.settings.connect_timeout, - socket_keepalive=True, - decode_responses=True, - max_connections=self.settings.pool_size, - ) - except Exception as e: - logger.error(f"Failed to initialize Redis client: {e}") - raise RateLimiterBackendException(f"Failed to initialize Redis client: {e}") - - async def increment_and_check(self, key: str, limit: int, period: int) -> tuple[int, bool]: - """Increment the counter for a key and check if rate limit is exceeded. - - Args: - key: The rate limit key to increment. - limit: Maximum number of requests allowed in the period. - period: Time period in seconds. - - Returns: - Tuple of (current_count, is_rate_limited) where: - - current_count: The current count of requests - - is_rate_limited: True if the rate limit is exceeded, False otherwise - """ - try: - current_timestamp = int(datetime.now(UTC).timestamp()) - window_start = current_timestamp - (current_timestamp % period) - rate_limit_key = f"{key}:{window_start}" - - pipe = self.client.pipeline() - pipe.incr(rate_limit_key) - pipe.expire(rate_limit_key, int(period)) - result = await pipe.execute() - - current_count = result[0] - - is_rate_limited = current_count > limit - return current_count, is_rate_limited - - except RedisError as e: - logger.error(f"Redis error checking rate limit for key {key}: {e}") - return 0, not self.fail_open - except Exception as e: - logger.error(f"Error checking rate limit for key {key}: {e}") - return 0, not self.fail_open - - async def get_count(self, key: str) -> int | None: - """Get the current count for a key. - - Args: - key: The rate limit key to check. - - Returns: - The current count or None if the key doesn't exist. - """ - try: - value = await self.client.get(key) - if value: - return int(value) - return None - except Exception as e: - logger.error(f"Error getting rate limit count for key {key}: {e}") - return None - - async def reset(self, key: str) -> None: - """Reset the counter for a key. - - Args: - key: The rate limit key to reset. - """ - try: - await self.client.delete(key) - except Exception as e: - logger.error(f"Error resetting rate limit for key {key}: {e}") - - async def increment(self, key: str, amount: int = 1, expiry: int = 300) -> int: - """Increment a counter by the given amount and set expiry. - - Args: - key: The key to increment - amount: Amount to increment by - expiry: Time in seconds for the key to expire - - Returns: - The new value after incrementing - """ - try: - expiry_int = int(expiry) - - pipe = self.client.pipeline() - pipe.incrby(key, amount) - pipe.expire(key, expiry_int) - result: list[int] = await pipe.execute() - return result[0] - except RedisError as e: - logger.error(f"Redis error incrementing count for key {key}: {type(e).__name__}: {str(e)}") - if not self.fail_open: - raise RateLimiterBackendException(f"Redis pipeline failed for key {key}") - return 0 - except Exception as e: - logger.error(f"Error incrementing count for key {key}: {type(e).__name__}: {str(e)}") - if not self.fail_open: - raise RateLimiterBackendException(f"Unexpected error for key {key}") - return 0 - - async def delete(self, key: str) -> bool: - """Delete a key. - - Args: - key: The key to delete - - Returns: - True if deleted, False otherwise - """ - try: - result = await self.client.delete(key) - return bool(result) - except Exception as e: - logger.error(f"Error deleting key {key}: {e}") - return False - - async def ping(self) -> bool: - """Check if the rate limiter backend is available. - - Returns: - True if the backend is available, False otherwise. - """ - try: - result = await self.client.ping() # type: ignore[misc] - return bool(result) - except Exception as e: - logger.error(f"Failed to ping Redis server: {e}") - return False diff --git a/backend/src/infrastructure/rate_limit/base.py b/backend/src/infrastructure/rate_limit/base.py deleted file mode 100644 index deb56cdd..00000000 --- a/backend/src/infrastructure/rate_limit/base.py +++ /dev/null @@ -1,252 +0,0 @@ -from abc import ABC, abstractmethod - - -class RateLimiterBackend(ABC): - """Abstract base class for rate limiter backends with comprehensive interface. - - Defines the standard interface that all rate limiter backend implementations - must follow, providing consistent rate limiting operations across different - backend technologies like Redis, Memcached, or in-memory storage. - - This abstract base class ensures: - - Consistent API across different rate limiter implementations - - Flexible failure handling with fail-open/fail-closed policies - - Comprehensive rate limiting operations including counters and resets - - Health checking and connection management - - Proper error handling patterns - - Implementations should handle: - - Thread-safe counter operations - - Atomic increment-and-check operations - - Connection management and retries - - Backend-specific optimizations - - Error handling and fallback behavior - - Example: - ```python - class RedisRateLimiterBackend(RateLimiterBackend): - async def increment_and_check(self, key: str, limit: int, period: int) -> tuple[int, bool]: - try: - count = await self.redis.incr(key) - if count == 1: - await self.redis.expire(key, period) - return count, count > limit - except ConnectionError: - return (0, False) if self.fail_open else (limit + 1, True) - ``` - """ - - def __init__(self, fail_open: bool = True): - """Initialize the rate limiter backend with failure handling policy. - - Args: - fail_open: Whether to fail open (allow requests) when rate limiting - errors occur. If True, allows requests when backend is unavailable. - If False, blocks requests when backend errors occur. - Default is True for safety and availability. - - Note: - Fail-open vs fail-closed policies: - - Fail-open: Prioritizes availability over strict rate limiting - - Fail-closed: Prioritizes security over availability - - Choose based on your application's requirements: - - Critical APIs may prefer fail-closed for security - - Public APIs may prefer fail-open for availability - """ - self.fail_open = fail_open - - @abstractmethod - async def increment_and_check(self, key: str, limit: int, period: int) -> tuple[int, bool]: - """Increment the counter for a key and check if rate limit is exceeded. - - Performs an atomic increment-and-check operation to determine if a - request should be rate limited. This is the core operation for most - rate limiting scenarios. - - Args: - key: The rate limit key to increment. Should be unique per user/IP/resource. - limit: Maximum number of requests allowed in the period. - period: Time period in seconds for the rate limit window. - - Returns: - Tuple of (current_count, is_rate_limited) where: - - current_count: The current count of requests in the window - - is_rate_limited: True if the rate limit is exceeded, False otherwise - - Note: - Implementation should handle: - - Atomic increment operations to prevent race conditions - - Automatic key expiration after the period - - Connection errors according to fail_open policy - - Efficient sliding window or fixed window algorithms - - Example: - ```python - # Check if user can make a request (10 requests per minute) - count, is_limited = await backend.increment_and_check( - key="user:123:api_calls", - limit=10, - period=60 - ) - - if is_limited: - raise RateLimitException(f"Rate limit exceeded. {count}/{limit} requests used.") - ``` - """ - pass - - @abstractmethod - async def get_count(self, key: str) -> int | None: - """Get the current count for a rate limit key. - - Retrieves the current count without incrementing it. Useful for - monitoring, dashboards, and providing rate limit information to clients. - - Args: - key: The rate limit key to check. - - Returns: - The current count or None if the key doesn't exist or has expired. - - Note: - Implementation should handle: - - Key normalization and validation - - Expired key cleanup where applicable - - Connection errors gracefully (return None) - - Efficient read operations - - Example: - ```python - # Check current usage for rate limit headers - current_count = await backend.get_count("user:123:api_calls") - if current_count is not None: - remaining = max(0, limit - current_count) - headers["X-RateLimit-Remaining"] = str(remaining) - ``` - """ - pass - - @abstractmethod - async def reset(self, key: str) -> None: - """Reset the counter for a specific rate limit key. - - Removes or resets the counter for a given key, effectively clearing - the rate limit for that key. Useful for administrative actions, - premium users, or error recovery. - - Args: - key: The rate limit key to reset. - - Note: - Implementation should handle: - - Key normalization and validation - - Idempotent deletion (no error if key doesn't exist) - - Connection errors gracefully - - Cleanup of any related metadata - - Example: - ```python - # Reset rate limit for premium user - await backend.reset("user:123:api_calls") - - # Reset after resolving user issue - await backend.reset(f"user:{user_id}:failed_logins") - ``` - """ - pass - - @abstractmethod - async def increment(self, key: str, amount: int = 1, expiry: int = 300) -> int: - """Increment a counter by the given amount and set expiry. - - Provides flexible counter increment operations with configurable - expiry times. Useful for custom rate limiting scenarios and - batched operations. - - Args: - key: The key to increment. - amount: Amount to increment by (default: 1). - expiry: Time in seconds for the key to expire (default: 300). - - Returns: - The new value after incrementing. - - Note: - Implementation should handle: - - Atomic increment operations - - Automatic key expiration - - Connection errors according to fail_open policy - - Efficient batch operations for multiple increments - - Example: - ```python - # Increment by custom amount for bulk operations - new_count = await backend.increment( - key="user:123:bulk_uploads", - amount=10, # 10 files uploaded - expiry=3600 # 1 hour window - ) - ``` - """ - pass - - @abstractmethod - async def delete(self, key: str) -> bool: - """Delete a rate limit key. - - Removes a specific key from the rate limiter backend. Similar to reset - but returns information about whether the key existed. - - Args: - key: The key to delete. - - Returns: - True if the key existed and was deleted, False otherwise. - - Note: - Implementation should handle: - - Key normalization and validation - - Atomic deletion operations - - Connection errors gracefully - - Cleanup of any related metadata - - Example: - ```python - # Clean up expired user session - existed = await backend.delete("user:123:session_requests") - if existed: - logger.info("Cleaned up rate limit data for user session") - ``` - """ - pass - - @abstractmethod - async def ping(self) -> bool: - """Check if the rate limiter backend is available and responsive. - - Performs a health check on the rate limiter backend to determine if it's - available and responding to requests. This is essential for monitoring - and graceful degradation. - - Returns: - True if the backend is available and responsive, False otherwise. - - Note: - Implementation should handle: - - Quick connectivity test - - Timeout handling for unresponsive backends - - Authentication validation - - Minimal resource usage for health checks - - Example: - ```python - # Health check in monitoring system - if await backend.ping(): - metrics.gauge("rate_limiter.health", 1) - else: - metrics.gauge("rate_limiter.health", 0) - logger.warning("Rate limiter backend is unavailable") - ``` - """ - pass diff --git a/backend/src/infrastructure/rate_limit/exceptions.py b/backend/src/infrastructure/rate_limit/exceptions.py deleted file mode 100644 index 4f432323..00000000 --- a/backend/src/infrastructure/rate_limit/exceptions.py +++ /dev/null @@ -1,156 +0,0 @@ -from fastapi import HTTPException, status - - -class RateLimitException(HTTPException): - """Exception raised when a rate limit is exceeded. - - This HTTP exception is thrown when a client exceeds their allowed request - rate, providing appropriate HTTP status code and headers for rate limiting. - - The exception automatically sets: - - HTTP 429 (Too Many Requests) status code - - Retry-After header indicating when to retry - - Detailed error message for the client - - Args: - detail: Custom error message describing the rate limit violation. - Defaults to "Rate limit exceeded". - - Note: - This exception follows RFC 6585 standards for HTTP 429 responses. - The Retry-After header helps clients implement proper backoff strategies. - - Consider including additional information in the detail message: - - Current rate limit values - - Time until reset - - Suggested retry intervals - - Example: - ```python - # Basic rate limit exceeded - raise RateLimitException("Rate limit exceeded") - - # With detailed information - raise RateLimitException( - f"Rate limit exceeded. {count}/{limit} requests used. " - f"Try again in {period} seconds." - ) - - # In middleware or endpoint - try: - await rate_limiter.check_limit(user_id, endpoint) - except RateLimitException as e: - logger.warning(f"Rate limit exceeded for user {user_id}: {e}") - raise - ``` - """ - - def __init__(self, detail: str = "Rate limit exceeded"): - super().__init__( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=detail, - headers={"Retry-After": "60"}, - ) - - -class RateLimiterBackendException(Exception): - """Base exception for rate limiter backend errors. - - Serves as the parent class for all rate limiter backend-specific exceptions, - providing a common interface for error handling throughout the rate limiting - infrastructure. - - Args: - message: Detailed error message describing the backend failure. - - Note: - This base class should not be raised directly. Instead, use - specific subclasses that better describe the type of backend error. - - All rate limiter backend exceptions inherit from this base class, - allowing for comprehensive error handling with a single exception - type when needed. - - Example: - ```python - try: - # Rate limiter backend operations - await backend.increment_and_check(key, limit, period) - except RateLimiterBackendException as e: - logger.error(f"Rate limiter backend error: {e}") - # Handle any backend-related error - ``` - """ - - def __init__(self, message: str = "Rate limiter backend error"): - self.message = message - super().__init__(self.message) - - -class BackendNotFoundError(RateLimiterBackendException): - """Raised when a requested rate limiter backend is not found. - - This exception occurs when trying to use a rate limiter backend that hasn't - been registered with the rate limiter provider or doesn't exist in the - backend registry. - - Args: - backend_name: The name of the backend that was not found. - - Note: - This exception typically indicates: - - Backend name typo in configuration - - Backend not properly registered during initialization - - Missing backend dependencies or imports - - Configuration mismatch between environments - - Example: - ```python - try: - backend = rate_limiter_provider.get_backend("nonexistent_backend") - except BackendNotFoundError as e: - logger.error(f"Rate limiter backend not found: {e}") - # Fall back to default backend or raise configuration error - ``` - """ - - def __init__(self, backend_name: str): - self.message = f"Rate limiter backend '{backend_name}' not found." - super().__init__(self.message) - - -class BackendInitializationError(RateLimiterBackendException): - """Raised when a rate limiter backend fails to initialize. - - This exception occurs when a backend cannot be properly initialized due to - configuration errors, connection failures, or missing dependencies. - - Args: - backend_name: The name of the backend that failed to initialize. - reason: The specific reason why initialization failed. - - Note: - This exception typically indicates: - - Invalid configuration parameters - - Network connectivity issues - - Missing credentials or authentication failures - - Backend service unavailability - - Resource constraints or permission issues - - Example: - ```python - try: - redis_backend = RedisRateLimiterBackend( - host="invalid_host", - port=6379 - ) - await redis_backend.initialize() - except BackendInitializationError as e: - logger.error(f"Failed to initialize rate limiter: {e}") - # Try alternative backend or raise startup error - ``` - """ - - def __init__(self, backend_name: str, reason: str): - self.message = f"Failed to initialize rate limiter backend '{backend_name}': {reason}" - super().__init__(self.message) diff --git a/backend/src/infrastructure/rate_limit/initialize.py b/backend/src/infrastructure/rate_limit/initialize.py deleted file mode 100644 index a8081aa8..00000000 --- a/backend/src/infrastructure/rate_limit/initialize.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Module for initializing the rate limiter backends.""" - -import importlib.util - -from ..config import CacheBackend, get_settings -from .provider import rate_limiter_provider - -MEMCACHED_INSTALLED = importlib.util.find_spec("aiomcache") is not None -REDIS_INSTALLED = importlib.util.find_spec("redis") is not None - -if MEMCACHED_INSTALLED: - from .backends import MemcachedBackend, MemcachedSettings - -if REDIS_INSTALLED: - from .backends import RedisBackend, RedisSettings - - -async def initialize_rate_limiter() -> None: - """Initialize the rate limiter backends. - - This function initializes the rate limiter backends based on the application settings. - It is called during application startup. - """ - settings = get_settings() - - if not settings.RATE_LIMITER_ENABLED: - return - - if settings.RATE_LIMITER_BACKEND == CacheBackend.MEMCACHED.value: - if not MEMCACHED_INSTALLED: - raise ImportError("The aiomcache package is not installed. Please install it with 'pip install aiomcache'.") - - memcached_settings = MemcachedSettings( - host=settings.RATE_LIMITER_MEMCACHED_HOST, - port=settings.RATE_LIMITER_MEMCACHED_PORT, - pool_size=settings.RATE_LIMITER_MEMCACHED_POOL_SIZE, - ) - memcached_backend = MemcachedBackend(settings=memcached_settings, fail_open=settings.RATE_LIMITER_FAIL_OPEN) - rate_limiter_provider.register_backend(CacheBackend.MEMCACHED.value, memcached_backend, default=True) - - elif settings.RATE_LIMITER_BACKEND == CacheBackend.REDIS.value: - if not REDIS_INSTALLED: - raise ImportError("The redis package is not installed. Please install it with 'pip install redis'.") - - redis_settings = RedisSettings( - host=settings.RATE_LIMITER_REDIS_HOST, - port=settings.RATE_LIMITER_REDIS_PORT, - db=settings.RATE_LIMITER_REDIS_DB, - password=settings.RATE_LIMITER_REDIS_PASSWORD, - connect_timeout=settings.RATE_LIMITER_REDIS_CONNECT_TIMEOUT, - pool_size=settings.RATE_LIMITER_REDIS_POOL_SIZE, - ) - redis_backend = RedisBackend(settings=redis_settings, fail_open=settings.RATE_LIMITER_FAIL_OPEN) - rate_limiter_provider.register_backend(CacheBackend.REDIS.value, redis_backend, default=True) - - -async def close_rate_limiter() -> None: - """Close all rate limiter connections. - - This function should be called during application shutdown to clean up resources. - """ - settings = get_settings() - - if not settings.RATE_LIMITER_ENABLED: - return - - if settings.RATE_LIMITER_BACKEND == CacheBackend.MEMCACHED.value and MEMCACHED_INSTALLED: - backend = rate_limiter_provider.get_backend(CacheBackend.MEMCACHED.value) - if hasattr(backend, "client") and hasattr(backend.client, "close"): - await backend.client.close() - - elif settings.RATE_LIMITER_BACKEND == CacheBackend.REDIS.value and REDIS_INSTALLED: - backend = rate_limiter_provider.get_backend(CacheBackend.REDIS.value) - if hasattr(backend, "client") and hasattr(backend.client, "close"): - await backend.client.close() diff --git a/backend/src/infrastructure/rate_limit/middleware.py b/backend/src/infrastructure/rate_limit/middleware.py deleted file mode 100644 index 0e310e95..00000000 --- a/backend/src/infrastructure/rate_limit/middleware.py +++ /dev/null @@ -1,151 +0,0 @@ -from collections.abc import Callable -from typing import Any, cast - -from fastapi import Depends, Request -from sqlalchemy.ext.asyncio import AsyncSession -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import Response - -from ...modules.common.utils.logger import get_logger -from ...modules.rate_limit.crud import crud_rate_limits -from ...modules.rate_limit.schemas import RateLimitSelect -from ...modules.tier.crud import crud_tiers -from ...modules.tier.schemas import TierSelect -from ..config import get_settings -from ..database import async_session -from .exceptions import RateLimitException -from .provider import increment_and_check -from .utils import sanitize_path - -logger = get_logger(__name__) - -settings = get_settings() -DEFAULT_LIMIT = settings.DEFAULT_RATE_LIMIT_LIMIT -DEFAULT_PERIOD = settings.DEFAULT_RATE_LIMIT_PERIOD - - -async def get_optional_user(request: Request) -> dict[str, Any] | None: - """Get the current user from the request, or None if not authenticated. - - This is a simplified version that assumes the user is stored in request.state.user. - In a real application, you would need to implement proper user extraction from - authentication tokens. - """ - if hasattr(request.state, "user"): - return cast(dict[str, Any], request.state.user) - return None - - -async def _check_rate_limit(request: Request, db: AsyncSession, user: dict[str, Any] | None = None) -> None: - """Internal implementation of check_rate_limit without FastAPI dependency injection. - - Args: - request: The current request. - db: The database session. - user: The authenticated user, or None if not authenticated. - - Raises: - RateLimitException: If the rate limit is exceeded. - """ - if not settings.RATE_LIMITER_ENABLED: - return - - if hasattr(request.app.state, "initialization_complete"): - await request.app.state.initialization_complete.wait() - - original_path = request.url.path - sanitized_path = sanitize_path(original_path) - - if user: - user_id = user["id"] - tier = await crud_tiers.get(db=db, id=user["tier_id"], schema_to_select=TierSelect) - - if tier: - rate_limit = await crud_rate_limits.get( - db=db, tier_id=tier["id"], path=sanitized_path, schema_to_select=RateLimitSelect - ) - - if not rate_limit: - rate_limit = await crud_rate_limits.get( - db=db, tier_id=tier["id"], path=original_path, schema_to_select=RateLimitSelect - ) - - if rate_limit: - limit, period = rate_limit["limit"], rate_limit["period"] - else: - logger.warning( - f"User {user_id} with tier '{tier['name']}' has no specific rate limit for path '{original_path}'. " - "Applying default rate limit." - ) - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD - else: - logger.warning(f"User {user_id} has no assigned tier. Applying default rate limit.") - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD - else: - user_id = request.client.host if request.client and hasattr(request.client, "host") else "unknown" - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD - - key = f"ratelimit:{user_id}:{sanitized_path}" - - try: - count, is_limited = await increment_and_check( - key=key, limit=limit, period=period, fail_open=settings.RATE_LIMITER_FAIL_OPEN - ) - - request.state.rate_limit_headers = { - "X-RateLimit-Limit": str(limit), - "X-RateLimit-Remaining": str(max(0, limit - count)), - "X-RateLimit-Reset": str(period), - } - - if is_limited: - logger.warning(f"Rate limit exceeded for {user_id} on path {sanitized_path}. Count: {count}, Limit: {limit}") - raise RateLimitException(f"Rate limit exceeded. Try again in {period} seconds.") - - except RateLimitException: - raise - except Exception as e: - logger.error(f"Error checking rate limit for {user_id} on path {sanitized_path}: {e}") - if not settings.RATE_LIMITER_FAIL_OPEN: - logger.warning("Blocking request due to fail-closed policy") - raise RateLimitException("Error checking rate limit. Access denied as a precaution.") - - -async def check_rate_limit( - request: Request, - db: AsyncSession = Depends(async_session), - user: dict[str, Any] | None = Depends(get_optional_user), -) -> None: - """Check if the current request exceeds rate limits. - - Args: - request: The current request. - db: The database session. - user: The authenticated user, or None if not authenticated. - - Raises: - RateLimitException: If the rate limit is exceeded. - """ - await _check_rate_limit(request, db, user) - - -class RateLimiterMiddleware(BaseHTTPMiddleware): - """Middleware for applying rate limits to all requests.""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - """Process a request through the middleware. - - Args: - request: The incoming request. - call_next: The next middleware or handler in the chain. - - Returns: - The response from the next middleware or handler. - """ - response = await call_next(request) - - if hasattr(request.state, "rate_limit_headers"): - for key, value in request.state.rate_limit_headers.items(): - response.headers[key] = value - - return cast(Response, response) diff --git a/backend/src/infrastructure/rate_limit/provider.py b/backend/src/infrastructure/rate_limit/provider.py deleted file mode 100644 index 1e85c265..00000000 --- a/backend/src/infrastructure/rate_limit/provider.py +++ /dev/null @@ -1,369 +0,0 @@ -from .base import RateLimiterBackend -from .exceptions import BackendNotFoundError - - -class RateLimiterProvider: - """Provider for rate limiter backends with comprehensive backend management. - - This class manages multiple rate limiter backends and provides a centralized - access point for all rate limiting operations. It supports dynamic backend - registration, switching between backends, and health monitoring. - - The provider enables: - - Multi-backend support for different use cases - - Dynamic backend switching based on configuration - - Health monitoring and fallback strategies - - Centralized rate limiter configuration management - - Example: - ```python - # Initialize provider and register backends - provider = RateLimiterProvider() - - # Register Redis backend for production - redis_backend = RedisRateLimiterBackend(host="redis.example.com") - provider.register_backend("redis", redis_backend, default=True) - - # Register in-memory backend for testing - memory_backend = MemoryRateLimiterBackend() - provider.register_backend("memory", memory_backend) - - # Use the provider - backend = provider.get_backend("redis") - count, is_limited = await backend.increment_and_check("user:123", 10, 60) - ``` - """ - - def __init__(self) -> None: - """Initialize the rate limiter provider. - - Creates an empty provider with no registered backends. Backends must be - registered before use via register_backend(). - - Note: - The provider starts with no default backend. The first registered - backend becomes the default, or you can explicitly set a default - using the default=True parameter in register_backend(). - """ - self._backends: dict[str, RateLimiterBackend] = {} - self._default_backend: str | None = None - - def register_backend(self, name: str, backend: RateLimiterBackend, default: bool = False) -> None: - """Register a rate limiter backend with the provider. - - Adds a backend to the provider's registry, making it available for - rate limiting operations. Optionally sets the backend as the default. - - Args: - name: The name of the backend for identification and retrieval. - backend: The backend instance to register. - default: Whether this backend should be the default. If True, or if - no default is set, this backend becomes the default. - - Note: - Backend names should be unique within the provider. Registering - a backend with an existing name will replace the previous backend. - - The first registered backend automatically becomes the default - unless explicitly overridden. - - Example: - ```python - # Register primary Redis backend - provider.register_backend( - "redis-primary", - RedisRateLimiterBackend(host="redis-primary.example.com"), - default=True - ) - - # Register backup Redis backend - provider.register_backend( - "redis-backup", - RedisRateLimiterBackend(host="redis-backup.example.com") - ) - ``` - """ - self._backends[name] = backend - if default or self._default_backend is None: - self._default_backend = name - - def get_backend(self, name: str | None = None) -> RateLimiterBackend: - """Get a rate limiter backend by name. - - Retrieves a registered backend by name, or returns the default backend - if no name is specified. - - Args: - name: The name of the backend to retrieve. If None, returns the - default backend. - - Returns: - The requested rate limiter backend. - - Raises: - BackendNotFoundError: If the requested backend is not found or - no default backend is available. - - Example: - ```python - # Get default backend - default_backend = provider.get_backend() - - # Get specific backend - redis_backend = provider.get_backend("redis") - - # Handle missing backend - try: - backend = provider.get_backend("nonexistent") - except BackendNotFoundError: - backend = provider.get_backend() # Fall back to default - ``` - """ - backend_name = name or self._default_backend - if not backend_name or backend_name not in self._backends: - raise BackendNotFoundError(backend_name or "default") - return self._backends[backend_name] - - def set_default_backend(self, name: str) -> None: - """Set the default backend for the provider. - - Changes the default backend to the specified registered backend. - The default backend is used when no specific backend is requested. - - Args: - name: The name of the backend to set as default. - - Raises: - BackendNotFoundError: If the requested backend is not found. - - Example: - ```python - # Switch to backup backend as default - provider.set_default_backend("redis-backup") - - # Now all default operations use the backup backend - backend = provider.get_backend() # Returns redis-backup - ``` - """ - if name not in self._backends: - raise BackendNotFoundError(name) - self._default_backend = name - - async def ping_all(self) -> dict[str, bool]: - """Ping all registered backends to check their availability. - - Performs health checks on all registered backends to determine their - current availability status. This is useful for monitoring, alerting, - and automatic failover decisions. - - Returns: - A dictionary mapping backend names to their availability status. - True indicates the backend is available, False indicates it's not. - - Example: - ```python - # Check all backend health - health_status = await provider.ping_all() - - # Log unhealthy backends - for backend_name, is_healthy in health_status.items(): - if not is_healthy: - logger.warning(f"Backend {backend_name} is unhealthy") - - # Find healthy backends - healthy_backends = [name for name, status in health_status.items() if status] - ``` - """ - results = {} - for name, backend in self._backends.items(): - results[name] = await backend.ping() - return results - - def list_backends(self) -> dict[str, type[RateLimiterBackend]]: - """List all registered backends with their types. - - Returns information about all registered backends, including their - implementation types. Useful for debugging, monitoring, and - administrative interfaces. - - Returns: - A dictionary mapping backend names to their implementation types. - - Example: - ```python - # List all backends - backends = provider.list_backends() - for name, backend_type in backends.items(): - print(f"Backend: {name}, Type: {backend_type.__name__}") - - # Filter for Redis backends - redis_backends = { - name: backend_type for name, backend_type in backends.items() - if "Redis" in backend_type.__name__ - } - ``` - """ - return {name: type(backend) for name, backend in self._backends.items()} - - @property - def default_backend_name(self) -> str | None: - """Get the name of the default backend. - - Returns: - The name of the default backend, or None if no default backend is set. - - Example: - ```python - # Check current default backend - default_name = provider.default_backend_name - if default_name: - print(f"Default backend: {default_name}") - else: - print("No default backend configured") - ``` - """ - return self._default_backend - - -rate_limiter_provider = RateLimiterProvider() - - -def get_rate_limiter_backend(backend_name: str | None = None) -> RateLimiterBackend: - """Get a rate limiter backend by name from the global provider. - - This is a convenience function to get a rate limiter backend from the - global provider instance. It provides a simple interface for accessing - rate limiter backends throughout the application. - - Args: - backend_name: The name of the backend to get. If None, the default - backend is used. - - Returns: - The requested rate limiter backend. - - Raises: - BackendNotFoundError: If the requested backend is not found. - - Example: - ```python - # Get default backend - backend = get_rate_limiter_backend() - - # Get specific backend - redis_backend = get_rate_limiter_backend("redis") - - # Use in dependency injection - async def rate_limited_endpoint( - backend: RateLimiterBackend = Depends(get_rate_limiter_backend) - ): - count, is_limited = await backend.increment_and_check("api_calls", 100, 3600) - ``` - """ - return rate_limiter_provider.get_backend(backend_name) - - -async def increment_and_check( - key: str, limit: int, period: int, backend_name: str | None = None, fail_open: bool | None = None -) -> tuple[int, bool]: - """Increment the counter for a key and check if rate limit is exceeded. - - Convenience function that combines backend retrieval and rate limit checking - in a single operation. Supports temporary fail-open policy overrides. - - Args: - key: The rate limit key to increment. - limit: Maximum number of requests allowed in the period. - period: Time period in seconds. - backend_name: The name of the backend to use. If None, the default - backend is used. - fail_open: Whether to fail open if an error occurs. If None, uses - the backend's configured setting. - - Returns: - Tuple of (current_count, is_rate_limited) where: - - current_count: The current count of requests - - is_rate_limited: True if the rate limit is exceeded, False otherwise - - Example: - ```python - # Basic rate limit check - count, is_limited = await increment_and_check( - key="user:123:api_calls", - limit=100, - period=3600 - ) - - # With specific backend and fail-open override - count, is_limited = await increment_and_check( - key="user:123:critical_api", - limit=10, - period=60, - backend_name="redis-primary", - fail_open=False # Strict enforcement - ) - ``` - """ - backend = rate_limiter_provider.get_backend(backend_name) - - original_fail_open = None - if fail_open is not None and fail_open != backend.fail_open: - original_fail_open = backend.fail_open - backend.fail_open = fail_open - - try: - return await backend.increment_and_check(key, limit, period) - finally: - if original_fail_open is not None: - backend.fail_open = original_fail_open - - -async def get_count(key: str, backend_name: str | None = None) -> int | None: - """Get the current count for a key from the specified backend. - - Convenience function to get the current count for a rate limit key - without incrementing it. - - Args: - key: The rate limit key to check. - backend_name: The name of the backend to use. If None, the default - backend is used. - - Returns: - The current count or None if the key doesn't exist. - - Example: - ```python - # Check current usage - current_count = await get_count("user:123:api_calls") - if current_count is not None: - remaining = max(0, limit - current_count) - print(f"Remaining requests: {remaining}") - ``` - """ - backend = rate_limiter_provider.get_backend(backend_name) - return await backend.get_count(key) - - -async def reset(key: str, backend_name: str | None = None) -> None: - """Reset the counter for a key using the specified backend. - - Convenience function to reset a rate limit counter, effectively - clearing the rate limit for that key. - - Args: - key: The rate limit key to reset. - backend_name: The name of the backend to use. If None, the default - backend is used. - - Example: - ```python - # Reset rate limit for premium user - await reset("user:123:api_calls") - - # Reset after resolving issue - await reset("user:123:failed_logins", backend_name="redis-primary") - ``` - """ - backend = rate_limiter_provider.get_backend(backend_name) - await backend.reset(key) diff --git a/backend/src/infrastructure/rate_limit/utils.py b/backend/src/infrastructure/rate_limit/utils.py deleted file mode 100644 index 18698987..00000000 --- a/backend/src/infrastructure/rate_limit/utils.py +++ /dev/null @@ -1,3 +0,0 @@ -def sanitize_path(path: str) -> str: - """Sanitize API path for use in rate limiting keys.""" - return path.strip("/").replace("/", "_") diff --git a/backend/src/infrastructure/redis.py b/backend/src/infrastructure/redis.py new file mode 100644 index 00000000..da996027 --- /dev/null +++ b/backend/src/infrastructure/redis.py @@ -0,0 +1,38 @@ +"""Redis clients shared by application infrastructure.""" + +from redis.asyncio import Redis + +from .config.settings import get_settings + +settings = get_settings() + + +def _client(host: str, port: int, db: int, password: str | None, pool_size: int, timeout: int) -> Redis: + return Redis( + host=host, + port=port, + db=db, + password=password, + socket_timeout=timeout, + max_connections=pool_size, + decode_responses=False, + ) + + +cache_redis_client = _client( + settings.CACHE_REDIS_HOST, + settings.CACHE_REDIS_PORT, + settings.CACHE_REDIS_DB, + settings.CACHE_REDIS_PASSWORD, + settings.CACHE_REDIS_POOL_SIZE, + settings.CACHE_REDIS_CONNECT_TIMEOUT, +) + +rate_limiter_redis_client = _client( + settings.RATE_LIMITER_REDIS_HOST, + settings.RATE_LIMITER_REDIS_PORT, + settings.RATE_LIMITER_REDIS_DB, + settings.RATE_LIMITER_REDIS_PASSWORD, + settings.RATE_LIMITER_REDIS_POOL_SIZE, + settings.RATE_LIMITER_REDIS_CONNECT_TIMEOUT, +) diff --git a/backend/src/infrastructure/security/production_validator.py b/backend/src/infrastructure/security/production_validator.py index e9858b4c..b5a0fae7 100644 --- a/backend/src/infrastructure/security/production_validator.py +++ b/backend/src/infrastructure/security/production_validator.py @@ -526,7 +526,7 @@ def _get_redis_configurations(self) -> list[dict]: } ) - if self.settings.RATE_LIMITER_BACKEND == "redis": + if self.settings.RATE_LIMITER_ENABLED: configs.append( { "service": "rate_limiter", diff --git a/backend/src/interfaces/admin/views/users.py b/backend/src/interfaces/admin/views/users.py index d79214f6..bee9bd21 100644 --- a/backend/src/interfaces/admin/views/users.py +++ b/backend/src/interfaces/admin/views/users.py @@ -7,6 +7,7 @@ from starlette.requests import Request from wtforms import SelectField +from ....infrastructure.auth.setup import auth from ....infrastructure.database.session import local_session from ....modules.user.enums import OAuthProvider from ....modules.user.models import User @@ -48,6 +49,7 @@ class UserAdmin(DataclassModelMixin, ModelView, model=User): async def on_model_change(self, data: dict[str, Any], model: Any, is_created: bool, request: Request) -> None: """Hash the password before saving.""" if is_created and "hashed_password" in data and data["hashed_password"]: + await auth.validate_password(data["hashed_password"]) data["hashed_password"] = get_password_hash(data["hashed_password"]) if "oauth_provider" in data and data["oauth_provider"] == "": data["oauth_provider"] = None diff --git a/backend/src/interfaces/api/v1/__init__.py b/backend/src/interfaces/api/v1/__init__.py index 98f3b69e..44ced452 100644 --- a/backend/src/interfaces/api/v1/__init__.py +++ b/backend/src/interfaces/api/v1/__init__.py @@ -1,14 +1,16 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from ....infrastructure.auth.routes import router as auth_router +from ....infrastructure.auth.setup import api_rate_limit_dependency from ....modules.api_keys.routes import router as api_keys_router from ....modules.rate_limit.routes import router as rate_limits_router from ....modules.tier.routes import router as tiers_router from ....modules.user.routes import router as users_router +rate_limit_dependencies = [Depends(api_rate_limit_dependency)] router = APIRouter(prefix="/v1") -router.include_router(users_router, prefix="/users") -router.include_router(tiers_router, prefix="/tiers") -router.include_router(rate_limits_router, prefix="/rate-limits") +router.include_router(users_router, prefix="/users", dependencies=rate_limit_dependencies) +router.include_router(tiers_router, prefix="/tiers", dependencies=rate_limit_dependencies) +router.include_router(rate_limits_router, prefix="/rate-limits", dependencies=rate_limit_dependencies) router.include_router(auth_router, prefix="/auth") -router.include_router(api_keys_router, prefix="/api-keys") +router.include_router(api_keys_router, prefix="/api-keys", dependencies=rate_limit_dependencies) diff --git a/backend/src/modules/user/constants.py b/backend/src/modules/user/constants.py index 4856658e..4341a2e4 100644 --- a/backend/src/modules/user/constants.py +++ b/backend/src/modules/user/constants.py @@ -4,10 +4,13 @@ USERNAME_MAX_LENGTH = 32 USERNAME_PATTERN = r"^[a-z0-9_]+$" -# Each class a signup password must contain, named as its error message names it. +# Each class a signup password must contain: the label its error message names it +# by, the ``AuthSettings`` flag that requires it, and the Unicode-aware predicate. +# Kept in step with crudauth's ``PasswordPolicy`` classification. PASSWORD_CHARACTER_CLASSES = ( - ("lowercase letter", str.islower), - ("uppercase letter", str.isupper), - ("number", str.isdecimal), - ("special character", lambda character: not character.isalnum()), + ("lowercase letter", "PASSWORD_REQUIRE_LOWERCASE", str.islower), + ("uppercase letter", "PASSWORD_REQUIRE_UPPERCASE", str.isupper), + ("number", "PASSWORD_REQUIRE_DIGIT", str.isdecimal), + ("special character", "PASSWORD_REQUIRE_SPECIAL", lambda character: not character.isalnum()), ) + diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index 35bb3664..f53ed132 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator +from ...infrastructure.config.settings import settings from ..common.schemas import PersistentDeletion, TimestampSchema from .constants import ( NAME_MAX_LENGTH, @@ -12,6 +13,15 @@ ) +def _password_description() -> str: + """Describe the configured policy for the OpenAPI password field.""" + required = [label for label, flag, _ in PASSWORD_CHARACTER_CLASSES if getattr(settings, flag)] + text = f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters" + if required: + text += " and include " + ", ".join(required) + return text + "." + + class UserBase(BaseModel): name: Annotated[str, Field(min_length=2, max_length=NAME_MAX_LENGTH, examples=["User Userson"])] username: Annotated[ @@ -85,11 +95,8 @@ class UserCreate(UserBase): password: Annotated[ str, Field( - min_length=8, - description=( - "Password must be at least 8 characters long and include a number," - "uppercase letter, lowercase letter, and special character" - ), + min_length=settings.PASSWORD_MIN_LENGTH, + description=_password_description(), examples=["Str1ngst!"], ), ] @@ -102,14 +109,15 @@ class UserCreate(UserBase): @field_validator("password") def validate_password_strength(cls, v: str) -> str: - """Require a lowercase letter, an uppercase letter, a number and a special character. + """Enforce the configured character-class rules, mirroring crudauth's PasswordPolicy. Classification is Unicode-aware: a Cyrillic password has lowercase letters, and an accented letter counts as a letter, not as a special - character. + character. Only the classes enabled through ``PASSWORD_REQUIRE_*`` are + checked, so this stays in step with the policy crudauth applies. """ - for label, has_class in PASSWORD_CHARACTER_CLASSES: - if not any(has_class(character) for character in v): + for label, flag, has_class in PASSWORD_CHARACTER_CLASSES: + if getattr(settings, flag) and not any(has_class(character) for character in v): raise ValueError(f"Password must include at least one {label}") return v diff --git a/backend/src/modules/user/service.py b/backend/src/modules/user/service.py index 7e48c7b0..ce785030 100644 --- a/backend/src/modules/user/service.py +++ b/backend/src/modules/user/service.py @@ -7,6 +7,7 @@ from sqlalchemy.exc import MultipleResultsFound, NoResultFound from sqlalchemy.ext.asyncio import AsyncSession +from ...infrastructure.auth.setup import auth from ...infrastructure.logging import get_logger from ..common.exceptions import ( PermissionDeniedError, @@ -77,6 +78,7 @@ async def create(self, user: UserCreate, db: AsyncSession) -> dict[str, Any]: created_user = await service.create(user_data, db) ``` """ + await auth.validate_password(user.password, source="register") email_exists = await crud_users.exists(db=db, email=user.email) if email_exists: raise UserExistsError("Email already registered") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fc62c163..0d8e72bb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -19,7 +19,6 @@ import sys # noqa: E402 from pathlib import Path # noqa: E402 -from unittest.mock import MagicMock # noqa: E402 import pytest # noqa: E402 import pytest_asyncio # noqa: E402 @@ -36,7 +35,7 @@ from testcontainers.postgres import PostgresContainer # noqa: E402 from src.infrastructure.auth.dependencies import get_current_superuser, get_current_user # noqa: E402 -from src.infrastructure.config.settings import Settings, get_settings # noqa: E402 +from src.infrastructure.config.settings import get_settings # noqa: E402 from src.infrastructure.database.session import Base, async_session # noqa: E402 from src.interfaces.main import app # noqa: E402 from src.modules.tier.models import Tier # noqa: E402 @@ -304,28 +303,6 @@ def delete(self, *args, **kwargs): monkeypatch.setattr(syncredis.Redis, "pipeline", MockPipeline) -@pytest.fixture -def mock_rate_limit_settings_fail_open(): - """Mock settings with fail_open=True for rate limiter tests.""" - settings = MagicMock(spec=Settings) - settings.RATE_LIMITER_ENABLED = True - settings.RATE_LIMITER_FAIL_OPEN = True - settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - return settings - - -@pytest.fixture -def mock_rate_limit_settings_fail_closed(): - """Mock settings with fail_open=False for rate limiter tests.""" - settings = MagicMock(spec=Settings) - settings.RATE_LIMITER_ENABLED = True - settings.RATE_LIMITER_FAIL_OPEN = False - settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - return settings - - @pytest.fixture(autouse=True) def mock_oauth_settings(monkeypatch): """Mock OAuth settings for testing.""" diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index d379d481..c99540c6 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -1,16 +1,13 @@ """Tests for the auth endpoints, now running on crudauth. -The OAuth routes drive module-level crudauth objects (``oauth_providers``, -``oauth_state_storage``) directly, so we patch those in the routes module. The -check-auth route depends on ``get_optional_principal``, so we override that +The check-auth route depends on ``get_optional_principal``, so we override that FastAPI dependency to simulate authenticated / anonymous callers. """ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest from crudauth import Principal, get_password_hash -from crudauth.oauth import OAuthState, OAuthUserInfo from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -19,8 +16,6 @@ from src.interfaces.main import app from src.modules.user.models import User -ROUTES = "src.infrastructure.auth.routes" - @pytest.mark.asyncio async def test_login_success(client: AsyncClient, test_user: dict): @@ -66,114 +61,6 @@ async def test_login_then_logout(client: AsyncClient, test_user: dict): assert logout.json()["message"] == "Logged out successfully" -@pytest.mark.asyncio -async def test_oauth_google_login(client: AsyncClient): - """The Google login initiation endpoint returns the provider authorization URL.""" - mock_provider = MagicMock() - mock_provider.get_authorization_url = MagicMock( - return_value={ - "url": "https://accounts.google.com/o/oauth2/v2/auth?dummy=params", - "state": "test-state-value", - "code_verifier": "test-code-verifier", - } - ) - mock_storage = MagicMock() - mock_storage.create = AsyncMock(return_value="test-state-value") - - with ( - patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), - patch(f"{ROUTES}.oauth_state_storage", mock_storage), - ): - response = await client.get("/api/v1/auth/oauth/google") - - assert response.status_code == 200 - assert response.json()["url"] == "https://accounts.google.com/o/oauth2/v2/auth?dummy=params" - mock_provider.get_authorization_url.assert_called_once() - mock_storage.create.assert_called_once() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("redirect_uri", "stored_redirect"), - [ - ("https://evil.example.com", None), - ("//evil.example.com", None), - ("/\\evil.example.com", None), - ("/dash\nboard", None), - ("/dashboard?tab=billing", "/dashboard?tab=billing"), - ], -) -async def test_oauth_google_login_stores_only_same_origin_redirects( - client: AsyncClient, redirect_uri: str, stored_redirect: str | None -): - """Only same-origin relative paths are stored in the OAuth state; anything else is dropped.""" - mock_provider = MagicMock() - mock_provider.get_authorization_url = MagicMock( - return_value={ - "url": "https://accounts.google.com/o/oauth2/v2/auth?dummy=params", - "state": "test-state-value", - "code_verifier": "test-code-verifier", - } - ) - mock_storage = MagicMock() - mock_storage.create = AsyncMock(return_value="test-state-value") - - with ( - patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), - patch(f"{ROUTES}.oauth_state_storage", mock_storage), - ): - response = await client.get("/api/v1/auth/oauth/google", params={"redirect_uri": redirect_uri}) - - assert response.status_code == 200 - assert mock_storage.create.call_args.args[0].redirect_to == stored_redirect - - -@pytest.mark.asyncio -async def test_oauth_callback_invalid_state(client: AsyncClient): - """An unknown state parameter is rejected (302 redirect / 400 for json).""" - mock_storage = MagicMock() - mock_storage.get = AsyncMock(return_value=None) - - with patch(f"{ROUTES}.oauth_state_storage", mock_storage): - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "invalid-state"}, - ) - assert response.status_code == 302 - - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "invalid-state", "response_format": "json"}, - ) - assert response.status_code == 400 - - -@pytest.mark.asyncio -async def test_oauth_callback_provider_mismatch(client: AsyncClient): - """A state minted for a different provider is rejected (302 redirect / 400 for json).""" - mismatched_state = OAuthState( - state="test-state-value", - provider="github", - redirect_to="/", - code_verifier="test-code-verifier", - ) - mock_storage = MagicMock() - mock_storage.get = AsyncMock(return_value=mismatched_state) - - with patch(f"{ROUTES}.oauth_state_storage", mock_storage): - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "test-state-value"}, - ) - assert response.status_code == 302 - - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "test-state-value", "response_format": "json"}, - ) - assert response.status_code == 400 - - @pytest.mark.asyncio async def test_check_auth_authenticated(client: AsyncClient): """check-auth returns the user info when a principal is resolved.""" @@ -381,159 +268,6 @@ async def test_refresh_csrf_token_no_session_returns_401(client: AsyncClient): assert response.status_code == 401 -@pytest.mark.asyncio -async def test_oauth_google_login_provider_failure_returns_500(client: AsyncClient): - """If the provider blows up while building the auth URL, the endpoint returns 500.""" - mock_provider = MagicMock() - mock_provider.get_authorization_url = MagicMock(side_effect=RuntimeError("boom")) - - with patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}): - response = await client.get("/api/v1/auth/oauth/google") - - assert response.status_code == 500 - - -@pytest.mark.asyncio -async def test_oauth_callback_success_creates_user(client: AsyncClient): - """The happy-path callback links/creates the user and starts a session (json format). - - Exercises the real oauth_account_service.get_or_create_user → repo.create against - the test DB (proving crudauth user creation works on the dataclass-mapped User), - with only the provider's network calls mocked. - """ - valid_state = OAuthState( - state="good-state", - provider="google", - redirect_to="/", - code_verifier="test-code-verifier", - ) - mock_storage = MagicMock() - mock_storage.get = AsyncMock(return_value=valid_state) - mock_storage.delete = AsyncMock(return_value=None) - - mock_provider = MagicMock() - mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"}) - mock_provider.get_user_info = AsyncMock(return_value={}) - mock_provider.process_user_info = AsyncMock( - return_value=OAuthUserInfo( - provider="google", - provider_user_id="google-uid-123", - email="oauth_new@example.com", - email_verified=True, - name="OAuth New User", - ) - ) - - with ( - patch(f"{ROUTES}.oauth_state_storage", mock_storage), - patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), - ): - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "good-state", "response_format": "json"}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["success"] is True - assert body["user"]["email"] == "oauth_new@example.com" - assert body["user"]["is_new_user"] is True - assert body["csrf_token"] - mock_storage.delete.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_oauth_callback_handles_long_provider_usernames(client: AsyncClient): - """OAuth signup succeeds when the provider's username and display name exceed the old column widths.""" - valid_state = OAuthState( - state="long-name-state", - provider="google", - redirect_to="/", - code_verifier="test-code-verifier", - ) - mock_storage = MagicMock() - mock_storage.get = AsyncMock(return_value=valid_state) - mock_storage.delete = AsyncMock(return_value=None) - - mock_provider = MagicMock() - mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"}) - mock_provider.get_user_info = AsyncMock(return_value={}) - mock_provider.process_user_info = AsyncMock( - return_value=OAuthUserInfo( - provider="google", - provider_user_id="google-uid-long", - email="long_username@example.com", - email_verified=True, - name="A" * 50, - username="verylongusername@subdomain.example.com", - ) - ) - - with ( - patch(f"{ROUTES}.oauth_state_storage", mock_storage), - patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), - ): - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "long-name-state", "response_format": "json"}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["success"] is True - assert body["user"]["username"] == "verylongusername_subdomain_examp" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("stored_redirect", "expected_location"), - [ - ("/docs", "/docs"), - ("https://evil.example.com", "/"), - ("/\\evil.example.com", "/"), - ], -) -async def test_oauth_callback_redirect_sets_session_cookies(client: AsyncClient, stored_redirect: str, expected_location: str): - """The browser callback redirects to a same-origin path and carries the session cookies.""" - valid_state = OAuthState( - state="redirect-state", - provider="google", - redirect_to=stored_redirect, - code_verifier="test-code-verifier", - ) - mock_storage = MagicMock() - mock_storage.get = AsyncMock(return_value=valid_state) - mock_storage.delete = AsyncMock(return_value=None) - - mock_provider = MagicMock() - mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"}) - mock_provider.get_user_info = AsyncMock(return_value={}) - mock_provider.process_user_info = AsyncMock( - return_value=OAuthUserInfo( - provider="google", - provider_user_id="google-uid-redirect", - email="redirect_flow@example.com", - email_verified=True, - name="Redirect Flow", - ) - ) - - with ( - patch(f"{ROUTES}.oauth_state_storage", mock_storage), - patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), - ): - response = await client.get( - "/api/v1/auth/oauth/callback/google", - params={"code": "test-code", "state": "redirect-state"}, - ) - - assert response.status_code == 302 - assert response.headers["location"] == expected_location - set_cookie = response.headers.get_list("set-cookie") - assert any(c.startswith("session_id=") for c in set_cookie), set_cookie - assert any(c.startswith("csrf_token=") for c in set_cookie), set_cookie - - @pytest.mark.asyncio async def test_check_auth_user_not_found(client: AsyncClient): """A resolved principal whose user row is missing reports authenticated=false.""" diff --git a/backend/tests/unit/infrastructure/rate_limit/__init__.py b/backend/tests/unit/infrastructure/rate_limit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/unit/infrastructure/rate_limit/backends/__init__.py b/backend/tests/unit/infrastructure/rate_limit/backends/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/unit/infrastructure/rate_limit/backends/test_fail_open.py b/backend/tests/unit/infrastructure/rate_limit/backends/test_fail_open.py deleted file mode 100644 index 04c2e7e6..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/backends/test_fail_open.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Tests for the fail_open behavior in rate limiter backends.""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from redis.exceptions import RedisError - -from src.infrastructure.rate_limit.backends.memcached import MemcachedBackend, MemcachedSettings -from src.infrastructure.rate_limit.backends.redis import RedisBackend, RedisSettings - - -@pytest.fixture -def mock_redis_client(): - """Create a mock Redis client.""" - pipeline_mock = MagicMock() - pipeline_mock.incr = MagicMock() - pipeline_mock.expire = MagicMock() - pipeline_mock.execute = AsyncMock(return_value=[1]) - - client_mock = AsyncMock() - client_mock.pipeline = MagicMock(return_value=pipeline_mock) - client_mock.get = AsyncMock(return_value="1") - client_mock.delete = AsyncMock(return_value=1) - client_mock.ping = AsyncMock(return_value=True) - - return client_mock, pipeline_mock - - -@pytest.fixture -def mock_memcached_client(): - """Create a mock Memcached client.""" - client_mock = AsyncMock() - client_mock.get = AsyncMock(return_value=b"1") - client_mock.set = AsyncMock(return_value=True) - client_mock.delete = AsyncMock(return_value=True) - - return client_mock - - -@pytest.fixture -def redis_backend_fail_open(mock_redis_client): - """Create a RedisBackend with fail_open=True.""" - client_mock, pipeline_mock = mock_redis_client - - settings = RedisSettings(host="localhost", port=6379) - backend = RedisBackend(settings=settings, fail_open=True) - backend.client = client_mock - - yield backend, client_mock, pipeline_mock - - -@pytest.fixture -def redis_backend_fail_closed(mock_redis_client): - """Create a RedisBackend with fail_open=False.""" - client_mock, pipeline_mock = mock_redis_client - - settings = RedisSettings(host="localhost", port=6379) - backend = RedisBackend(settings=settings, fail_open=False) - backend.client = client_mock - - yield backend, client_mock, pipeline_mock - - -@pytest.fixture -def memcached_backend_fail_open(mock_memcached_client): - """Create a MemcachedBackend with fail_open=True.""" - settings = MemcachedSettings(host="localhost", port=11211) - backend = MemcachedBackend(settings=settings, fail_open=True) - backend.client = mock_memcached_client - - yield backend, mock_memcached_client - - -@pytest.fixture -def memcached_backend_fail_closed(mock_memcached_client): - """Create a MemcachedBackend with fail_open=False.""" - settings = MemcachedSettings(host="localhost", port=11211) - backend = MemcachedBackend(settings=settings, fail_open=False) - backend.client = mock_memcached_client - - yield backend, mock_memcached_client - - -@pytest.mark.asyncio -async def test_redis_error_handling_fail_open(redis_backend_fail_open): - """Test Redis error handling with fail_open=True.""" - backend, _, pipeline_mock = redis_backend_fail_open - - pipeline_mock.execute.side_effect = RedisError("Test Redis error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is False - - -@pytest.mark.asyncio -async def test_redis_error_handling_fail_closed(redis_backend_fail_closed): - """Test Redis error handling with fail_open=False.""" - backend, _, pipeline_mock = redis_backend_fail_closed - - pipeline_mock.execute.side_effect = RedisError("Test Redis error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is True - - -@pytest.mark.asyncio -async def test_redis_general_error_fail_open(redis_backend_fail_open): - """Test general error handling with fail_open=True in Redis backend.""" - backend, _, pipeline_mock = redis_backend_fail_open - - pipeline_mock.execute.side_effect = Exception("General error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is False - - -@pytest.mark.asyncio -async def test_redis_general_error_fail_closed(redis_backend_fail_closed): - """Test general error handling with fail_open=False in Redis backend.""" - backend, _, pipeline_mock = redis_backend_fail_closed - - pipeline_mock.execute.side_effect = Exception("General error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is True - - -@pytest.mark.asyncio -async def test_memcached_error_fail_open(memcached_backend_fail_open): - """Test error handling with fail_open=True in Memcached backend.""" - backend, client_mock = memcached_backend_fail_open - - client_mock.get.side_effect = Exception("Memcached error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is False - - -@pytest.mark.asyncio -async def test_memcached_error_fail_closed(memcached_backend_fail_closed): - """Test error handling with fail_open=False in Memcached backend.""" - backend, client_mock = memcached_backend_fail_closed - - client_mock.get.side_effect = Exception("Memcached error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is True diff --git a/backend/tests/unit/infrastructure/rate_limit/backends/test_memcached.py b/backend/tests/unit/infrastructure/rate_limit/backends/test_memcached.py deleted file mode 100644 index 74c3bb91..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/backends/test_memcached.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Tests for the Memcached rate limiter backend.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from src.infrastructure.rate_limit.backends.memcached import ( - MemcachedBackend, - MemcachedSettings, -) -from src.infrastructure.rate_limit.exceptions import RateLimiterBackendException - - -@pytest.fixture -def mock_aiomcache(): - """Create a mock aiomcache client.""" - client_mock = AsyncMock() - client_mock.get = AsyncMock() - client_mock.set = AsyncMock() - client_mock.delete = AsyncMock() - return client_mock - - -@pytest.fixture -def memcached_backend(mock_aiomcache): - """Create a MemcachedBackend with a mock client.""" - with patch("aiomcache.Client", return_value=mock_aiomcache): - settings = MemcachedSettings(host="localhost", port=11211) - backend = MemcachedBackend(settings=settings) - backend.client = mock_aiomcache - yield backend - - -@pytest.mark.asyncio -async def test_init_error(): - """Test that initialization errors are properly handled.""" - with patch("aiomcache.Client", side_effect=Exception("Connection error")): - with pytest.raises(RateLimiterBackendException) as excinfo: - MemcachedBackend(settings=MemcachedSettings()) - - assert "Failed to initialize Memcached client" in str(excinfo.value) - - -@pytest.mark.asyncio -async def test_increment_and_check_new_key(memcached_backend, mock_aiomcache): - """Test incrementing a counter for a new key.""" - mock_aiomcache.get.return_value = None - - count, is_limited = await memcached_backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 1 - assert is_limited is False - - assert mock_aiomcache.get.called - assert mock_aiomcache.set.called - - set_args = mock_aiomcache.set.call_args.args - assert set_args[1] == b"1" - assert mock_aiomcache.set.call_args.kwargs["exptime"] == 60 - - -@pytest.mark.asyncio -async def test_increment_and_check_existing_key(memcached_backend, mock_aiomcache): - """Test incrementing a counter for an existing key.""" - mock_aiomcache.get.return_value = b"4" - - count, is_limited = await memcached_backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 5 - assert is_limited is False - - mock_aiomcache.get.assert_called_once() - mock_aiomcache.set.assert_called_once() - - set_args = mock_aiomcache.set.call_args.args - assert set_args[1] == b"5" - - -@pytest.mark.asyncio -async def test_rate_limited(memcached_backend, mock_aiomcache): - """Test that requests are rate limited once limit is exceeded.""" - mock_aiomcache.get.return_value = b"5" - - count, is_limited = await memcached_backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 6 - assert is_limited is True - - -@pytest.mark.asyncio -async def test_get_count(memcached_backend, mock_aiomcache): - """Test getting the current count for a key.""" - mock_aiomcache.get.return_value = b"3" - count = await memcached_backend.get_count("test:123") - assert count == 3 - - mock_aiomcache.get.return_value = None - count = await memcached_backend.get_count("test:456") - assert count is None - - -@pytest.mark.asyncio -async def test_reset(memcached_backend, mock_aiomcache): - """Test resetting the counter for a key.""" - await memcached_backend.reset("test:123") - mock_aiomcache.delete.assert_called_once_with(b"test:123") - - -@pytest.mark.asyncio -async def test_ping_success(memcached_backend, mock_aiomcache): - """Test ping with successful connection.""" - mock_aiomcache.set.return_value = None - mock_aiomcache.get.return_value = b"1" - - result = await memcached_backend.ping() - assert result is True - - -@pytest.mark.asyncio -async def test_ping_failure(memcached_backend, mock_aiomcache): - """Test ping with failed connection.""" - mock_aiomcache.set.side_effect = Exception("Connection failed") - - result = await memcached_backend.ping() - assert result is False - - -@pytest.mark.asyncio -async def test_increment_error_handling(memcached_backend, mock_aiomcache): - """Test error handling during increment operation.""" - mock_aiomcache.get.side_effect = Exception("Connection error") - - count, is_limited = await memcached_backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is False diff --git a/backend/tests/unit/infrastructure/rate_limit/backends/test_redis.py b/backend/tests/unit/infrastructure/rate_limit/backends/test_redis.py deleted file mode 100644 index b9a0ccd6..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/backends/test_redis.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Tests for the Redis rate limiter backend.""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from redis.exceptions import RedisError - -from src.infrastructure.rate_limit.backends.redis import RedisBackend, RedisSettings - - -@pytest.fixture -def mock_redis_client(): - """Create a mock Redis client.""" - pipeline_mock = MagicMock() - pipeline_mock.incr = MagicMock() - pipeline_mock.expire = MagicMock() - pipeline_mock.execute = AsyncMock(return_value=[1]) - - client_mock = AsyncMock() - client_mock.pipeline = MagicMock(return_value=pipeline_mock) - client_mock.get = AsyncMock(return_value="1") - client_mock.delete = AsyncMock(return_value=1) - client_mock.ping = AsyncMock(return_value=True) - - return client_mock, pipeline_mock - - -@pytest.fixture -def redis_backend(mock_redis_client): - """Create a RedisBackend with a mock client.""" - client_mock, pipeline_mock = mock_redis_client - - settings = RedisSettings(host="localhost", port=6379) - backend = RedisBackend(settings=settings, fail_open=True) - backend.client = client_mock - - yield backend, client_mock, pipeline_mock - - -@pytest.mark.asyncio -async def test_increment_and_check_new_key(redis_backend): - """Test incrementing a counter for a new key.""" - backend, client_mock, pipeline_mock = redis_backend - - pipeline_mock.execute.return_value = [1] - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 1 - assert is_limited is False - - client_mock.pipeline.assert_called_once() - pipeline_mock.incr.assert_called_once() - pipeline_mock.expire.assert_called_once() - pipeline_mock.execute.assert_called_once() - - -@pytest.mark.asyncio -async def test_increment_and_check_existing_key(redis_backend): - """Test incrementing a counter for an existing key.""" - backend, client_mock, pipeline_mock = redis_backend - - pipeline_mock.execute.return_value = [5] - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 5 - assert is_limited is False - - -@pytest.mark.asyncio -async def test_rate_limited(redis_backend): - """Test that requests are rate limited once limit is exceeded.""" - backend, client_mock, pipeline_mock = redis_backend - - pipeline_mock.execute.return_value = [6] - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 6 - assert is_limited is True - - -@pytest.mark.asyncio -async def test_get_count(redis_backend): - """Test getting the current count for a key.""" - backend, client_mock, _ = redis_backend - - client_mock.get.return_value = "3" - count = await backend.get_count("test:123") - assert count == 3 - - client_mock.get.return_value = None - count = await backend.get_count("test:456") - assert count is None - - -@pytest.mark.asyncio -async def test_reset(redis_backend): - """Test resetting the counter for a key.""" - backend, client_mock, _ = redis_backend - - await backend.reset("test:123") - client_mock.delete.assert_called_once_with("test:123") - - -@pytest.mark.asyncio -async def test_ping_success(redis_backend): - """Test ping with successful connection.""" - backend, client_mock, _ = redis_backend - client_mock.ping.return_value = True - - result = await backend.ping() - assert result is True - - -@pytest.mark.asyncio -async def test_ping_failure(redis_backend): - """Test ping with failed connection.""" - backend, client_mock, _ = redis_backend - client_mock.ping.side_effect = Exception("Connection failed") - - result = await backend.ping() - assert result is False - - -@pytest.mark.asyncio -async def test_redis_error_handling(redis_backend): - """Test Redis-specific error handling.""" - backend, client_mock, pipeline_mock = redis_backend - - pipeline_mock.execute.side_effect = RedisError("Redis error") - - count, is_limited = await backend.increment_and_check(key="test:123", limit=5, period=60) - - assert count == 0 - assert is_limited is False diff --git a/backend/tests/unit/infrastructure/rate_limit/test_fail_open_middleware.py b/backend/tests/unit/infrastructure/rate_limit/test_fail_open_middleware.py deleted file mode 100644 index 4418bee7..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/test_fail_open_middleware.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tests for the fail_open behavior in rate limiter middleware.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import Request - -from src.infrastructure.rate_limit.exceptions import RateLimitException -from src.infrastructure.rate_limit.middleware import _check_rate_limit - - -@pytest.fixture -def mock_request(): - """Create a mock Request object.""" - mock = MagicMock(spec=Request) - mock.url = MagicMock() - mock.url.path = "/api/v1/test" - mock.client = MagicMock() - mock.client.host = "127.0.0.1" - mock.state = MagicMock() - mock.app = MagicMock() - mock.app.state = MagicMock() - mock.app.state.initialization_complete = AsyncMock() - mock.app.state.initialization_complete.wait = AsyncMock() - return mock - - -@pytest.fixture -def mock_db(): - """Create a mock database session.""" - return AsyncMock() - - -@pytest.mark.asyncio -async def test_middleware_fail_open_behavior(mock_request, mock_db, mock_rate_limit_settings_fail_open): - """Test middleware with fail_open=True when a backend error occurs.""" - - with ( - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_inc, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.settings", mock_rate_limit_settings_fail_open), - ): - mock_inc.side_effect = Exception("Backend error") - - await _check_rate_limit(mock_request, mock_db) - - mock_inc.assert_called_once() - assert mock_inc.call_args[1]["fail_open"] is True - - -@pytest.mark.asyncio -async def test_middleware_fail_closed_behavior(mock_request, mock_db, mock_rate_limit_settings_fail_closed): - """Test middleware with fail_open=False when a backend error occurs.""" - - with ( - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_inc, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.settings", mock_rate_limit_settings_fail_closed), - ): - mock_inc.side_effect = Exception("Backend error") - - with pytest.raises(RateLimitException): - await _check_rate_limit(mock_request, mock_db) - - mock_inc.assert_called_once() - assert mock_inc.call_args[1]["fail_open"] is False - - -@pytest.mark.asyncio -async def test_middleware_respects_rate_limit_exception(mock_request, mock_db, mock_rate_limit_settings_fail_open): - """Test that middleware re-raises RateLimitException even with fail_open=True.""" - - with ( - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_inc, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.settings", mock_rate_limit_settings_fail_open), - ): - mock_inc.side_effect = RateLimitException("Rate limit exceeded") - - with pytest.raises(RateLimitException): - await _check_rate_limit(mock_request, mock_db) - - -@pytest.mark.asyncio -async def test_middleware_sets_correct_headers(mock_request, mock_db, mock_rate_limit_settings_fail_open): - """Test that middleware sets correct rate limit headers on success.""" - - with ( - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_inc, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.settings", mock_rate_limit_settings_fail_open), - ): - mock_inc.return_value = (3, False) - - await _check_rate_limit(mock_request, mock_db) - - assert hasattr(mock_request.state, "rate_limit_headers") - headers = mock_request.state.rate_limit_headers - assert headers["X-RateLimit-Limit"] == "100" - assert headers["X-RateLimit-Remaining"] == "97" - assert headers["X-RateLimit-Reset"] == "60" diff --git a/backend/tests/unit/infrastructure/rate_limit/test_fail_open_provider.py b/backend/tests/unit/infrastructure/rate_limit/test_fail_open_provider.py deleted file mode 100644 index 17a72b72..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/test_fail_open_provider.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Tests for the fail_open override functionality in the rate limiter provider.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from src.infrastructure.rate_limit.base import RateLimiterBackend -from src.infrastructure.rate_limit.provider import ( - RateLimiterProvider, - increment_and_check, -) - - -class MockBackend(RateLimiterBackend): - """Mock implementation of RateLimiterBackend with fail_open support.""" - - def __init__(self, fail_open: bool = True): - """Initialize the mock backend with configurable fail_open behavior.""" - super().__init__(fail_open=fail_open) - self.increment_and_check_mock = AsyncMock(return_value=(1, False)) - self.get_count_mock = AsyncMock(return_value=1) - self.reset_mock = AsyncMock() - self.ping_mock = AsyncMock(return_value=True) - self.increment_mock = AsyncMock(return_value=1) - self.delete_mock = AsyncMock(return_value=True) - - async def increment_and_check(self, key, limit, period): - """Mock implementation with side effect based on fail_open value.""" - if hasattr(self.increment_and_check_mock, "side_effect") and self.increment_and_check_mock.side_effect: - if isinstance(self.increment_and_check_mock.side_effect, Exception): - return 0, not self.fail_open - if callable(self.increment_and_check_mock.side_effect): - return self.increment_and_check_mock.side_effect(key, limit, period) - raise self.increment_and_check_mock.side_effect - return await self.increment_and_check_mock(key, limit, period) - - async def get_count(self, key): - return await self.get_count_mock(key) - - async def reset(self, key): - return await self.reset_mock(key) - - async def ping(self): - return await self.ping_mock() - - async def increment(self, key, amount=1, expiry=300): - return await self.increment_mock(key, amount, expiry) - - async def delete(self, key): - return await self.delete_mock(key) - - -@pytest.fixture -def provider(): - """Create a fresh RateLimiterProvider for testing.""" - return RateLimiterProvider() - - -@pytest.fixture -def mock_backend_fail_open(): - """Create a mock rate limiter backend with fail_open=True.""" - return MockBackend(fail_open=True) - - -@pytest.fixture -def mock_backend_fail_closed(): - """Create a mock rate limiter backend with fail_open=False.""" - return MockBackend(fail_open=False) - - -@pytest.mark.asyncio -async def test_increment_and_check_with_fail_open_override(provider, mock_backend_fail_closed): - """Test overriding fail_closed with fail_open in increment_and_check.""" - provider.register_backend("test", mock_backend_fail_closed, default=True) - - mock_backend_fail_closed.increment_and_check_mock.side_effect = Exception("Test error") - - with patch("src.infrastructure.rate_limit.provider.rate_limiter_provider", provider): - count, is_limited = await increment_and_check(key="test:key", limit=5, period=60, backend_name="test") - assert is_limited is True - - count, is_limited = await increment_and_check(key="test:key", limit=5, period=60, backend_name="test", fail_open=True) - assert is_limited is False - - assert mock_backend_fail_closed.fail_open is False - - -@pytest.mark.asyncio -async def test_increment_and_check_with_fail_closed_override(provider, mock_backend_fail_open): - """Test overriding fail-open with fail-closed in increment_and_check.""" - provider.register_backend("test", mock_backend_fail_open, default=True) - - mock_backend_fail_open.increment_and_check_mock.side_effect = Exception("Test error") - - with patch("src.infrastructure.rate_limit.provider.rate_limiter_provider", provider): - count, is_limited = await increment_and_check(key="test:key", limit=5, period=60, backend_name="test") - assert is_limited is False - - count, is_limited = await increment_and_check(key="test:key", limit=5, period=60, backend_name="test", fail_open=False) - assert is_limited is True - - assert mock_backend_fail_open.fail_open is True - - -@pytest.mark.asyncio -async def test_provider_temp_override_behavior(provider, mock_backend_fail_open): - """Test that temporary override only affects the current call.""" - provider.register_backend("test", mock_backend_fail_open, default=True) - - fail_open_during_call = None - - def side_effect(key, limit, period): - nonlocal fail_open_during_call - fail_open_during_call = mock_backend_fail_open.fail_open - return 1, False - - mock_backend_fail_open.increment_and_check_mock.side_effect = side_effect - - with patch("src.infrastructure.rate_limit.provider.rate_limiter_provider", provider): - await increment_and_check(key="test:key", limit=5, period=60, backend_name="test", fail_open=False) - assert fail_open_during_call is False - - assert mock_backend_fail_open.fail_open is True - - -@pytest.mark.asyncio -async def test_provider_no_override_needed(provider, mock_backend_fail_open): - """Test that no override happens if the value matches.""" - provider.register_backend("test", mock_backend_fail_open, default=True) - - original_fail_open = mock_backend_fail_open.fail_open - assert original_fail_open is True - - with patch("src.infrastructure.rate_limit.provider.rate_limiter_provider", provider): - await increment_and_check(key="test:key", limit=5, period=60, backend_name="test", fail_open=True) - - assert mock_backend_fail_open.fail_open is original_fail_open diff --git a/backend/tests/unit/infrastructure/rate_limit/test_middleware.py b/backend/tests/unit/infrastructure/rate_limit/test_middleware.py deleted file mode 100644 index ef2e0861..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/test_middleware.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for the rate limiter middleware module.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import Request, Response - -from src.infrastructure.rate_limit.exceptions import RateLimitException -from src.infrastructure.rate_limit.middleware import ( - RateLimiterMiddleware, - _check_rate_limit, -) -from src.modules.tier.schemas import TierSelect - - -@pytest.fixture -def mock_request(): - """Create a mock Request object.""" - mock = MagicMock(spec=Request) - mock.url = MagicMock() - mock.url.path = "/api/v1/test" - mock.client = MagicMock() - mock.client.host = "127.0.0.1" - mock.state = MagicMock() - mock.app = MagicMock() - mock.app.state = MagicMock() - mock.app.state.initialization_complete = AsyncMock() - mock.app.state.initialization_complete.wait = AsyncMock() - return mock - - -@pytest.fixture -def mock_response(): - """Create a mock Response object.""" - mock = MagicMock(spec=Response) - mock.headers = {} - return mock - - -@pytest.fixture -def mock_db(): - """Create a mock database session.""" - return AsyncMock() - - -@pytest.fixture -def mock_user(): - """Create a mock user dict.""" - return { - "id": 123, - "username": "testuser", - "email": "test@example.com", - "tier_id": 1, - } - - -@pytest.fixture -def mock_app(): - """Create a mock FastAPI app.""" - return MagicMock() - - -@pytest.mark.asyncio -async def test_check_rate_limit_disabled(mock_request, mock_db): - """Test check_rate_limit when rate limiting is disabled.""" - with patch("src.infrastructure.rate_limit.middleware.settings") as mock_settings: - mock_settings.RATE_LIMITER_ENABLED = False - - await _check_rate_limit(mock_request, mock_db, None) - - -@pytest.mark.asyncio -async def test_check_rate_limit_no_user(mock_request, mock_db): - """Test check_rate_limit with no authenticated user.""" - with ( - patch("src.infrastructure.rate_limit.middleware.settings") as mock_settings, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_increment, - ): - mock_settings.RATE_LIMITER_ENABLED = True - mock_settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - mock_settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - - mock_increment.return_value = (1, False) - - await _check_rate_limit(mock_request, mock_db, None) - - mock_increment.assert_called_once() - key_arg = mock_increment.call_args.kwargs["key"] - assert "127.0.0.1" in key_arg - assert mock_increment.call_args.kwargs["limit"] == 100 - assert mock_increment.call_args.kwargs["period"] == 60 - - -@pytest.mark.asyncio -async def test_check_rate_limit_with_user(mock_request, mock_db, mock_user): - """Test check_rate_limit with an authenticated user.""" - with ( - patch("src.infrastructure.rate_limit.middleware.settings") as mock_settings, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_increment, - patch("src.infrastructure.rate_limit.middleware.crud_tiers.get") as mock_get_tier, - patch("src.infrastructure.rate_limit.middleware.crud_rate_limits.get") as mock_get_rate_limit, - ): - mock_settings.RATE_LIMITER_ENABLED = True - mock_settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - mock_settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - - mock_get_tier.return_value = {"id": 1, "name": "pro"} - mock_get_rate_limit.return_value = {"limit": 10, "period": 30} - - mock_increment.return_value = (1, False) - - await _check_rate_limit(mock_request, mock_db, mock_user) - - mock_get_tier.assert_called_once_with(db=mock_db, id=1, schema_to_select=TierSelect) - mock_get_rate_limit.assert_called_once() - - mock_increment.assert_called_once() - key_arg = mock_increment.call_args.kwargs["key"] - assert "123" in key_arg - assert mock_increment.call_args.kwargs["limit"] == 10 - assert mock_increment.call_args.kwargs["period"] == 30 - - -@pytest.mark.asyncio -async def test_check_rate_limit_no_specific_limits(mock_request, mock_db, mock_user): - """Test check_rate_limit with user but no specific rate limits.""" - with ( - patch("src.infrastructure.rate_limit.middleware.settings") as mock_settings, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_increment, - patch("src.infrastructure.rate_limit.middleware.crud_tiers.get") as mock_get_tier, - patch("src.infrastructure.rate_limit.middleware.crud_rate_limits.get") as mock_get_rate_limit, - patch("src.infrastructure.rate_limit.middleware.logger") as mock_logger, - ): - mock_settings.RATE_LIMITER_ENABLED = True - mock_settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - mock_settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - - mock_get_tier.return_value = {"id": 1, "name": "pro"} - mock_get_rate_limit.return_value = None - - mock_increment.return_value = (1, False) - - await _check_rate_limit(mock_request, mock_db, mock_user) - - assert mock_logger.warning.called - mock_increment.assert_called_once() - assert mock_increment.call_args.kwargs["limit"] == 100 - assert mock_increment.call_args.kwargs["period"] == 60 - - -@pytest.mark.asyncio -async def test_check_rate_limit_exceeded(mock_request, mock_db): - """Test check_rate_limit when rate limit is exceeded.""" - with ( - patch("src.infrastructure.rate_limit.middleware.settings") as mock_settings, - patch("src.infrastructure.rate_limit.middleware.DEFAULT_LIMIT", 100), - patch("src.infrastructure.rate_limit.middleware.increment_and_check") as mock_increment, - patch("src.infrastructure.rate_limit.middleware.logger") as mock_logger, - ): - mock_settings.RATE_LIMITER_ENABLED = True - mock_settings.DEFAULT_RATE_LIMIT_LIMIT = 100 - mock_settings.DEFAULT_RATE_LIMIT_PERIOD = 60 - - mock_increment.return_value = (101, True) - - with pytest.raises(RateLimitException) as excinfo: - await _check_rate_limit(mock_request, mock_db, None) - - assert "Rate limit exceeded" in str(excinfo.value) - assert mock_logger.warning.called - - -@pytest.mark.asyncio -async def test_rate_limiter_middleware(mock_request, mock_response, mock_app): - """Test the RateLimiterMiddleware.""" - middleware = RateLimiterMiddleware(app=mock_app) - - async def next_handler(request): - return mock_response - - mock_request.state.rate_limit_headers = { - "X-RateLimit-Limit": "10", - "X-RateLimit-Remaining": "5", - "X-RateLimit-Reset": "60", - } - - response = await middleware.dispatch(mock_request, next_handler) - - assert response.headers["X-RateLimit-Limit"] == "10" - assert response.headers["X-RateLimit-Remaining"] == "5" - assert response.headers["X-RateLimit-Reset"] == "60" - - -@pytest.mark.asyncio -async def test_rate_limiter_middleware_no_headers(mock_request, mock_response, mock_app): - """Test the RateLimiterMiddleware with no rate limit headers.""" - middleware = RateLimiterMiddleware(app=mock_app) - - async def next_handler(request): - return mock_response - - if hasattr(mock_request.state, "rate_limit_headers"): - delattr(mock_request.state, "rate_limit_headers") - - response = await middleware.dispatch(mock_request, next_handler) - - assert len(response.headers) == 0 diff --git a/backend/tests/unit/infrastructure/rate_limit/test_provider.py b/backend/tests/unit/infrastructure/rate_limit/test_provider.py deleted file mode 100644 index 743e5b7b..00000000 --- a/backend/tests/unit/infrastructure/rate_limit/test_provider.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Tests for the rate limiter provider module.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from src.infrastructure.rate_limit.base import RateLimiterBackend -from src.infrastructure.rate_limit.exceptions import BackendNotFoundError -from src.infrastructure.rate_limit.provider import ( - RateLimiterProvider, - get_count, - increment_and_check, - reset, -) - - -class MockBackend(RateLimiterBackend): - """Mock implementation of RateLimiterBackend for testing.""" - - def __init__(self): - super().__init__() - self.increment_and_check_mock = AsyncMock(return_value=(1, False)) - self.get_count_mock = AsyncMock(return_value=1) - self.reset_mock = AsyncMock() - self.ping_mock = AsyncMock(return_value=True) - self.increment_mock = AsyncMock(return_value=1) - self.delete_mock = AsyncMock(return_value=True) - - async def increment_and_check(self, key, limit, period): - return await self.increment_and_check_mock(key, limit, period) - - async def get_count(self, key): - return await self.get_count_mock(key) - - async def reset(self, key): - return await self.reset_mock(key) - - async def ping(self): - return await self.ping_mock() - - async def increment(self, key, amount=1, expiry=300): - return await self.increment_mock(key, amount, expiry) - - async def delete(self, key): - return await self.delete_mock(key) - - -@pytest.fixture -def provider(): - """Create a fresh RateLimiterProvider for testing.""" - return RateLimiterProvider() - - -@pytest.fixture -def mock_backend(): - """Create a mock rate limiter backend.""" - return MockBackend() - - -@pytest.mark.asyncio -async def test_register_backend(provider, mock_backend): - """Test registering a backend.""" - provider.register_backend("test", mock_backend) - - assert provider.get_backend("test") == mock_backend - - assert provider.default_backend_name == "test" - - -@pytest.mark.asyncio -async def test_register_multiple_backends(provider, mock_backend): - """Test registering multiple backends.""" - provider.register_backend("test1", mock_backend) - - mock_backend2 = MockBackend() - provider.register_backend("test2", mock_backend2, default=True) - - assert provider.get_backend("test1") == mock_backend - assert provider.get_backend("test2") == mock_backend2 - - assert provider.default_backend_name == "test2" - assert provider.get_backend() == mock_backend2 - - -@pytest.mark.asyncio -async def test_get_backend_not_found(provider): - """Test getting a non-existent backend.""" - with pytest.raises(BackendNotFoundError): - provider.get_backend("nonexistent") - - -@pytest.mark.asyncio -async def test_set_default_backend(provider, mock_backend): - """Test setting the default backend.""" - provider.register_backend("test1", mock_backend) - mock_backend2 = MockBackend() - provider.register_backend("test2", mock_backend2) - - assert provider.default_backend_name == "test1" - - provider.set_default_backend("test2") - assert provider.default_backend_name == "test2" - assert provider.get_backend() == mock_backend2 - - -@pytest.mark.asyncio -async def test_set_default_backend_not_found(provider, mock_backend): - """Test setting a non-existent backend as default.""" - provider.register_backend("test", mock_backend) - - with pytest.raises(BackendNotFoundError): - provider.set_default_backend("nonexistent") - - -@pytest.mark.asyncio -async def test_ping_all(provider, mock_backend): - """Test pinging all backends.""" - provider.register_backend("test1", mock_backend) - - mock_backend2 = MockBackend() - mock_backend2.ping_mock.return_value = False - provider.register_backend("test2", mock_backend2) - - results = await provider.ping_all() - - assert results == {"test1": True, "test2": False} - - -@pytest.mark.asyncio -async def test_list_backends(provider, mock_backend): - """Test listing all registered backends.""" - provider.register_backend("test1", mock_backend) - mock_backend2 = MockBackend() - provider.register_backend("test2", mock_backend2) - - backends = provider.list_backends() - - assert set(backends.keys()) == {"test1", "test2"} - assert all(issubclass(cls, MockBackend) for cls in backends.values()) - - -@pytest.mark.asyncio -async def test_increment_and_check_convenience(mock_backend): - """Test the increment_and_check convenience function.""" - with patch( - "src.infrastructure.rate_limit.provider.rate_limiter_provider.get_backend", - return_value=mock_backend, - ): - result = await increment_and_check("test:key", 5, 60) - - mock_backend.increment_and_check_mock.assert_called_once_with("test:key", 5, 60) - assert result == (1, False) - - -@pytest.mark.asyncio -async def test_get_count_convenience(mock_backend): - """Test the get_count convenience function.""" - with patch( - "src.infrastructure.rate_limit.provider.rate_limiter_provider.get_backend", - return_value=mock_backend, - ): - result = await get_count("test:key") - - mock_backend.get_count_mock.assert_called_once_with("test:key") - assert result == 1 - - -@pytest.mark.asyncio -async def test_reset_convenience(mock_backend): - """Test the reset convenience function.""" - with patch( - "src.infrastructure.rate_limit.provider.rate_limiter_provider.get_backend", - return_value=mock_backend, - ): - await reset("test:key") - - mock_backend.reset_mock.assert_called_once_with("test:key") diff --git a/backend/tests/unit/infrastructure/security/test_production_validator.py b/backend/tests/unit/infrastructure/security/test_production_validator.py index dcea705f..9fc19c27 100644 --- a/backend/tests/unit/infrastructure/security/test_production_validator.py +++ b/backend/tests/unit/infrastructure/security/test_production_validator.py @@ -24,7 +24,7 @@ def create_mock_settings(self, **overrides): "DATABASE_URL_OVERRIDE": None, "REDIS_PASSWORD": "secure_redis_password", "CACHE_BACKEND": "memcached", - "RATE_LIMITER_BACKEND": "memcached", + "RATE_LIMITER_ENABLED": False, "SESSION_BACKEND": "redis", "CORS_ENABLED": True, "CORS_ORIGINS": "https://example.com", @@ -227,7 +227,7 @@ def test_shared_redis_instance_logs_warning(self, caplog): """Test that shared Redis instances log warning.""" settings = self.create_mock_settings( CACHE_BACKEND="redis", - RATE_LIMITER_BACKEND="redis", + RATE_LIMITER_ENABLED=True, # Both using same Redis instance CACHE_REDIS_HOST="localhost", CACHE_REDIS_PORT=6379, diff --git a/backend/tests/unit/infrastructure/test_app_factory.py b/backend/tests/unit/infrastructure/test_app_factory.py index b695a38d..04f1efc9 100644 --- a/backend/tests/unit/infrastructure/test_app_factory.py +++ b/backend/tests/unit/infrastructure/test_app_factory.py @@ -12,7 +12,7 @@ from src.infrastructure.config.settings import EnvironmentOption, Settings, settings DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") -TEARDOWN_NAMES = ("close_cache", "close_rate_limiter", "close_database") +TEARDOWN_NAMES = ("close_cache", "close_database") @pytest.mark.asyncio @@ -52,10 +52,17 @@ def recorder(name: str) -> AsyncMock: auth.initialize = AsyncMock() auth.shutdown = recorder("auth_shutdown") + cache_redis_client = MagicMock() + cache_redis_client.aclose = recorder("cache_redis_client_aclose") + + rate_limiter_redis_client = MagicMock() + rate_limiter_redis_client.aclose = recorder("rate_limiter_redis_client_aclose") + mocks = { "create_tables": AsyncMock(), "initialize_cache": AsyncMock(), - "initialize_rate_limiter": AsyncMock(), + "cache_redis_client": cache_redis_client, + "rate_limiter_redis_client": rate_limiter_redis_client, "auth": auth, } for name in TEARDOWN_NAMES: @@ -88,7 +95,13 @@ async def test_teardown_runs_in_reverse_order_with_database_last(self, lifespan_ async with lifespan(FastAPI()): pass - assert call_order == ["auth_shutdown", "close_rate_limiter", "close_cache", "close_database"] + assert call_order == [ + "auth_shutdown", + "cache_redis_client_aclose", + "rate_limiter_redis_client_aclose", + "close_cache", + "close_database", + ] async def test_disposes_when_body_raises(self, lifespan_settings, patched_lifespan): """A failure while the app is serving still drains the pool.""" diff --git a/docs/changelog.md b/docs/changelog.md index 622cec9c..291dcd27 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -34,7 +34,7 @@ This is a **breaking** change for anyone importing from the old auth modules or - Auth dependencies now import from `infrastructure.auth.dependencies` (was `infrastructure.auth.session.dependencies`). - `get_password_hash` / `verify_password` now come `from crudauth` (was `infrastructure.auth.utils`). - Login lockout now returns **`429 Too Many Requests` with a `Retry-After` header** (was a generic `401`). It is throttled internally by crudauth (escalating per-IP / per-identifier), not via env vars. -- OAuth providers are now registered via crudauth's `OAuthProviderFactory` in `infrastructure/auth/oauth.py` rather than as separate `oauth/providers/.py` files. Google remains wired. +- OAuth providers are now configured through crudauth's built-in OAuth router in `infrastructure/auth/setup.py` rather than hand-rolled callback wiring. Google remains wired. - `APP_NAME`, `APP_DESCRIPTION`, and `VERSION` are now environment-configurable (read via `config(...)`; previously hardcoded). - `/check-auth` now answers anonymous callers with `{"authenticated": false}` instead of raising `401` ([#261](https://github.com/benavlabs/FastAPI-boilerplate/pull/261)). diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 4d29d769..7637ec2f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -242,7 +242,7 @@ class AuditLogFeature(Feature): feature = AuditLogFeature() ``` -A feature plugin writes into a directory that still exists in the layout — that's why this example targets `src/modules//` rather than the old `auth/oauth/providers/` path. OAuth providers are no longer separate files: they're registered with crudauth's `OAuthProviderFactory` in `infrastructure/auth/oauth.py`, so an "add an OAuth provider" plugin would edit that file (e.g. via an idempotent patch op) instead of dropping in a new module. +A feature plugin writes into a directory that still exists in the layout — that's why this example targets `src/modules//` rather than the old `auth/oauth/providers/` path. OAuth providers are no longer separate files: they're configured with crudauth's `OAuthCredentials` in `infrastructure/auth/setup.py`, so an "add an OAuth provider" plugin would edit that file (e.g. via an idempotent patch op) instead of dropping in a new module. #### 3. `pyproject.toml` — declare the entry point diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 880f76f9..b48265f3 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -84,6 +84,13 @@ CSRF_ENABLED=true # Trusted reverse proxies in front of the app (used to resolve the real client # IP for login lockout). 0 = none; set 1 behind a single nginx/Caddy. TRUSTED_PROXY_HOPS=0 + +# Password policy (enforced by crudauth on registration and password changes) +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_DIGIT=true +PASSWORD_REQUIRE_SPECIAL=true ``` Login lockout is handled by `crudauth` itself: it applies an escalating per-IP / per-identifier lockout and returns `429 Too Many Requests` with a `Retry-After` header. There are no `LOGIN_MAX_ATTEMPTS` / `LOGIN_WINDOW_MINUTES` knobs to set. @@ -127,8 +134,6 @@ CACHE_REDIS_PASSWORD= ```env RATE_LIMITER_ENABLED=true -RATE_LIMITER_BACKEND=redis # or "memcached" -RATE_LIMITER_FAIL_OPEN=true DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 @@ -139,6 +144,10 @@ RATE_LIMITER_REDIS_DB=1 RATE_LIMITER_REDIS_PASSWORD= ``` +API limits are resolved by `crudauth` per request from the user's tier and path. Authenticated +requests are keyed by user ID; anonymous requests are keyed by the client IP, honoring +`TRUSTED_PROXY_HOPS`. + ### Background Tasks (Taskiq) ```env @@ -192,6 +201,10 @@ OAUTH_GITHUB_CLIENT_SECRET= Leave the credentials empty to disable a provider. See [Authentication](../user-guide/authentication/index.md) for the OAuth setup walkthrough. +The built-in crudauth OAuth router keeps the existing paths (`/api/v1/auth/oauth/{provider}` and +`/api/v1/auth/oauth/callback/{provider}`), returns JSON, sets session cookies, binds state to the +browser, and validates post-login redirects as same-origin relative paths. + ### Admin Interface ```env diff --git a/docs/index.md b/docs/index.md index 0c406080..d5d8f294 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ Postgres is the only hard requirement of those you have to provide — run the b ### Security & Authentication - Server-side session authentication with secure HTTP-only cookies -- OAuth 2.0 sign-in (Google wired; add others via crudauth's `OAuthProviderFactory`) using PKCE +- OAuth 2.0 sign-in (Google wired; add others via crudauth's `OAuthCredentials`) using PKCE - API keys with per-key permissions and usage tracking - CSRF protection and login rate limiting - Role-based access control with user tiers diff --git a/docs/user-guide/api/index.md b/docs/user-guide/api/index.md index d3645693..27111e7e 100644 --- a/docs/user-guide/api/index.md +++ b/docs/user-guide/api/index.md @@ -186,7 +186,7 @@ What ships out of the box (40 total routes): | `GET /api/v1/tiers/*` | `modules/tier/routes.py` | Authenticated list + lookup by name | | `GET/PATCH/DELETE /api/v1/rate-limits/*` | `modules/rate_limit/routes.py` | Superuser only | | `POST /api/v1/auth/login`, `logout`, `logout-all`, `refresh-csrf`, `check-auth` | `infrastructure/auth/routes.py` | Session auth | -| `GET /api/v1/auth/oauth/google`, `oauth/callback/google` | `infrastructure/auth/routes.py` | Google OAuth | +| `GET /api/v1/auth/oauth/{provider}`, `oauth/callback/{provider}` | crudauth router mounted in `infrastructure/auth/routes.py` | Google OAuth (configured in `infrastructure/auth/setup.py`) | | `POST/GET/PATCH/DELETE /api/v1/api-keys/*` | `modules/api_keys/routes.py` | Authenticated key management | | `GET /admin/*` | `interfaces/admin/initialize.py` | SQLAdmin UI | | `GET /docs`, `/redoc`, `/openapi.json` | App factory (protected when gated) | Disabled in production unless `ENABLE_DOCS_IN_PRODUCTION=true`; when enabled in production or running in staging, requires superuser authentication | diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md index 72a8901b..e87a91bb 100644 --- a/docs/user-guide/authentication/index.md +++ b/docs/user-guide/authentication/index.md @@ -88,10 +88,12 @@ curl http://localhost:8000/api/v1/auth/oauth/google # After the user signs in at Google, they hit the callback: # GET /api/v1/auth/oauth/callback/google?code=...&state=... -# The server creates a session and either redirects or returns JSON. +# The server creates a session and returns JSON with the CSRF token. ``` -Only Google is wired (in the `oauth_providers` dict in `infrastructure/auth/oauth.py`), and the `User` model keeps `github_id` and `oauth_provider` columns. crudauth's `OAuthProviderFactory` already ships both `google` and `github` providers, so enabling **GitHub** is just adding a `"github"` entry to the `oauth_providers` dict and its two routes in `infrastructure/auth/routes.py` — no provider implementation needed. For a provider crudauth doesn't ship, register it with `OAuthProviderFactory` first, then wire the dict entry and routes the same way. +Only Google is wired when its credentials are configured. The router is supplied by crudauth and +uses PKCE, browser-bound single-use state, session cookies, JSON responses, and safe same-origin +redirects. Add another provider in `infrastructure/auth/setup.py` using `OAuthCredentials`. ### 3. API Keys (Machine-to-Machine) diff --git a/docs/user-guide/authentication/permissions.md b/docs/user-guide/authentication/permissions.md index 648b37db..b6ff066f 100644 --- a/docs/user-guide/authentication/permissions.md +++ b/docs/user-guide/authentication/permissions.md @@ -168,7 +168,7 @@ This works for "binary" features. For more complex models (per-feature quotas, m ### Tier-Based Rate Limits -Rate limiting *is* built-in: each `RateLimit` row binds a tier to a path with a `limit` and `period`. The middleware in `infrastructure/rate_limit/middleware.py` enforces these per request. See [Rate Limiting](../rate-limiting/index.md). +Rate limiting *is* built-in: each `RateLimit` row binds a tier to a path with a `limit` and `period`. crudauth's limiter, wired in `infrastructure/auth/setup.py`, enforces these per request. See [Rate Limiting](../rate-limiting/index.md). To configure rate limits for a tier: diff --git a/docs/user-guide/authentication/sessions.md b/docs/user-guide/authentication/sessions.md index 7094bbea..7eb1cc68 100644 --- a/docs/user-guide/authentication/sessions.md +++ b/docs/user-guide/authentication/sessions.md @@ -269,7 +269,7 @@ No re-authentication step is required, because this is the action a user needs w |-----------|----------| | `auth = CRUDAuth(...)` singleton | `backend/src/infrastructure/auth/setup.py` | | Dependencies | `backend/src/infrastructure/auth/dependencies.py` | -| OAuth building blocks | `backend/src/infrastructure/auth/oauth.py` | +| OAuth configuration | `backend/src/infrastructure/auth/setup.py` | | Login/logout/logout-all/OAuth routes | `backend/src/infrastructure/auth/routes.py` | | HTTP exceptions (fastcrud re-export) | `backend/src/infrastructure/auth/http_exceptions.py` | | Auth settings | `backend/src/infrastructure/config/settings.py` (`AuthSettings`) | diff --git a/docs/user-guide/configuration/docker-setup.md b/docs/user-guide/configuration/docker-setup.md index 6d78322a..9d65371b 100644 --- a/docs/user-guide/configuration/docker-setup.md +++ b/docs/user-guide/configuration/docker-setup.md @@ -210,11 +210,11 @@ And in `.env`: ```env CACHE_BACKEND=memcached -RATE_LIMITER_BACKEND=memcached CACHE_MEMCACHED_HOST=memcached -RATE_LIMITER_MEMCACHED_HOST=memcached ``` +The rate limiter is always Redis-backed, so it still needs the `redis` service. + ### RabbitMQ (alternative Taskiq broker) ```yaml diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index fa2a99b3..adf7a111 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -85,10 +85,11 @@ CACHE_MEMCACHED_CONNECT_TIMEOUT=5 ## Rate Limiting +Provided by `crudauth` (Redis-backed). Limits are resolved per request from +the user's tier and path, falling back to the defaults below. + ```env RATE_LIMITER_ENABLED=true -RATE_LIMITER_BACKEND=redis # or "memcached" -RATE_LIMITER_FAIL_OPEN=true # allow requests when backend is unreachable DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 ``` @@ -104,14 +105,6 @@ RATE_LIMITER_REDIS_CONNECT_TIMEOUT=5 RATE_LIMITER_REDIS_POOL_SIZE=10 ``` -### Memcached backend - -```env -RATE_LIMITER_MEMCACHED_HOST=localhost -RATE_LIMITER_MEMCACHED_PORT=11211 -RATE_LIMITER_MEMCACHED_POOL_SIZE=10 -``` - ## Background Tasks (Taskiq) ```env diff --git a/docs/user-guide/configuration/index.md b/docs/user-guide/configuration/index.md index 987a05f7..fbb9be48 100644 --- a/docs/user-guide/configuration/index.md +++ b/docs/user-guide/configuration/index.md @@ -144,7 +144,6 @@ TASKIQ_REDIS_DB=3 ```env RATE_LIMITER_ENABLED=true -RATE_LIMITER_BACKEND=redis RATE_LIMITER_REDIS_HOST=localhost RATE_LIMITER_REDIS_DB=1 DEFAULT_RATE_LIMIT_LIMIT=100 diff --git a/docs/user-guide/development.md b/docs/user-guide/development.md index 699426f0..23f9cf31 100644 --- a/docs/user-guide/development.md +++ b/docs/user-guide/development.md @@ -156,7 +156,7 @@ Register in `infrastructure/app_factory.py` (or your overridden `create_applicat application.add_middleware(TimingMiddleware) ``` -Order matters — middleware added later runs **earlier** in the request path. The boilerplate's own middlewares (`SecurityHeadersMiddleware`, `ClientCacheMiddleware`, `RateLimiterMiddleware`, `SessionMiddleware`, etc.) are added in a deliberate order; see `app_factory.py:create_application`. +Order matters — middleware added later runs **earlier** in the request path. The boilerplate's own middlewares (`SecurityHeadersMiddleware`, `ClientCacheMiddleware`, `SessionMiddleware`, etc.) are added in a deliberate order; see `app_factory.py:create_application`. ## Adding a Custom Dependency @@ -357,7 +357,7 @@ Most major subsystems toggle via env vars rather than code changes: |------------------|---------------------------------------|---------------------------------------------| | Cache | `CACHE_ENABLED=false` | `@cache` becomes a no-op | | Client cache | `CLIENT_CACHE_ENABLED=false` | Middleware doesn't mount | -| Rate limiter | `RATE_LIMITER_ENABLED=false` | `check_rate_limit` returns immediately | +| Rate limiter | `RATE_LIMITER_ENABLED=false` | crudauth API limiter returns immediately | | Background tasks | Don't run the worker | The broker is created but no consumer | | Admin panel | `ADMIN_ENABLED=false` | `/admin` is unmounted | | Documentation | `OPENAPI_URL=` | Disables `/docs` and `/redoc` | diff --git a/docs/user-guide/production.md b/docs/user-guide/production.md index 6ffe73a9..daea5e94 100644 --- a/docs/user-guide/production.md +++ b/docs/user-guide/production.md @@ -74,12 +74,10 @@ SESSION_SECURE_COOKIES=true # required when serving over HTTPS CSRF_ENABLED=true TRUSTED_PROXY_HOPS=1 # set to the number of proxies in front of the app -# Rate limiting +# Rate limiting (Redis-backed, provided by crudauth) RATE_LIMITER_ENABLED=true -RATE_LIMITER_BACKEND=redis RATE_LIMITER_REDIS_HOST= RATE_LIMITER_REDIS_PASSWORD= -RATE_LIMITER_FAIL_OPEN=true # let traffic through when Redis errors # Taskiq TASKIQ_ENABLED=true diff --git a/docs/user-guide/project-structure.md b/docs/user-guide/project-structure.md index 6fc63ecd..af43eb4c 100644 --- a/docs/user-guide/project-structure.md +++ b/docs/user-guide/project-structure.md @@ -95,13 +95,11 @@ infrastructure/ ├── auth/ # crudauth wiring: deps, OAuth, route handlers │ ├── setup.py # The `auth = CRUDAuth(...)` singleton (composition root) │ ├── dependencies.py # get_current_user / _superuser / _optional_user + Principal deps -│ ├── oauth.py # crudauth OAuth building blocks (Google wired) -│ ├── routes.py # /auth/login, /logout, /logout-all, /oauth/google, /check-auth +│ ├── routes.py # /auth/login, /logout, /oauth, /check-auth │ └── http_exceptions.py # fastcrud HTTP exception re-export ├── cache/ # Redis/Memcached cache + decorator │ └── backends/ -├── rate_limit/ # Rate limiter middleware + Redis/Memcached backends -│ └── backends/ +├── redis.py # Shared Redis clients injected into crudauth and the cache ├── taskiq/ # Async task queue (broker, worker entry point, registry) ├── security/ # Production security validator └── logging/ # Centralized logging configuration diff --git a/docs/user-guide/rate-limiting/index.md b/docs/user-guide/rate-limiting/index.md index 77c49b09..7f0149fa 100644 --- a/docs/user-guide/rate-limiting/index.md +++ b/docs/user-guide/rate-limiting/index.md @@ -1,6 +1,8 @@ # Rate Limiting -The boilerplate ships a flexible rate limiter that supports per-tier, per-path limits with Redis or Memcached backends. This page covers how the pieces fit together, how to enable enforcement on your routes, and the gotchas to know upfront. +The boilerplate ships crudauth's rate limiter with per-tier, per-path limits backed by Redis. API +routes are protected by default; authenticated requests key by user ID and anonymous requests by +trusted-proxy-aware client IP. !!! tip "Building a full SaaS?" Rate limiting is part of the free foundation. **[FastroAI](https://fastro.ai)** bundles it with Stripe payments, entitlements, transactional email, a frontend, and AI agents - all wired together and production-ready. [Ship your SaaS faster →](https://fastro.ai) @@ -8,14 +10,8 @@ The boilerplate ships a flexible rate limiter that supports per-tier, per-path l ## What's Built In ```text -backend/src/infrastructure/rate_limit/ -├── base.py RateLimiterBackend abstract base -├── backends/ Redis and Memcached implementations -├── exceptions.py RateLimitException, RateLimiterBackendException -├── initialize.py initialize_rate_limiter() / close_rate_limiter() -├── middleware.py RateLimiterMiddleware + check_rate_limit dependency -├── provider.py increment_and_check, get_count, reset -└── utils.py sanitize_path +backend/src/infrastructure/auth/setup.py +└── resolve_api_rate_limit() crudauth per-request resolver backend/src/modules/rate_limit/ ├── models.py RateLimit (tier_id, path, limit, period) @@ -24,47 +20,46 @@ backend/src/modules/rate_limit/ └── schemas.py ``` -The middleware and provider are wired up; the backend is initialized in the app's lifespan. **Enforcement is opt-in per route** — see below. +The configured crudauth backend is initialized with the auth singleton in the app's lifespan. ## How a Request Flows Through It -1. **Request arrives**, `RateLimiterMiddleware` is on the stack but **does not enforce limits** — it only attaches `X-RateLimit-*` headers to the response after the handler runs. -2. **The route's `Depends(check_rate_limit)` runs.** This is the actual enforcement point. Without this dependency on a route, no limit is checked. -3. **`check_rate_limit` extracts the user** from `request.state.user` (or falls back to client IP for anonymous requests), looks up the user's tier and the matching rate-limit row from the database, and computes `(limit, period)`. -4. **`increment_and_check`** atomically increments the counter at `ratelimit:{user_or_ip}:{sanitized_path}` and returns `(count, is_limited)`. The TTL on the key is set on first increment to `period` seconds. -5. **If `is_limited`**, raises `RateLimitException` (HTTP 429). Otherwise, sets `request.state.rate_limit_headers` so the middleware can attach them to the response. +1. **The router-level crudauth dependency runs** for each API request. +2. **`resolve_api_rate_limit`** looks up the user's tier and matching path row from the database. +3. **crudauth resolves the principal** and keys authenticated requests by user ID or anonymous requests by client IP. +4. **crudauth's limiter** atomically increments the counter and returns `(count, is_limited)`. The TTL on the key is set on first increment to `period` seconds. +5. **If `is_limited`**, raises a 429. Otherwise, the limiter attaches the `X-RateLimit-*` headers to the response. The key shape (no window suffix — the TTL handles the window): ```text -ratelimit:{user_id_or_ip}:{sanitized_path} +ratelimit:{user_id_or_ip}:{action} ``` -## Enabling Enforcement on a Route +## Custom Enforcement -Add the dependency: +Shipped API routes already use a router-level dependency. For another router, reuse the same +crudauth API: ```python from fastapi import APIRouter, Depends -from src.infrastructure.rate_limit import check_rate_limit +from src.infrastructure.auth.setup import auth +from crudauth.ratelimit import KeyBy, RateLimit router = APIRouter() -@router.post("/widgets", dependencies=[Depends(check_rate_limit)]) +@router.post("/widgets", dependencies=[Depends(auth.rate_limit("widgets", RateLimit(10, 60), key=KeyBy.USER_OR_IP))]) async def create_widget(...): ... ``` Or apply it to every route in a router: ```python -router = APIRouter(dependencies=[Depends(check_rate_limit)]) +router = APIRouter(dependencies=[Depends(auth.rate_limit("widgets", RateLimit(10, 60), key=KeyBy.USER_OR_IP))]) ``` -That's all that's required — provided the rate limiter is enabled (`RATE_LIMITER_ENABLED=true`), every request to that route is checked. - -!!! warning "Currently no built-in route uses `check_rate_limit`" - The boilerplate's shipped routes (`/api/v1/users`, `/api/v1/auth`, `/api/v1/tiers`, `/api/v1/rate-limits`, `/api/v1/api-keys`) do **not** apply `check_rate_limit` by default. You add the dependency where you want enforcement. The middleware will still attach `X-RateLimit-*` headers, but only when something has populated `request.state.rate_limit_headers` — which only happens after `check_rate_limit` has run. +That's all that's required. The limiter is enabled with `RATE_LIMITER_ENABLED=true`. ## Configuration @@ -72,101 +67,42 @@ That's all that's required — provided the rate limiter is enabled (`RATE_LIMIT # Master toggle RATE_LIMITER_ENABLED=true -# Backend selection (mirrors the cache backend selector) -RATE_LIMITER_BACKEND=redis # or "memcached" - -# Behavior on backend errors: -# true → log and let the request through (recommended) -# false → raise RateLimitException ("Access denied as a precaution") -RATE_LIMITER_FAIL_OPEN=true - # Defaults applied when the user has no tier or no matching rate-limit row DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 # seconds — 100/60s by default -# Redis backend (when RATE_LIMITER_BACKEND=redis) +# Redis backend RATE_LIMITER_REDIS_HOST=redis # use "localhost" without Docker RATE_LIMITER_REDIS_PORT=6379 RATE_LIMITER_REDIS_DB=1 # rate-limiter DB (cache DB 0, sessions DB 2, taskiq DB 3) RATE_LIMITER_REDIS_PASSWORD= RATE_LIMITER_REDIS_CONNECT_TIMEOUT=5 RATE_LIMITER_REDIS_POOL_SIZE=10 - -# Memcached backend (when RATE_LIMITER_BACKEND=memcached) -RATE_LIMITER_MEMCACHED_HOST=localhost -RATE_LIMITER_MEMCACHED_PORT=11211 -RATE_LIMITER_MEMCACHED_POOL_SIZE=10 ``` -When `RATE_LIMITER_ENABLED=false`, `check_rate_limit` returns immediately — the dependency is a no-op. Useful in tests and for isolating performance issues. +When `RATE_LIMITER_ENABLED=false`, the router-level dependency is a no-op. This is useful in tests +and for isolating performance issues. ## User-Tier vs IP-Based Limits -The rate limiter has two paths depending on whether `request.state.user` is set: - -```python -# Inside _check_rate_limit -if user: - user_id = user["id"] - tier = await crud_tiers.get(db=db, id=user["tier_id"], ...) - if tier: - rate_limit = await crud_rate_limits.get(db=db, tier_id=tier["id"], path=sanitized_path, ...) - if rate_limit: - limit, period = rate_limit["limit"], rate_limit["period"] - else: - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD - else: - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD -else: - # Anonymous — key by client IP - user_id = request.client.host - limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD -``` - -!!! warning "`request.state.user` is not populated automatically" - The default session auth dependency (`get_current_user`) does not write the user back to `request.state.user`. Until you add a small helper that does, **every request looks anonymous to the rate limiter**, and tier-specific limits won't apply. - -A minimal middleware to bridge the two: - -Session validation now lives in the `crudauth` library — the `auth` singleton in `infrastructure/auth/setup.py` resolves the cookie (and enforces CSRF/lockout) and yields a `Principal`. The simplest way to bring the user into the rate limiter is at the route layer: depend on `get_optional_user` (from `infrastructure/auth/dependencies.py`) and stash the result on `request.state.user` before the limiter reads it. - -```python -# a router-level dependency you can attach where tier limits matter -from typing import Annotated, Any - -from fastapi import Depends, Request - -from src.infrastructure.auth.dependencies import get_optional_user - - -async def attach_user_to_request( - request: Request, - user: Annotated[dict[str, Any] | None, Depends(get_optional_user)], -) -> None: - if user is not None: - request.state.user = user -``` - -crudauth owns the cookie/CSRF/lockout validation, so you never re-implement session lookup — you only reuse the resolved user. (A pure-Starlette middleware can't run FastAPI dependencies, which is why this is wired as a dependency rather than as middleware.) - -For most teams, IP-based default limits (`100 req/60s`) are enough until you have an actual product reason to bring tiers into the rate-limit story. +`KeyBy.USER_OR_IP` uses the request principal when authentication is present and falls back to +the client IP using `TRUSTED_PROXY_HOPS`. The resolver checks the current path against the user's +tier and falls back to the configured default. -## Path Sanitization +## Path Matching -Paths are normalized for consistent keys: +Rate-limit rows are matched against the request path. Store the exact API path in the database, +including its `/api/v1` prefix: -```python -def sanitize_path(path: str) -> str: - return path.strip("/").replace("/", "_") - -# /api/v1/users → "api_v1_users" -# /api/v1/users/42 → "api_v1_users_42" -# /api/v1/users/{id} → "api_v1_users_{id}" +```text +/api/v1/users # matches only that route +/api/v1/users/42 # a per-resource path gets its own counter ``` -The middleware first looks up the `RateLimit` row by sanitized path. If nothing matches, it falls back to looking up the original path. **In practice you should store the sanitized form in the database** — that's what the lookup primarily uses, and it's what the cache key format mirrors. - -Note: paths with path parameters (`/users/42`) sanitize to `api_v1_users_42`, which means **each individual resource ID gets its own counter**. That's almost always what you want (otherwise a single hot resource could rate-limit unrelated reads), but if you specifically want a single counter for a parameterized route, store the rule under the literal pattern `api_v1_users_{id}` and write a small middleware that matches the route against the path template before sanitizing. +Note: paths with path parameters (`/users/42`) mean **each individual resource ID gets its own +counter**. That's almost always what you want (otherwise a single hot resource could rate-limit +unrelated reads). If you specifically want a single counter for a parameterized route, match on +the route template instead. ## Managing Rate-Limit Rules @@ -179,7 +115,7 @@ class RateLimit(Base, TimestampMixin, SoftDeleteMixin): id: int tier_id: int # FK to tiers.id name: str # unique — used as the URL path on /rate-limits/{name} - path: str # sanitized path the rule applies to + path: str # exact request path the rule applies to limit: int # max requests per period period: int # seconds ``` @@ -188,8 +124,8 @@ class RateLimit(Base, TimestampMixin, SoftDeleteMixin): | Method | Path | Auth | Notes | |--------|------------------------------|-------------|------------------------------------------| -| GET | `/api/v1/rate-limits/` | Public | Paginated list of all rate-limit rules | -| GET | `/api/v1/rate-limits/{name}` | Public | Get a rule by name | +| GET | `/api/v1/rate-limits/` | Superuser | Paginated list of all rate-limit rules | +| GET | `/api/v1/rate-limits/{name}` | Superuser | Get a rule by name | | PATCH | `/api/v1/rate-limits/{name}` | Superuser | Update an existing rule | | DELETE | `/api/v1/rate-limits/{name}` | Superuser | Delete a rule | @@ -205,8 +141,8 @@ def upgrade(): op.execute(""" INSERT INTO rate_limits (tier_id, name, path, "limit", period, created_at) VALUES - (1, 'free_widgets_create', 'api_v1_widgets', 10, 60, NOW()), - (2, 'pro_widgets_create', 'api_v1_widgets', 100, 60, NOW()) + (1, 'free_widgets_create', '/api/v1/widgets', 10, 60, NOW()), + (2, 'pro_widgets_create', '/api/v1/widgets', 100, 60, NOW()) """) ``` @@ -218,21 +154,17 @@ Add a one-off in `backend/scripts/`: # backend/scripts/setup_rate_limits.py import asyncio -from src.infrastructure.database.initialize import close_database from src.infrastructure.database.session import local_session from src.modules.rate_limit.crud import crud_rate_limits async def main(): - try: - async with local_session() as db: - await crud_rate_limits.create(db=db, object={ - "tier_id": 1, "name": "free_widgets_create", - "path": "api_v1_widgets", "limit": 10, "period": 60, - }) - await db.commit() - finally: - await close_database() + async with local_session() as db: + await crud_rate_limits.create(db=db, object={ + "tier_id": 1, "name": "free_widgets_create", + "path": "/api/v1/widgets", "limit": 10, "period": 60, + }) + await db.commit() if __name__ == "__main__": @@ -247,7 +179,7 @@ Mirror `UserAdmin` and `TierAdmin` to add a `RateLimitAdmin` view — see [Admin ## Response Headers -When `check_rate_limit` runs successfully, the middleware attaches: +When the crudauth limiter runs successfully, it attaches: | Header | Meaning | |-----------------------|--------------------------------------------------| @@ -257,52 +189,21 @@ When `check_rate_limit` runs successfully, the middleware attaches: These are standard-ish (formatted like the GitHub / Stripe convention, not RFC 6585). Frontends can read them to surface graceful "you're approaching your limit" UI. -## Programmatic Cache-like Operations - -The provider exposes the same primitives the middleware uses, in case you need to apply rate limits outside the HTTP request path (background jobs that throttle calls to a third party, for example): - -```python -from src.infrastructure.rate_limit import increment_and_check, get_count, reset - -count, is_limited = await increment_and_check( - key="external_api_calls:user_42", - limit=100, - period=3600, - fail_open=True, -) - -current = await get_count("external_api_calls:user_42") -await reset("external_api_calls:user_42") -``` - -Use a key prefix that doesn't collide with the HTTP rate limiter's `ratelimit:` namespace. - -## Backend Differences - -| Feature | Redis | Memcached | -|-----------------------------|-------|-----------| -| Atomic increment + TTL set | Yes | Yes | -| `get_count` / `reset` | Yes | Yes | -| Pattern-based reset | Yes | No | -| Connection pooling | Yes | Yes | - -Both backends do everything the middleware needs. Pick Redis if you're already running it for cache or Taskiq. - ## Production Considerations ### Pool sizing `RATE_LIMITER_REDIS_POOL_SIZE=10` is enough for typical workloads. If you're seeing `redis.exceptions.ConnectionError` under load, it usually means pool exhaustion — raise the pool size or check upstream connection-leak issues first. -### Fail-open vs fail-closed - -The default `RATE_LIMITER_FAIL_OPEN=true` means a Redis outage doesn't take your API down — requests pass through unrate-limited. This is the right call for most public APIs. +### Backend errors -If you specifically need rate limits enforced even during cache outages (e.g. you're protecting an expensive AI inference endpoint that you don't want hammered), set `RATE_LIMITER_FAIL_OPEN=false`. Be aware: a flaky Redis connection now translates directly into 429s for users. +crudauth's window checks fail open on a Redis outage — requests pass through unrate-limited rather +than turning a cache blip into 429s for everyone. The login lockout is the exception: it fails +closed, so a locked-out account can't slip through while Redis is down. ### Window behavior -The implementation uses a fixed-window counter (TTL on first increment). At the boundary between windows, a user can technically make `2 × limit` requests in a short span. For most use cases this is fine; if you need stricter sliding-window semantics, build that on top of the provider yourself. +The implementation uses a fixed-window counter (TTL on first increment). At the boundary between windows, a user can technically make `2 × limit` requests in a short span. For most use cases this is fine; if you need stricter sliding-window semantics, build that on top of the limiter yourself. ### Anonymous-user limits @@ -310,32 +211,29 @@ IP-based rate limits are easy to bypass with NAT / proxies / IPv6 rotation. They ## Troubleshooting -### "I added `Depends(check_rate_limit)` but no headers appear" +### "My limit is not matching" - Confirm `RATE_LIMITER_ENABLED=true` -- Confirm the rate limiter initialized cleanly at startup (look for `Cache backend not available` or similar) -- Confirm the dependency runs **before** the response is built (it does, by virtue of being a dependency — but if you're seeing an empty body the route may have errored earlier) +- Confirm the auth singleton initialized cleanly at startup +- Confirm the `path` column on the rule matches the exact request path, including `/api/v1` ### "All requests look anonymous even though users are logged in" -`request.state.user` isn't being populated. Either implement the bridge middleware shown above, or accept that the rate limiter operates on IP only. - -### "Path lookups never find the rate-limit row" - -Verify the `path` column in `rate_limits` matches the **sanitized** form (slashes replaced with underscores). The lookup tries sanitized first, then falls back to the original path — but keys in Redis always use the sanitized version, so configs should match. +The limiter keys on the resolved principal for authenticated requests. If users appear anonymous, +the session cookie isn't reaching the app — check `SESSION_BACKEND`, `SESSION_REDIS_DB` and any +reverse proxy that strips cookies. -### "The rate-limiter dependency raises `RateLimiterBackendException`" +### "The limiter can't reach Redis" -The Redis connection failed and `RATE_LIMITER_FAIL_OPEN=false`. Either fix Redis, switch to fail-open, or temporarily disable the limiter (`RATE_LIMITER_ENABLED=false`). +Window checks fail open, so requests keep flowing while Redis is down (the login lockout fails +closed). Fix the Redis connection, or take the limiter out of the path entirely with +`RATE_LIMITER_ENABLED=false`. ## Key Files | Component | Location | |-----------------------|-----------------------------------------------------------| -| Middleware + dependency | `backend/src/infrastructure/rate_limit/middleware.py` | -| Provider API | `backend/src/infrastructure/rate_limit/provider.py` | -| Backend implementations | `backend/src/infrastructure/rate_limit/backends/` | -| Path sanitization | `backend/src/infrastructure/rate_limit/utils.py` | +| Resolver + dependency | `backend/src/infrastructure/auth/setup.py` | | RateLimit model | `backend/src/modules/rate_limit/models.py` | | Rate-limit routes | `backend/src/modules/rate_limit/routes.py` | | Settings | `backend/src/infrastructure/config/settings.py` (`RateLimiterSettings`) | diff --git a/uv.lock b/uv.lock index df610f40..7b8cd578 100644 --- a/uv.lock +++ b/uv.lock @@ -423,6 +423,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -535,7 +633,7 @@ wheels = [ [[package]] name = "crudauth" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -546,18 +644,75 @@ dependencies = [ { name = "python-multipart" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/b9/5c698178e4d53e8e113bd35a45eff185ec7316e7775d84ab4a4e306b97f6/crudauth-0.6.0.tar.gz", hash = "sha256:25e6056584a8631b4b032725e405ff05a5513bda19e9c5131a0b8685f465fc2e", size = 103061, upload-time = "2026-06-22T02:46:23.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/fc/71ea91b84c4a2a6412836456179bcf4413f29749020654af8e410b21341b/crudauth-0.7.0.tar.gz", hash = "sha256:e23bdd043e191d7bce97858a3d01ca073ebb1682b1f7a91f31e9b1a7cb59804e", size = 146524, upload-time = "2026-09-18T00:06:01.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/11/535ee9a8f2a21ab302a249f3e37d153b4376e77503b94b9c984f099eb79a/crudauth-0.6.0-py3-none-any.whl", hash = "sha256:9951cdbd8d7c8bee33b1b789349fb565718118752dfd24779614ce2b0d5e4bf0", size = 139738, upload-time = "2026-06-22T02:46:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/7c/db/dc3b6c4feef72cbb98966d23715aae331fbcbc4f899df4881b264be9c2b0/crudauth-0.7.0-py3-none-any.whl", hash = "sha256:76c444eb4105c8ec72feccc60a914c2734a065af2b3686a6529814259124bbc3", size = 197248, upload-time = "2026-09-18T00:05:59.805Z" }, ] [package.optional-dependencies] all = [ + { name = "cryptography" }, { name = "httpx" }, { name = "redis" }, { name = "user-agents" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -690,7 +845,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.16.4" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "crudauth", extras = ["all"], specifier = ">=0.6.0,<0.7.0" }, + { name = "crudauth", extras = ["all"], specifier = ">=0.7.0,<0.8.0" }, { name = "faker", specifier = ">=37.1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, { name = "fastcrud", specifier = ">=0.21.0" }, @@ -1707,6 +1862,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycron" version = "3.2.0" From 1e462953a59fb6eaa983d1e4dd45f4ab0962de2c Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Fri, 18 Sep 2026 22:54:54 -0300 Subject: [PATCH 2/3] fix the oauth callback uri and new-user names, give each path its own rate-limit budget, take the password field from the policy, and test all of it --- backend/.env.example | 2 + backend/src/infrastructure/app_factory.py | 8 +- .../infrastructure/auth/password_policy.py | 13 ++ backend/src/infrastructure/auth/routes.py | 4 - backend/src/infrastructure/auth/setup.py | 142 ++++++++++----- backend/src/infrastructure/config/enums.py | 12 +- backend/src/infrastructure/config/settings.py | 14 +- backend/src/interfaces/api/__init__.py | 8 +- backend/src/modules/user/constants.py | 11 -- backend/src/modules/user/schemas.py | 38 +--- backend/tests/conftest.py | 7 +- .../integration/api/v1/users/test_create.py | 37 ++++ backend/tests/integration/auth/test_oauth.py | 172 ++++++++++++++++++ .../tests/integration/test_api_rate_limits.py | 73 ++++++++ .../unit/infrastructure/auth/test_setup.py | 90 +++++++-- .../tests/unit/modules/user/test_schemas.py | 61 +------ docs/getting-started/configuration.md | 1 + docs/user-guide/authentication/index.md | 31 ++-- .../configuration/environment-variables.md | 5 +- docs/user-guide/rate-limiting/index.md | 43 +++-- 20 files changed, 560 insertions(+), 212 deletions(-) create mode 100644 backend/src/infrastructure/auth/password_policy.py create mode 100644 backend/tests/integration/auth/test_oauth.py create mode 100644 backend/tests/integration/test_api_rate_limits.py diff --git a/backend/.env.example b/backend/.env.example index 99e1eaf0..039fde38 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -67,6 +67,8 @@ CACHE_REDIS_POOL_SIZE=10 # Provided by crudauth (Redis-backed). Limits are resolved per request from the # user's tier and path, falling back to the defaults below. RATE_LIMITER_ENABLED=true +# redis (default) or memory; memory counters are per process +RATE_LIMITER_BACKEND=redis DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 diff --git a/backend/src/infrastructure/app_factory.py b/backend/src/infrastructure/app_factory.py index 1696daa2..2e21783e 100644 --- a/backend/src/infrastructure/app_factory.py +++ b/backend/src/infrastructure/app_factory.py @@ -66,13 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: teardown.push_async_callback(rate_limiter_redis_client.aclose) - # The cache backend owns ``cache_redis_client`` when it's redis-backed; - # otherwise the module-level client still needs releasing. - if not ( - isinstance(settings, CacheSettings) - and settings.CACHE_ENABLED - and settings.CACHE_BACKEND == "redis" - ): + if not (isinstance(settings, CacheSettings) and settings.CACHE_ENABLED and settings.CACHE_BACKEND == "redis"): teardown.push_async_callback(cache_redis_client.aclose) teardown.push_async_callback(auth.shutdown) diff --git a/backend/src/infrastructure/auth/password_policy.py b/backend/src/infrastructure/auth/password_policy.py new file mode 100644 index 00000000..b3f036cb --- /dev/null +++ b/backend/src/infrastructure/auth/password_policy.py @@ -0,0 +1,13 @@ +"""The password policy every password-writing path enforces, built from settings.""" + +from crudauth import PasswordPolicy + +from ..config.settings import settings + +password_policy = PasswordPolicy( + min_length=settings.PASSWORD_MIN_LENGTH, + require_uppercase=settings.PASSWORD_REQUIRE_UPPERCASE, + require_lowercase=settings.PASSWORD_REQUIRE_LOWERCASE, + require_digit=settings.PASSWORD_REQUIRE_DIGIT, + require_special=settings.PASSWORD_REQUIRE_SPECIAL, +) diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index 7b783673..b07f7bf5 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -169,10 +169,6 @@ async def refresh_csrf_token( return {"csrf_token": csrf_token} -if crud_auth.oauth is not None: - router.include_router(crud_auth.oauth_router) - - @router.get("/check-auth") async def check_auth( principal: Annotated[Principal | None, Depends(get_optional_principal)], diff --git a/backend/src/infrastructure/auth/setup.py b/backend/src/infrastructure/auth/setup.py index fecf3b25..708253aa 100644 --- a/backend/src/infrastructure/auth/setup.py +++ b/backend/src/infrastructure/auth/setup.py @@ -6,86 +6,130 @@ connections via ``auth.initialize()`` / ``auth.shutdown()`` (see ``app_factory``). Wires a single session transport (sessions + CSRF + escalating login lockout) -over the configured session backend, plus a shared Redis rate limiter for the -lockout counters. Email recovery and sudo are intentionally not configured - -the boilerplate has no email pipeline, and no route gates on sudo. +over the configured session backend, the rate limiter behind both the login +lockout and the per-tier API limits, the password policy, and Google OAuth when +it's configured. Email recovery and sudo are intentionally not configured - the +boilerplate has no email pipeline, and no route gates on sudo. """ -from crudauth import CookieConfig, CRUDAuth, OAuthCredentials, PasswordPolicy, Principal, SessionTransport -from crudauth.ratelimit import KeyBy, RateLimit, redis_rate_limiter +from contextlib import asynccontextmanager +from typing import Any + +from crudauth import CookieConfig, CRUDAuth, NewUserContext, OAuthCredentials, Principal, SessionTransport +from crudauth.ratelimit import RateLimit, RateLimiterBackend, redis_rate_limiter +from crudauth.utils import client_ip_key, get_client_ip from fastapi import Request from ...modules.rate_limit.crud import crud_rate_limits from ...modules.rate_limit.schemas import RateLimitSelect -from ...modules.tier.crud import crud_tiers -from ...modules.tier.schemas import TierSelect +from ...modules.user.constants import NAME_MAX_LENGTH from ...modules.user.models import User +from ..config.enums import RateLimiterBackend as RateLimiterBackendName +from ..config.enums import SessionBackend from ..config.settings import settings -from ..database.session import async_session, local_session +from ..database.session import async_session from ..redis import rate_limiter_redis_client +from .password_policy import password_policy + +OAUTH_PREFIX = "/api/v1/auth/oauth" + + +def _rate_limiter() -> RateLimiterBackend | None: + """The limiter backend ``RATE_LIMITER_BACKEND`` names; ``None`` lets crudauth use memory.""" + backend = settings.RATE_LIMITER_BACKEND + if backend == RateLimiterBackendName.REDIS: + return redis_rate_limiter(client=rate_limiter_redis_client) + if backend == RateLimiterBackendName.MEMORY: + return None + raise ValueError( + f"RATE_LIMITER_BACKEND={backend!r} isn't supported; use 'redis' or 'memory'. " + "The memcached rate limiter was removed when rate limiting moved to crudauth." + ) -_session_redis_url = settings.SESSION_REDIS_URL -_use_redis = settings.SESSION_BACKEND == "redis" -_oauth = {} -if settings.OAUTH_GOOGLE_CLIENT_ID and settings.OAUTH_GOOGLE_CLIENT_SECRET: - _oauth["google"] = OAuthCredentials( - client_id=settings.OAUTH_GOOGLE_CLIENT_ID, - client_secret=settings.OAUTH_GOOGLE_CLIENT_SECRET, +def _session_transport() -> SessionTransport: + """Cookie sessions on ``SESSION_BACKEND``, on their own Redis database when Redis-backed.""" + use_redis = settings.SESSION_BACKEND == SessionBackend.REDIS + return SessionTransport( + backend=SessionBackend.REDIS.value if use_redis else SessionBackend.MEMORY.value, + redis_url=settings.SESSION_REDIS_URL if use_redis else None, + csrf=settings.CSRF_ENABLED, + max_sessions_per_user=settings.MAX_SESSIONS_PER_USER, + session_timeout_minutes=settings.SESSION_TIMEOUT_MINUTES, + cleanup_interval_minutes=settings.SESSION_CLEANUP_INTERVAL_MINUTES, ) + +def _new_user_fields(context: NewUserContext) -> dict[str, Any]: + """The columns crudauth doesn't fill for an account it creates: the display name.""" + return {"name": context.suggested_name[:NAME_MAX_LENGTH]} + + +def _oauth_providers() -> dict[str, OAuthCredentials]: + if settings.OAUTH_GOOGLE_CLIENT_ID and settings.OAUTH_GOOGLE_CLIENT_SECRET: + return { + "google": OAuthCredentials( + client_id=settings.OAUTH_GOOGLE_CLIENT_ID, + client_secret=settings.OAUTH_GOOGLE_CLIENT_SECRET, + ) + } + return {} + + auth = CRUDAuth( session=async_session, user_model=User, SECRET_KEY=settings.SECRET_KEY, cookies=CookieConfig(secure=settings.SESSION_SECURE_COOKIES), - transports=[ - SessionTransport( - backend="redis" if _use_redis else "memory", - redis_url=_session_redis_url if _use_redis else None, - csrf=settings.CSRF_ENABLED, - max_sessions_per_user=settings.MAX_SESSIONS_PER_USER, - session_timeout_minutes=settings.SESSION_TIMEOUT_MINUTES, - cleanup_interval_minutes=settings.SESSION_CLEANUP_INTERVAL_MINUTES, - ) - ], - rate_limiter=redis_rate_limiter(client=rate_limiter_redis_client) if settings.RATE_LIMITER_ENABLED and _use_redis else None, + transports=[_session_transport()], + rate_limiter=_rate_limiter(), trusted_proxy_hops=settings.TRUSTED_PROXY_HOPS, - password_policy=PasswordPolicy( - min_length=settings.PASSWORD_MIN_LENGTH, - require_uppercase=settings.PASSWORD_REQUIRE_UPPERCASE, - require_lowercase=settings.PASSWORD_REQUIRE_LOWERCASE, - require_digit=settings.PASSWORD_REQUIRE_DIGIT, - require_special=settings.PASSWORD_REQUIRE_SPECIAL, - ), - oauth=_oauth or None, - redirect_base_url=settings.OAUTH_REDIRECT_BASE_URL, + password_policy=password_policy, + new_user_fields=_new_user_fields, + oauth=_oauth_providers() or None, + redirect_base_url=settings.OAUTH_REDIRECT_BASE_URL.rstrip("/"), oauth_paths={ - "prefix": "/oauth", + "prefix": OAUTH_PREFIX, "authorize_path": "/{provider}", "callback_path": "/callback/{provider}", }, - oauth_response_mode="json", + oauth_response_mode="redirect", ) +def api_rate_limit_key(request: Request, principal: Principal | None) -> str: + """Name the budget a request counts against: one per caller per path. + + Tier limits are configured per path, so each path keeps its own counter - + spending the budget on one route never throttles another. + """ + if principal is not None: + caller = f"user:{principal.user_id}" + else: + caller = f"ip:{client_ip_key(get_client_ip(request, settings.TRUSTED_PROXY_HOPS))}" + return f"{caller}:{request.url.path}" + + async def resolve_api_rate_limit(request: Request, principal: Principal | None) -> RateLimit | None: - """Resolve the configured tier/path limit for crudauth's limiter.""" + """The limit for this request: the caller's tier row for the path, else the default. + + The row is read through the app's own database dependency, honoring any + override on it, so the lookup uses the same database as the route it guards. + """ if not settings.RATE_LIMITER_ENABLED: return None - async with local_session() as db: - tier_id = auth.repo.get(principal.user, "tier_id") if principal and principal.user else None - if tier_id is not None: - tier = await crud_tiers.get(db=db, id=tier_id, schema_to_select=TierSelect) - if tier: - configured = await crud_rate_limits.get( - db=db, tier_id=tier["id"], path=request.url.path, schema_to_select=RateLimitSelect - ) - if configured: - return RateLimit(configured["limit"], configured["period"]) + tier_id: Any = auth.repo.get(principal.user, "tier_id") if principal is not None and principal.user else None + if tier_id is not None: + database = request.app.dependency_overrides.get(async_session, async_session) + async with asynccontextmanager(database)() as db: + configured = await crud_rate_limits.get( + db=db, tier_id=tier_id, path=request.url.path, schema_to_select=RateLimitSelect + ) + if configured: + return RateLimit(configured["limit"], configured["period"]) return RateLimit(settings.DEFAULT_RATE_LIMIT_LIMIT, settings.DEFAULT_RATE_LIMIT_PERIOD) -api_rate_limit_dependency = auth.rate_limit("api", resolve_api_rate_limit, key=KeyBy.USER_OR_IP) +api_rate_limit_dependency = auth.rate_limit("api", resolve_api_rate_limit, key=api_rate_limit_key) diff --git a/backend/src/infrastructure/config/enums.py b/backend/src/infrastructure/config/enums.py index d8cfb869..b2d9a49d 100644 --- a/backend/src/infrastructure/config/enums.py +++ b/backend/src/infrastructure/config/enums.py @@ -4,10 +4,7 @@ class CacheBackend(StrEnum): - """Cache backend types. - - Supported backends for caching and rate limiting. - """ + """Cache backend types.""" REDIS = "redis" MEMCACHED = "memcached" @@ -24,6 +21,13 @@ class SessionBackend(StrEnum): MEMORY = "memory" +class RateLimiterBackend(StrEnum): + """Rate limiter backend types (crudauth supports redis and memory only).""" + + REDIS = "redis" + MEMORY = "memory" + + class TaskiqBrokerType(StrEnum): """Taskiq message broker types. diff --git a/backend/src/infrastructure/config/settings.py b/backend/src/infrastructure/config/settings.py index 0d52a388..a13e2e39 100644 --- a/backend/src/infrastructure/config/settings.py +++ b/backend/src/infrastructure/config/settings.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings from starlette.config import Config -from .enums import CacheBackend, LogFormat, LogLevel, SessionBackend, TaskiqBrokerType +from .enums import CacheBackend, LogFormat, LogLevel, RateLimiterBackend, SessionBackend, TaskiqBrokerType logger = logging.getLogger(__name__) @@ -140,12 +140,15 @@ class CacheSettings(BaseSettings): class RateLimiterSettings(BaseSettings): """Rate limiter settings. - Rate limiting is provided by crudauth, which is Redis-backed. These settings - configure the enable flag, the default per-path limits, and the Redis - connection the shared limiter client uses. + Rate limiting is provided by crudauth. These settings configure the enable + flag, the backend, the default per-path limits, and the Redis connection the + shared limiter client uses. Attributes: - RATE_LIMITER_ENABLED: Whether to enable rate limiting. Default is True. + RATE_LIMITER_ENABLED: Whether to rate limit API routes. Default is True. + The login lockout runs on the same backend either way. + RATE_LIMITER_BACKEND: "redis" (default) or "memory". Memory counters are + per process, so they only hold for a single worker. # Default rate limit settings DEFAULT_RATE_LIMIT_LIMIT: Default number of requests allowed. Default is 100. @@ -161,6 +164,7 @@ class RateLimiterSettings(BaseSettings): """ RATE_LIMITER_ENABLED: bool = config("RATE_LIMITER_ENABLED", default=True, cast=bool) + RATE_LIMITER_BACKEND: str = config("RATE_LIMITER_BACKEND", default=RateLimiterBackend.REDIS.value) DEFAULT_RATE_LIMIT_LIMIT: int = config("DEFAULT_RATE_LIMIT_LIMIT", default=100, cast=int) DEFAULT_RATE_LIMIT_PERIOD: int = config("DEFAULT_RATE_LIMIT_PERIOD", default=60, cast=int) diff --git a/backend/src/interfaces/api/__init__.py b/backend/src/interfaces/api/__init__.py index 4afb5c81..027d1a0e 100644 --- a/backend/src/interfaces/api/__init__.py +++ b/backend/src/interfaces/api/__init__.py @@ -1,6 +1,10 @@ from fastapi import APIRouter +from ...infrastructure.auth.setup import auth from .v1 import router as v1_router -router = APIRouter(prefix="/api") -router.include_router(v1_router) +router = APIRouter() +router.include_router(v1_router, prefix="/api") + +if auth.oauth is not None: + router.include_router(auth.oauth_router) diff --git a/backend/src/modules/user/constants.py b/backend/src/modules/user/constants.py index 4341a2e4..5f9ac403 100644 --- a/backend/src/modules/user/constants.py +++ b/backend/src/modules/user/constants.py @@ -3,14 +3,3 @@ NAME_MAX_LENGTH = 30 USERNAME_MAX_LENGTH = 32 USERNAME_PATTERN = r"^[a-z0-9_]+$" - -# Each class a signup password must contain: the label its error message names it -# by, the ``AuthSettings`` flag that requires it, and the Unicode-aware predicate. -# Kept in step with crudauth's ``PasswordPolicy`` classification. -PASSWORD_CHARACTER_CLASSES = ( - ("lowercase letter", "PASSWORD_REQUIRE_LOWERCASE", str.islower), - ("uppercase letter", "PASSWORD_REQUIRE_UPPERCASE", str.isupper), - ("number", "PASSWORD_REQUIRE_DIGIT", str.isdecimal), - ("special character", "PASSWORD_REQUIRE_SPECIAL", lambda character: not character.isalnum()), -) - diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index f53ed132..bbe4c3dd 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -1,27 +1,17 @@ from datetime import datetime from typing import Annotated -from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator +from pydantic import BaseModel, ConfigDict, EmailStr, Field -from ...infrastructure.config.settings import settings +from ...infrastructure.auth.password_policy import password_policy from ..common.schemas import PersistentDeletion, TimestampSchema from .constants import ( NAME_MAX_LENGTH, - PASSWORD_CHARACTER_CLASSES, USERNAME_MAX_LENGTH, USERNAME_PATTERN, ) -def _password_description() -> str: - """Describe the configured policy for the OpenAPI password field.""" - required = [label for label, flag, _ in PASSWORD_CHARACTER_CLASSES if getattr(settings, flag)] - text = f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters" - if required: - text += " and include " + ", ".join(required) - return text + "." - - class UserBase(BaseModel): name: Annotated[str, Field(min_length=2, max_length=NAME_MAX_LENGTH, examples=["User Userson"])] username: Annotated[ @@ -92,14 +82,7 @@ class UserRead(BaseModel): class UserCreate(UserBase): """Schema for creating a new user.""" - password: Annotated[ - str, - Field( - min_length=settings.PASSWORD_MIN_LENGTH, - description=_password_description(), - examples=["Str1ngst!"], - ), - ] + password: password_policy.body_field() # type: ignore[valid-type] google_id: str | None = None github_id: str | None = None oauth_provider: str | None = None @@ -107,21 +90,6 @@ class UserCreate(UserBase): oauth_created_at: datetime | None = None oauth_updated_at: datetime | None = None - @field_validator("password") - def validate_password_strength(cls, v: str) -> str: - """Enforce the configured character-class rules, mirroring crudauth's PasswordPolicy. - - Classification is Unicode-aware: a Cyrillic password has lowercase - letters, and an accented letter counts as a letter, not as a special - character. Only the classes enabled through ``PASSWORD_REQUIRE_*`` are - checked, so this stays in step with the policy crudauth applies. - """ - for label, flag, has_class in PASSWORD_CHARACTER_CLASSES: - if getattr(settings, flag) and not any(has_class(character) for character in v): - raise ValueError(f"Password must include at least one {label}") - - return v - model_config = ConfigDict(extra="forbid") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0d8e72bb..17f2784a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,12 @@ import os # Configure the environment BEFORE importing anything from ``src``: the crudauth -# ``auth`` singleton is constructed at import time and reads ``SESSION_BACKEND``, -# so it must be set to the in-memory backend (no Redis) before that import runs. +# ``auth`` singleton is constructed at import time and reads the session and rate +# limiter backends, so both must be in-memory (no Redis) before that import runs. os.environ.setdefault("SESSION_BACKEND", "memory") +os.environ.setdefault("RATE_LIMITER_BACKEND", "memory") +os.environ.setdefault("OAUTH_GOOGLE_CLIENT_ID", "test-google-client-id") +os.environ.setdefault("OAUTH_GOOGLE_CLIENT_SECRET", "test-google-client-secret") # Tests run over http (base_url http://test), so the session/CSRF cookies must not be # Secure-only or httpx won't send them back on follow-up requests. os.environ.setdefault("SESSION_SECURE_COOKIES", "false") diff --git a/backend/tests/integration/api/v1/users/test_create.py b/backend/tests/integration/api/v1/users/test_create.py index 93551738..323f8104 100644 --- a/backend/tests/integration/api/v1/users/test_create.py +++ b/backend/tests/integration/api/v1/users/test_create.py @@ -98,3 +98,40 @@ async def test_create_superuser(superuser_auth_client: AsyncClient, db_session: await db_session.refresh(user_in_db) assert user_in_db.is_superuser is True + + +@pytest.mark.parametrize( + ("password", "requirement"), + [ + ("password123!", "uppercase"), + ("PASSWORD123!", "lowercase"), + ("Password!!!!", "digit"), + ("Password1234", "special"), + ("Senhaé123", "special"), + ], +) +async def test_signup_rejects_a_password_missing_a_required_class( + client: AsyncClient, db_session: AsyncSession, password: str, requirement: str +): + """The policy names every class a password lacks; an accented letter is a letter, not a special.""" + user_data = {**generate_unique_user_data(), "password": password} + + response = await client.post("/api/v1/users/", json=user_data) + + assert response.status_code == 422 + requirements = [error["ctx"]["requirement"] for error in response.json()["detail"]] + assert requirement in requirements + assert password not in response.text + + +async def test_signup_rejects_a_short_password(client: AsyncClient, db_session: AsyncSession): + response = await client.post("/api/v1/users/", json={**generate_unique_user_data(), "password": "Pa1!"}) + + assert response.status_code == 422 + + +async def test_signup_accepts_a_non_latin_password(client: AsyncClient, db_session: AsyncSession): + """Character classes are Unicode-aware, so a Cyrillic password has letters of both cases.""" + response = await client.post("/api/v1/users/", json={**generate_unique_user_data(), "password": "Пароль1!"}) + + assert response.status_code == 201 diff --git a/backend/tests/integration/auth/test_oauth.py b/backend/tests/integration/auth/test_oauth.py new file mode 100644 index 00000000..95e7fe8b --- /dev/null +++ b/backend/tests/integration/auth/test_oauth.py @@ -0,0 +1,172 @@ +"""Google sign-in through crudauth's OAuth router, as the app mounts it.""" + +from urllib.parse import parse_qs, urlparse + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.infrastructure.auth.setup import auth +from src.infrastructure.config.settings import settings +from src.interfaces.main import app +from src.modules.user.constants import NAME_MAX_LENGTH, USERNAME_MAX_LENGTH +from src.modules.user.models import User + +pytestmark = pytest.mark.asyncio + +BASE = settings.OAUTH_REDIRECT_BASE_URL.rstrip("/") +CALLBACK = f"{BASE}/api/v1/auth/oauth/callback/google" + + +def _stub_google(monkeypatch, profile: dict) -> None: + """Answer the token exchange and userinfo calls without reaching Google.""" + provider = auth.oauth_providers["google"] + + async def exchange_code(code, code_verifier=None, headers=None): + return {"access_token": "google-access-token", "token_type": "Bearer"} + + async def get_user_info(access_token): + return profile + + monkeypatch.setattr(provider, "exchange_code", exchange_code) + monkeypatch.setattr(provider, "get_user_info", get_user_info) + + +async def _start(client: AsyncClient, **params) -> str: + """Begin a sign-in and return the state Google would echo back.""" + response = await client.get("/api/v1/auth/oauth/google", params=params, follow_redirects=False) + assert response.status_code == 307 + return parse_qs(urlparse(response.headers["location"]).query)["state"][0] + + +async def test_google_is_told_to_return_to_the_route_that_serves_the_callback(): + """The URI Google redirects to must be one the app actually routes.""" + assert auth.oauth_providers["google"].redirect_uri == CALLBACK + assert "/api/v1/auth/oauth/callback/{provider}" in {route.path for route in app.routes} + + +async def test_authorize_sends_the_browser_to_google_with_pkce(client: AsyncClient): + response = await client.get("/api/v1/auth/oauth/google", follow_redirects=False) + + assert response.status_code == 307 + location = urlparse(response.headers["location"]) + params = parse_qs(location.query) + assert location.netloc == "accounts.google.com" + assert params["redirect_uri"] == [CALLBACK] + assert params["code_challenge_method"] == ["S256"] + assert params["state"] + + +async def test_a_successful_callback_signs_the_user_in_and_returns_them( + client: AsyncClient, db_session: AsyncSession, monkeypatch +): + _stub_google( + monkeypatch, + { + "sub": "google-123", + "email": "grace.hopper@example.com", + "email_verified": True, + "name": "Grace Hopper", + "given_name": "Grace", + "picture": "https://example.com/grace.png", + }, + ) + state = await _start(client, redirect_to="/dashboard") + + response = await client.get( + "/api/v1/auth/oauth/callback/google", params={"code": "the-code", "state": state}, follow_redirects=False + ) + + assert response.status_code == 307 + assert response.headers["location"] == "/dashboard" + assert "session_id" in response.cookies + user = (await db_session.execute(select(User).where(User.email == "grace.hopper@example.com"))).scalar_one() + assert user.name == "Grace Hopper" + assert user.google_id == "google-123" + check = await client.get("/api/v1/auth/check-auth") + assert check.json()["authenticated"] is True + + +async def test_long_provider_names_fit_the_user_columns(client: AsyncClient, db_session: AsyncSession, monkeypatch): + """Generated usernames and display names are bounded to the model's column widths.""" + long_name = "Wolfeschlegelsteinhausenbergerdorff Hubert Blaine Senior" + _stub_google( + monkeypatch, + { + "sub": "google-456", + "email": "hubert@example.com", + "email_verified": True, + "name": long_name, + "given_name": "Hubert", + }, + ) + state = await _start(client) + + response = await client.get( + "/api/v1/auth/oauth/callback/google", params={"code": "the-code", "state": state}, follow_redirects=False + ) + + assert response.status_code == 307 + user = (await db_session.execute(select(User).where(User.google_id == "google-456"))).scalar_one() + assert 0 < len(user.name) <= NAME_MAX_LENGTH + assert 0 < len(user.username) <= USERNAME_MAX_LENGTH + + +async def test_an_email_longer_than_the_column_is_refused_cleanly(client: AsyncClient, db_session: AsyncSession, monkeypatch): + """An address that can't be stored ends the sign-in with an error, not a 500.""" + _stub_google( + monkeypatch, + { + "sub": "google-999", + "email": f"{'a' * 60}@example.com", + "email_verified": True, + "name": "Long Address", + }, + ) + state = await _start(client) + + response = await client.get( + "/api/v1/auth/oauth/callback/google", params={"code": "the-code", "state": state}, follow_redirects=False + ) + + assert response.status_code == 307 + assert parse_qs(urlparse(response.headers["location"]).query)["error"] == ["email_too_long"] + assert (await db_session.execute(select(User).where(User.google_id == "google-999"))).scalar_one_or_none() is None + + +async def test_an_offsite_redirect_target_falls_back_to_the_app(client: AsyncClient, monkeypatch): + _stub_google( + monkeypatch, + {"sub": "google-789", "email": "offsite@example.com", "email_verified": True, "name": "Off Site"}, + ) + state = await _start(client, redirect_to="//evil.example.com/steal") + + response = await client.get( + "/api/v1/auth/oauth/callback/google", params={"code": "the-code", "state": state}, follow_redirects=False + ) + + assert response.status_code == 307 + assert response.headers["location"] == BASE + + +async def test_a_state_this_browser_never_started_is_refused(client: AsyncClient): + """A state without its browser-bound cookie may be a login-CSRF attempt, so no session.""" + response = await client.get( + "/api/v1/auth/oauth/callback/google", + params={"code": "the-code", "state": "never-issued"}, + follow_redirects=False, + ) + + assert response.status_code == 400 + assert "session_id" not in response.cookies + + +async def test_a_provider_error_sends_the_browser_back_with_an_error(client: AsyncClient): + """The user declined at Google: back to the app, carrying the error code.""" + response = await client.get("/api/v1/auth/oauth/callback/google", params={"error": "access_denied"}, follow_redirects=False) + + assert response.status_code == 307 + location = urlparse(response.headers["location"]) + assert f"{location.scheme}://{location.netloc}" == BASE + assert parse_qs(location.query)["error"] diff --git a/backend/tests/integration/test_api_rate_limits.py b/backend/tests/integration/test_api_rate_limits.py new file mode 100644 index 00000000..01f6620c --- /dev/null +++ b/backend/tests/integration/test_api_rate_limits.py @@ -0,0 +1,73 @@ +"""Per-tier, per-path rate limits on the API routes, through crudauth's limiter.""" + +import itertools + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from src.infrastructure.auth.setup import auth +from src.infrastructure.config.settings import settings +from src.modules.rate_limit.models import RateLimit + +pytestmark = pytest.mark.asyncio + +_addresses = (f"198.51.100.{n}" for n in itertools.count(1)) + + +@pytest.fixture +def limits(monkeypatch): + """A small default limit, and each test's anonymous caller on its own address.""" + monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", True) + monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_LIMIT", 3) + monkeypatch.setattr(settings, "DEFAULT_RATE_LIMIT_PERIOD", 3600) + monkeypatch.setattr(settings, "TRUSTED_PROXY_HOPS", 1) + return {"X-Forwarded-For": next(_addresses)} + + +async def test_the_default_limit_is_enforced(client: AsyncClient, limits: dict): + statuses = [(await client.get("/api/v1/tiers/", headers=limits)).status_code for _ in range(4)] + + assert statuses[:3] == [401, 401, 401] + assert statuses[3] == 429 + + +async def test_each_path_keeps_its_own_budget(client: AsyncClient, limits: dict): + """Spending the budget on one route never throttles another.""" + for _ in range(3): + await client.get("/api/v1/tiers/", headers=limits) + + exhausted = await client.get("/api/v1/tiers/", headers=limits) + other_path = [(await client.get("/api/v1/rate-limits/", headers=limits)).status_code for _ in range(4)] + + assert exhausted.status_code == 429 + assert other_path[:3] == [401, 401, 401] + assert other_path[3] == 429 + + +async def test_a_signed_in_user_gets_their_tiers_limit_for_the_path( + client: AsyncClient, db_session: AsyncSession, test_user: dict, test_tier: dict, limits: dict +): + path = "/api/v1/tiers/" + db_session.add(RateLimit(tier_id=test_tier["id"], name="tiers_listing", path=path, limit=2, period=3600)) + await db_session.commit() + login = await client.post("/api/v1/auth/login", data={"username": test_user["username"], "password": test_user["password"]}) + assert login.status_code == 200 + await auth.rate_limiter.reset(f"ratelimit:api:user:{test_user['id']}:{path}") + + responses = [await client.get(path) for _ in range(3)] + default_path = await client.get("/api/v1/users/me") + + assert [response.status_code for response in responses] == [200, 200, 429] + assert responses[0].headers["X-RateLimit-Limit"] == "2" + assert responses[0].headers["X-RateLimit-Remaining"] == "1" + assert default_path.status_code == 200 + assert default_path.headers["X-RateLimit-Limit"] == "3" + + +async def test_disabling_rate_limits_lets_every_request_through(client: AsyncClient, limits: dict, monkeypatch): + monkeypatch.setattr(settings, "RATE_LIMITER_ENABLED", False) + + statuses = [(await client.get("/api/v1/tiers/", headers=limits)).status_code for _ in range(5)] + + assert 429 not in statuses diff --git a/backend/tests/unit/infrastructure/auth/test_setup.py b/backend/tests/unit/infrastructure/auth/test_setup.py index c75c24a0..48ed3d77 100644 --- a/backend/tests/unit/infrastructure/auth/test_setup.py +++ b/backend/tests/unit/infrastructure/auth/test_setup.py @@ -1,24 +1,92 @@ """Tests for the crudauth composition root wiring.""" +import pytest +from crudauth import Principal +from starlette.requests import Request + from src.infrastructure.auth import setup from src.infrastructure.config.settings import settings -class TestSessionRedisWiring: - """Session storage must use SESSION_REDIS_URL, on a different DB than the cache by default.""" +def _request(path: str, client_host: str = "203.0.113.7") -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [], + "client": (client_host, 1234), + "server": ("test", 80), + "scheme": "http", + } + ) - def test_session_redis_url_comes_from_session_settings(self): - """The URL handed to crudauth is SESSION_REDIS_URL.""" - assert setup._session_redis_url == settings.SESSION_REDIS_URL + +class TestSessionRedisWiring: + """Session storage uses SESSION_REDIS_URL, on a different DB than the cache by default.""" def test_session_redis_db_is_not_the_cache_db(self): """By default a cache FLUSHDB must not reach the database holding sessions.""" assert settings.SESSION_REDIS_DB != settings.CACHE_REDIS_DB - assert setup._session_redis_url.endswith(f"/{settings.SESSION_REDIS_DB}") + assert settings.SESSION_REDIS_URL.endswith(f"/{settings.SESSION_REDIS_DB}") + + def test_redis_sessions_are_built_from_the_session_url(self, monkeypatch): + monkeypatch.setattr(settings, "SESSION_BACKEND", "redis") + + transport = setup._session_transport() + + assert transport.redis_url == settings.SESSION_REDIS_URL + + def test_memory_sessions_carry_no_redis_url(self, monkeypatch): + monkeypatch.setattr(settings, "SESSION_BACKEND", "memory") + + assert setup._session_transport().redis_url is None + + +class TestRateLimiterBackend: + """RATE_LIMITER_BACKEND alone decides where the limiter and login lockout count.""" + + def test_redis_uses_the_shared_limiter_client(self, monkeypatch): + monkeypatch.setattr(settings, "RATE_LIMITER_BACKEND", "redis") + + assert setup._rate_limiter() is not None + + def test_memory_leaves_crudauth_its_in_process_limiter(self, monkeypatch): + monkeypatch.setattr(settings, "RATE_LIMITER_BACKEND", "memory") + + assert setup._rate_limiter() is None + + def test_the_removed_memcached_backend_fails_loudly(self, monkeypatch): + """A deployment still configured for memcached must not silently fall back to memory.""" + monkeypatch.setattr(settings, "RATE_LIMITER_BACKEND", "memcached") + + with pytest.raises(ValueError, match="memcached"): + setup._rate_limiter() + + +class TestApiRateLimitKey: + """Each caller gets one budget per path, so one route can't exhaust another's.""" + + def test_signed_in_callers_are_keyed_by_user_and_path(self): + principal = Principal(user_id=42, transport="session") + + assert setup.api_rate_limit_key(_request("/api/v1/tiers/"), principal) == "user:42:/api/v1/tiers/" + + def test_anonymous_callers_are_keyed_by_ip_and_path(self): + assert setup.api_rate_limit_key(_request("/api/v1/tiers/"), None) == "ip:203.0.113.7:/api/v1/tiers/" + + def test_different_paths_get_different_budgets(self): + principal = Principal(user_id=42, transport="session") + + assert setup.api_rate_limit_key(_request("/api/v1/tiers/"), principal) != setup.api_rate_limit_key( + _request("/api/v1/rate-limits/"), principal + ) + - def test_session_transport_receives_session_redis_url(self): - """The session transport is built from the session URL when Redis-backed.""" - transport = setup.auth.transports[0] +class TestOAuthWiring: + """The callback URI crudauth sends to the provider matches the route that serves it.""" - expected = setup._session_redis_url if setup._use_redis else None - assert transport.redis_url == expected + def test_the_callback_lives_under_the_api_prefix(self): + assert setup.OAUTH_PREFIX == "/api/v1/auth/oauth" diff --git a/backend/tests/unit/modules/user/test_schemas.py b/backend/tests/unit/modules/user/test_schemas.py index 9c6b6d9f..42736d5f 100644 --- a/backend/tests/unit/modules/user/test_schemas.py +++ b/backend/tests/unit/modules/user/test_schemas.py @@ -1,60 +1,19 @@ """Unit tests for the User schemas.""" -import pytest -from pydantic import ValidationError - +from src.infrastructure.config.settings import settings from src.modules.user.schemas import UserCreate -def _user_data(password: str) -> dict[str, str]: - return { - "name": "Test User", - "username": "testuser", - "email": "user.userson@example.com", - "password": password, - } - - -def test_password_with_every_character_class_is_accepted(): - """The documented example must keep working.""" - user = UserCreate(**_user_data("Str1ngst!")) - - assert user.password == "Str1ngst!" - - -@pytest.mark.parametrize( - ("password", "missing"), - [ - ("str1ngst!", "uppercase letter"), - ("STR1NGST!", "lowercase letter"), - ("Stringst!", "number"), - ("Str1ngst", "special character"), - ("abcdefgh", "uppercase letter"), - ("aaaaaaaaaaaa", "uppercase letter"), - (" ", "lowercase letter"), - ], -) -def test_password_missing_a_character_class_is_rejected(password: str, missing: str): - """A password is rejected when any class from the description is missing.""" - with pytest.raises(ValidationError, match=missing): - UserCreate(**_user_data(password)) - - -def test_a_non_latin_password_is_accepted(): - """Character classes are Unicode-aware, so a Cyrillic password has lowercase letters.""" - user = UserCreate(**_user_data("Пароль1!")) - - assert user.password == "Пароль1!" +def test_the_password_field_documents_the_configured_policy(): + """OpenAPI describes the policy crudauth enforces, from the same object.""" + field = UserCreate.model_json_schema()["properties"]["password"] + assert field["minLength"] == settings.PASSWORD_MIN_LENGTH + assert f"At least {settings.PASSWORD_MIN_LENGTH} characters" in field["description"] -def test_an_accented_letter_is_not_a_special_character(): - """``é`` is a letter, so it doesn't satisfy the special-character requirement.""" - with pytest.raises(ValidationError, match="special character"): - UserCreate(**_user_data("Senhaé123")) +def test_the_schema_leaves_enforcement_to_the_policy(): + """A weak password parses, so it's rejected by crudauth and never echoed in a validation error.""" + user = UserCreate(name="Test User", username="testuser", email="user.userson@example.com", password="weak") -@pytest.mark.parametrize("password", ["Str1ng!", "Ab1!cde"]) -def test_password_shorter_than_eight_characters_is_rejected(password: str): - """Length stays enforced separately from the character classes.""" - with pytest.raises(ValidationError, match="at least 8 characters"): - UserCreate(**_user_data(password)) + assert user.password == "weak" diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index b48265f3..f2a8df08 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -134,6 +134,7 @@ CACHE_REDIS_PASSWORD= ```env RATE_LIMITER_ENABLED=true +RATE_LIMITER_BACKEND=redis # or memory (per process, single worker only) DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md index e87a91bb..943858c9 100644 --- a/docs/user-guide/authentication/index.md +++ b/docs/user-guide/authentication/index.md @@ -79,21 +79,30 @@ Routes use `Depends(get_current_user)` to require an authenticated session. ### 2. OAuth (Google) -For social sign-in — Google OAuth 2.0 with PKCE is wired up. The user is redirected to Google, signs in, and is bounced back to a callback that creates a session. +For social sign-in — Google OAuth 2.0 with PKCE is wired up. The browser goes to Google, signs +in, and comes back to a callback that creates the session and sends it on to your app. -```bash -# Start the flow -curl http://localhost:8000/api/v1/auth/oauth/google -# → { "url": "https://accounts.google.com/...?state=..." } +```text +# Link or redirect the browser to (redirect_to is optional, same-origin paths only): +GET /api/v1/auth/oauth/google?redirect_to=/dashboard +# → 307 to https://accounts.google.com/... -# After the user signs in at Google, they hit the callback: -# GET /api/v1/auth/oauth/callback/google?code=...&state=... -# The server creates a session and returns JSON with the CSRF token. +# Google sends the browser back to: +GET /api/v1/auth/oauth/callback/google?code=...&state=... +# → session + CSRF cookies set, 307 to /dashboard (or to OAUTH_REDIRECT_BASE_URL) ``` -Only Google is wired when its credentials are configured. The router is supplied by crudauth and -uses PKCE, browser-bound single-use state, session cookies, JSON responses, and safe same-origin -redirects. Add another provider in `infrastructure/auth/setup.py` using `OAuthCredentials`. +Register `{OAUTH_REDIRECT_BASE_URL}/api/v1/auth/oauth/callback/google` as the redirect URI in the +Google console; `OAUTH_REDIRECT_BASE_URL` is the public origin of the API, without a path. + +A failed sign-in - the user declined, or their address is longer than the `email` column - sends +the browser to `OAUTH_REDIRECT_BASE_URL?error=`. A callback whose `state` doesn't match the +cookie set when the flow started gets a plain 400 instead, since it may be a login-CSRF attempt. +New accounts take their display name from the Google profile. + +Only Google is wired when its credentials are configured. The router is supplied by crudauth: PKCE, +browser-bound single-use state, and safe same-origin redirects. Add another provider in +`infrastructure/auth/setup.py` using `OAuthCredentials`. ### 3. API Keys (Machine-to-Machine) diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index adf7a111..1c4016a7 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -85,11 +85,12 @@ CACHE_MEMCACHED_CONNECT_TIMEOUT=5 ## Rate Limiting -Provided by `crudauth` (Redis-backed). Limits are resolved per request from -the user's tier and path, falling back to the defaults below. +Provided by `crudauth`, on Redis or in memory. Limits are resolved per request from +the user's tier and path, falling back to the defaults below, and each path keeps its own counter. ```env RATE_LIMITER_ENABLED=true +RATE_LIMITER_BACKEND=redis # or memory (per process, single worker only) DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 ``` diff --git a/docs/user-guide/rate-limiting/index.md b/docs/user-guide/rate-limiting/index.md index 7f0149fa..3c60f43b 100644 --- a/docs/user-guide/rate-limiting/index.md +++ b/docs/user-guide/rate-limiting/index.md @@ -26,14 +26,15 @@ The configured crudauth backend is initialized with the auth singleton in the ap 1. **The router-level crudauth dependency runs** for each API request. 2. **`resolve_api_rate_limit`** looks up the user's tier and matching path row from the database. -3. **crudauth resolves the principal** and keys authenticated requests by user ID or anonymous requests by client IP. -4. **crudauth's limiter** atomically increments the counter and returns `(count, is_limited)`. The TTL on the key is set on first increment to `period` seconds. -5. **If `is_limited`**, raises a 429. Otherwise, the limiter attaches the `X-RateLimit-*` headers to the response. +3. **`api_rate_limit_key`** names the budget: the caller (user ID when signed in, client IP otherwise) plus the request path, so every path has its own counter. +4. **crudauth's limiter** atomically increments the counter for the current window and returns `(count, is_limited)`. Windows are `period` seconds long and aligned to the clock, and each window's key expires on its own. +5. **If `is_limited`**, raises a 429 with `Retry-After`. Otherwise the limiter attaches `X-RateLimit-Limit` and `X-RateLimit-Remaining` to the response. A request that fails authentication afterwards still counts, but its 401 doesn't carry the headers. -The key shape (no window suffix — the TTL handles the window): +The key shape in Redis, ending in the start of the current window: ```text -ratelimit:{user_id_or_ip}:{action} +crudauth:rl:ratelimit:api:user:{user_id}:{path}:{window_start} +crudauth:rl:ratelimit:api:ip:{client_ip}:{path}:{window_start} ``` ## Custom Enforcement @@ -64,9 +65,13 @@ That's all that's required. The limiter is enabled with `RATE_LIMITER_ENABLED=tr ## Configuration ```env -# Master toggle +# Master toggle for the API limits (the login lockout runs either way) RATE_LIMITER_ENABLED=true +# Where counters live: redis (default) or memory. Memory is per process, so it only +# holds for a single worker. The memcached limiter was removed; that value fails at startup. +RATE_LIMITER_BACKEND=redis + # Defaults applied when the user has no tier or no matching rate-limit row DEFAULT_RATE_LIMIT_LIMIT=100 DEFAULT_RATE_LIMIT_PERIOD=60 # seconds — 100/60s by default @@ -81,12 +86,13 @@ RATE_LIMITER_REDIS_POOL_SIZE=10 ``` When `RATE_LIMITER_ENABLED=false`, the router-level dependency is a no-op. This is useful in tests -and for isolating performance issues. +and for isolating performance issues. The login lockout still counts on `RATE_LIMITER_BACKEND`, and +fails closed: with that backend unreachable, logins are refused rather than left unthrottled. ## User-Tier vs IP-Based Limits -`KeyBy.USER_OR_IP` uses the request principal when authentication is present and falls back to -the client IP using `TRUSTED_PROXY_HOPS`. The resolver checks the current path against the user's +`api_rate_limit_key` uses the request principal when authentication is present and falls back to +the client IP using `TRUSTED_PROXY_HOPS`, adding the path either way. The resolver checks the current path against the user's tier and falls back to the configured default. ## Path Matching @@ -100,9 +106,8 @@ including its `/api/v1` prefix: ``` Note: paths with path parameters (`/users/42`) mean **each individual resource ID gets its own -counter**. That's almost always what you want (otherwise a single hot resource could rate-limit -unrelated reads). If you specifically want a single counter for a parameterized route, match on -the route template instead. +counter**, and a limit row must name the concrete path to apply to it. That's almost always what +you want: a single hot resource can't rate-limit unrelated reads. ## Managing Rate-Limit Rules @@ -179,13 +184,15 @@ Mirror `UserAdmin` and `TierAdmin` to add a `RateLimitAdmin` view — see [Admin ## Response Headers -When the crudauth limiter runs successfully, it attaches: +Responses the route answers carry: -| Header | Meaning | -|-----------------------|--------------------------------------------------| -| `X-RateLimit-Limit` | The configured limit for this user × path | +| Header | Meaning | +|-------------------------|--------------------------------------------------| +| `X-RateLimit-Limit` | The configured limit for this caller × path | | `X-RateLimit-Remaining` | How many requests are left in the current window | -| `X-RateLimit-Reset` | Period (seconds) for the window | + +A 429 also carries `Retry-After`, the seconds until the window resets. A request that fails +authentication after the limiter counted it answers 401 without these headers. These are standard-ish (formatted like the GitHub / Stripe convention, not RFC 6585). Frontends can read them to surface graceful "you're approaching your limit" UI. @@ -203,7 +210,7 @@ closed, so a locked-out account can't slip through while Redis is down. ### Window behavior -The implementation uses a fixed-window counter (TTL on first increment). At the boundary between windows, a user can technically make `2 × limit` requests in a short span. For most use cases this is fine; if you need stricter sliding-window semantics, build that on top of the limiter yourself. +The implementation uses a fixed-window counter, with windows aligned to the clock. At the boundary between windows, a user can technically make `2 × limit` requests in a short span. For most use cases this is fine; if you need stricter sliding-window semantics, build that on top of the limiter yourself. ### Anonymous-user limits From 917e6e445084e75b03e7fdc2a56775bf6f2652c0 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Fri, 18 Sep 2026 23:06:29 -0300 Subject: [PATCH 3/3] finish logins through crudauth's session transport, refuse cross-site logins, and hash passwords off the event loop --- backend/src/infrastructure/auth/routes.py | 42 +++++++----- backend/src/infrastructure/auth/setup.py | 4 +- backend/src/interfaces/admin/views/users.py | 4 +- backend/src/modules/user/service.py | 4 +- .../tests/integration/auth/test_endpoints.py | 67 +++++++++++++++++++ docs/getting-started/first-run.md | 2 +- docs/user-guide/authentication/index.md | 2 +- docs/user-guide/authentication/sessions.md | 7 +- 8 files changed, 109 insertions(+), 23 deletions(-) diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index b07f7bf5..3cc0a3a2 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -1,15 +1,17 @@ from typing import Annotated, Any from crudauth import Principal -from crudauth.exceptions import UnauthorizedException +from crudauth.exceptions import ForbiddenException, UnauthorizedException from crudauth.ratelimit import KeyBy -from fastapi import APIRouter, Depends, Query, Request, Response +from crudauth.utils import is_cross_site +from fastapi import APIRouter, Depends, Form, Query, Request, Response from ...modules.user.crud import crud_users from ..dependencies import AsyncSessionDep, OAuth2FormDep from ..logging import get_logger from .dependencies import get_current_principal, get_optional_principal from .setup import auth as crud_auth +from .setup import session_transport logger = get_logger() @@ -28,39 +30,49 @@ - A session ID is set as an HTTP-only cookie - A CSRF token is generated for protection against CSRF attacks + With remember_me=true the session cookie persists across browser + restarts; otherwise it ends with the browser session. + The endpoint is protected by rate limiting to prevent brute force attacks. After multiple failed attempts, further login attempts will be temporarily blocked. + A request the browser marks as sent from another site is refused, so a + third-party page can't sign a visitor into an account it controls. """, responses={ 200: {"description": "Login successful, session created"}, 401: {"description": "Authentication failed"}, + 403: {"description": "Cross-site login request"}, 429: {"description": "Too many login attempts, try again later"}, }, - response_description="CSRF token for use in subsequent requests", + response_description="The signed-in user's id and username, and the CSRF token for subsequent requests", ) async def login( request: Request, response: Response, form_data: OAuth2FormDep, db: AsyncSessionDep, -) -> dict[str, str]: + remember_me: Annotated[bool, Form()] = False, +) -> dict[str, Any]: """Login endpoint to get session cookies. - The session ID is set as an HTTP-only cookie. The CSRF token is set as a - regular cookie and returned in the response. Credentials are verified by - crudauth's hardened ``authenticate_password`` (timing-equalized check, - disabled-account guard, escalating lockout that returns 429 + Retry-After). + Credentials go through crudauth's ``authenticate_password`` (timing-equalized + check, disabled-account guard, escalating lockout that answers 429 + + Retry-After), and the session is completed by the session transport, which + sets the cookies and fires the ``on_after_login`` hook. """ - user = await crud_auth.authenticate_password(db, form_data.username, form_data.password, request=request) + if is_cross_site(request): + raise ForbiddenException("Cross-site login requests are not allowed.") - session_id, csrf_token = await crud_auth.sessions.create_session( + user = await crud_auth.authenticate_password(db, form_data.username, form_data.password, request=request) + return await session_transport.complete_login( request, - user_id=crud_auth.repo.user_id(user), - metadata={"login_type": "password", "username": crud_auth.repo.get(user, "username")}, + response, + user, + { + "remember_me": remember_me, + "metadata": {"login_type": "password", "username": crud_auth.repo.get(user, "username")}, + }, ) - crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) - - return {"csrf_token": csrf_token} @router.post( diff --git a/backend/src/infrastructure/auth/setup.py b/backend/src/infrastructure/auth/setup.py index 708253aa..e4309699 100644 --- a/backend/src/infrastructure/auth/setup.py +++ b/backend/src/infrastructure/auth/setup.py @@ -76,12 +76,14 @@ def _oauth_providers() -> dict[str, OAuthCredentials]: return {} +session_transport = _session_transport() + auth = CRUDAuth( session=async_session, user_model=User, SECRET_KEY=settings.SECRET_KEY, cookies=CookieConfig(secure=settings.SESSION_SECURE_COOKIES), - transports=[_session_transport()], + transports=[session_transport], rate_limiter=_rate_limiter(), trusted_proxy_hops=settings.TRUSTED_PROXY_HOPS, password_policy=password_policy, diff --git a/backend/src/interfaces/admin/views/users.py b/backend/src/interfaces/admin/views/users.py index bee9bd21..13331514 100644 --- a/backend/src/interfaces/admin/views/users.py +++ b/backend/src/interfaces/admin/views/users.py @@ -2,7 +2,7 @@ from typing import Any -from crudauth import get_password_hash +from crudauth import get_password_hash_async from sqladmin import ModelView from starlette.requests import Request from wtforms import SelectField @@ -50,7 +50,7 @@ async def on_model_change(self, data: dict[str, Any], model: Any, is_created: bo """Hash the password before saving.""" if is_created and "hashed_password" in data and data["hashed_password"]: await auth.validate_password(data["hashed_password"]) - data["hashed_password"] = get_password_hash(data["hashed_password"]) + data["hashed_password"] = await get_password_hash_async(data["hashed_password"]) if "oauth_provider" in data and data["oauth_provider"] == "": data["oauth_provider"] = None diff --git a/backend/src/modules/user/service.py b/backend/src/modules/user/service.py index ce785030..b62b162e 100644 --- a/backend/src/modules/user/service.py +++ b/backend/src/modules/user/service.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from typing import Any, cast -from crudauth import get_password_hash +from crudauth import get_password_hash_async from fastcrud import JoinConfig from fastcrud.types import GetMultiResponseDict from sqlalchemy.exc import MultipleResultsFound, NoResultFound @@ -88,7 +88,7 @@ async def create(self, user: UserCreate, db: AsyncSession) -> dict[str, Any]: raise UserExistsError("Username already taken") user_internal_dict = user.model_dump() - user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) + user_internal_dict["hashed_password"] = await get_password_hash_async(user_internal_dict["password"]) del user_internal_dict["password"] user_internal = UserCreateInternal(**user_internal_dict) diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index c99540c6..f3fc2b02 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -4,8 +4,10 @@ FastAPI dependency to simulate authenticated / anonymous callers. """ +import threading from unittest.mock import patch +import bcrypt import pytest from crudauth import Principal, get_password_hash from httpx import AsyncClient @@ -283,3 +285,68 @@ async def test_check_auth_user_not_found(client: AsyncClient): assert response.json()["message"] == "User not found" finally: app.dependency_overrides = original_deps + + +def _credentials(user: dict) -> dict: + return {"username": user["username"], "password": user["password"]} + + +@pytest.mark.asyncio +async def test_login_returns_the_user_and_the_csrf_token(client: AsyncClient, test_user: dict): + response = await client.post("/api/v1/auth/login", data=_credentials(test_user)) + + body = response.json() + assert body["id"] == test_user["id"] + assert body["username"] == test_user["username"] + assert body["csrf_token"] + + +@pytest.mark.asyncio +async def test_a_cross_site_login_is_refused(client: AsyncClient, test_user: dict): + """Another site can't sign the visitor into an account it controls (login CSRF).""" + response = await client.post("/api/v1/auth/login", data=_credentials(test_user), headers={"Sec-Fetch-Site": "cross-site"}) + + assert response.status_code == 403 + assert "session_id" not in response.cookies + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fetch_site", ["same-origin", "same-site", "none"]) +async def test_a_first_party_login_is_accepted(client: AsyncClient, test_user: dict, fetch_site: str): + response = await client.post("/api/v1/auth/login", data=_credentials(test_user), headers={"Sec-Fetch-Site": fetch_site}) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_remember_me_makes_the_session_cookie_persistent(client: AsyncClient, test_user: dict): + remembered = await client.post("/api/v1/auth/login", data={**_credentials(test_user), "remember_me": "true"}) + client.cookies.clear() + forgotten = await client.post("/api/v1/auth/login", data=_credentials(test_user)) + + def session_cookie(response) -> str: + return next(c for c in response.headers.get_list("set-cookie") if c.startswith("session_id=")) + + assert "max-age" in session_cookie(remembered).lower() + assert "max-age" not in session_cookie(forgotten).lower() + + +@pytest.mark.asyncio +async def test_signup_hashes_the_password_off_the_event_loop(client: AsyncClient, db_session: AsyncSession): + """bcrypt is deliberately slow; on the loop thread it would stall every other request.""" + real_hashpw = bcrypt.hashpw + threads: list[str] = [] + + def recording_hashpw(password, salt): + threads.append(threading.current_thread().name) + return real_hashpw(password, salt) + + with patch.object(bcrypt, "hashpw", recording_hashpw): + response = await client.post( + "/api/v1/users/", + json={"name": "Off Loop", "username": "offloop", "email": "off.loop@example.com", "password": "Str1ngst!"}, + ) + + assert response.status_code == 201 + assert threads + assert threading.main_thread().name not in threads diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index 0d6b6801..fef5e9a6 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -111,7 +111,7 @@ curl -X POST "http://localhost:8000/api/v1/auth/login" \ Response sets an HTTP-only `session_id` cookie and returns a CSRF token: ```json -{ "csrf_token": "..." } +{ "id": 1, "username": "admin", "csrf_token": "..." } ``` `cookies.txt` now holds your session — pass it back with `-b cookies.txt` on subsequent requests. diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md index 943858c9..7b796403 100644 --- a/docs/user-guide/authentication/index.md +++ b/docs/user-guide/authentication/index.md @@ -66,7 +66,7 @@ curl -X POST "http://localhost:8000/api/v1/auth/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=admin&password=your_admin_password" \ -c cookies.txt -# → { "csrf_token": "..." } +# → { "id": 1, "username": "admin", "csrf_token": "..." } # Subsequent requests — send the cookie back curl http://localhost:8000/api/v1/users/me -b cookies.txt diff --git a/docs/user-guide/authentication/sessions.md b/docs/user-guide/authentication/sessions.md index 7eb1cc68..d30f6cc2 100644 --- a/docs/user-guide/authentication/sessions.md +++ b/docs/user-guide/authentication/sessions.md @@ -204,11 +204,16 @@ curl -X POST http://localhost:8000/api/v1/auth/login \ Response: ```json -{ "csrf_token": "..." } +{ "id": 1, "username": "admin", "csrf_token": "..." } ``` The HTTP-only `session_id` cookie is now in `cookies.txt`. The CSRF token is also set as a cookie *and* returned in the body so JS clients can store it (browsers can't read HTTP-only cookies). +Add `remember_me=true` to the form to make the session cookie persistent; without it the cookie ends +with the browser session. A login the browser marks `Sec-Fetch-Site: cross-site` is refused with +`403`, so another site can't sign a visitor into an account it controls. Clients that don't send the +header, such as `curl` or a mobile app, are unaffected. + ### Authenticated Request ```bash