From 476589d4b95fbdbc536052514787d86b466759c3 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 19:29:59 -0300 Subject: [PATCH 1/8] replace forked auth stack with the crudauth library --- backend/.env.example | 8 +- backend/pyproject.toml | 2 +- backend/src/infrastructure/app_factory.py | 7 +- backend/src/infrastructure/auth/__init__.py | 3 +- backend/src/infrastructure/auth/constants.py | 3 - .../src/infrastructure/auth/dependencies.py | 88 +++ backend/src/infrastructure/auth/oauth.py | 45 ++ .../src/infrastructure/auth/oauth/__init__.py | 17 - .../infrastructure/auth/oauth/dependencies.py | 95 --- .../src/infrastructure/auth/oauth/factory.py | 73 --- .../src/infrastructure/auth/oauth/provider.py | 206 ------ .../auth/oauth/providers/__init__.py | 1 - .../auth/oauth/providers/github.py | 160 ----- .../auth/oauth/providers/google.py | 113 ---- .../src/infrastructure/auth/oauth/schemas.py | 67 -- .../src/infrastructure/auth/oauth/services.py | 125 ---- backend/src/infrastructure/auth/routes.py | 291 +++------ .../infrastructure/auth/session/__init__.py | 20 - .../auth/session/backends/__init__.py | 7 - .../auth/session/backends/memcached.py | 350 ----------- .../auth/session/backends/memory.py | 229 ------- .../auth/session/backends/redis.py | 364 ----------- .../src/infrastructure/auth/session/base.py | 137 ---- .../auth/session/dependencies.py | 268 -------- .../infrastructure/auth/session/manager.py | 590 ------------------ .../infrastructure/auth/session/schemas.py | 54 -- .../infrastructure/auth/session/storage.py | 59 -- .../auth/session/user_agents_types.py | 61 -- backend/src/infrastructure/auth/setup.py | 44 ++ backend/src/infrastructure/auth/utils.py | 80 --- backend/src/infrastructure/config/enums.py | 3 +- backend/src/infrastructure/config/settings.py | 10 +- backend/src/infrastructure/dependencies.py | 26 +- backend/src/infrastructure/middleware.py | 3 +- backend/src/interfaces/admin/views/users.py | 2 +- backend/src/modules/user/models.py | 10 + backend/src/modules/user/service.py | 2 +- backend/tests/conftest.py | 92 +-- backend/tests/integration/auth/helpers.py | 71 --- .../tests/integration/auth/test_endpoints.py | 246 ++++---- .../infrastructure/auth/oauth/__init__.py | 1 - .../infrastructure/auth/oauth/test_factory.py | 141 ----- .../auth/oauth/test_provider.py | 237 ------- .../auth/oauth/test_providers.py | 232 ------- .../auth/oauth/test_services.py | 302 --------- .../infrastructure/auth/session/__init__.py | 1 - .../auth/session/backends/__init__.py | 1 - .../auth/session/backends/test_memcached.py | 221 ------- .../auth/session/backends/test_redis.py | 262 -------- .../infrastructure/auth/session/test_api.py | 284 --------- .../auth/session/test_dependencies.py | 156 ----- .../auth/session/test_manager.py | 398 ------------ .../auth/session/test_rate_limiting.py | 146 ----- uv.lock | 38 +- 54 files changed, 471 insertions(+), 5981 deletions(-) delete mode 100644 backend/src/infrastructure/auth/constants.py create mode 100644 backend/src/infrastructure/auth/dependencies.py create mode 100644 backend/src/infrastructure/auth/oauth.py delete mode 100644 backend/src/infrastructure/auth/oauth/__init__.py delete mode 100644 backend/src/infrastructure/auth/oauth/dependencies.py delete mode 100644 backend/src/infrastructure/auth/oauth/factory.py delete mode 100644 backend/src/infrastructure/auth/oauth/provider.py delete mode 100644 backend/src/infrastructure/auth/oauth/providers/__init__.py delete mode 100644 backend/src/infrastructure/auth/oauth/providers/github.py delete mode 100644 backend/src/infrastructure/auth/oauth/providers/google.py delete mode 100644 backend/src/infrastructure/auth/oauth/schemas.py delete mode 100644 backend/src/infrastructure/auth/oauth/services.py delete mode 100644 backend/src/infrastructure/auth/session/__init__.py delete mode 100644 backend/src/infrastructure/auth/session/backends/__init__.py delete mode 100644 backend/src/infrastructure/auth/session/backends/memcached.py delete mode 100644 backend/src/infrastructure/auth/session/backends/memory.py delete mode 100644 backend/src/infrastructure/auth/session/backends/redis.py delete mode 100644 backend/src/infrastructure/auth/session/base.py delete mode 100644 backend/src/infrastructure/auth/session/dependencies.py delete mode 100644 backend/src/infrastructure/auth/session/manager.py delete mode 100644 backend/src/infrastructure/auth/session/schemas.py delete mode 100644 backend/src/infrastructure/auth/session/storage.py delete mode 100644 backend/src/infrastructure/auth/session/user_agents_types.py create mode 100644 backend/src/infrastructure/auth/setup.py delete mode 100644 backend/src/infrastructure/auth/utils.py delete mode 100644 backend/tests/integration/auth/helpers.py delete mode 100644 backend/tests/unit/infrastructure/auth/oauth/__init__.py delete mode 100644 backend/tests/unit/infrastructure/auth/oauth/test_factory.py delete mode 100644 backend/tests/unit/infrastructure/auth/oauth/test_provider.py delete mode 100644 backend/tests/unit/infrastructure/auth/oauth/test_providers.py delete mode 100644 backend/tests/unit/infrastructure/auth/oauth/test_services.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/__init__.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/backends/__init__.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/backends/test_memcached.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/backends/test_redis.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/test_api.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/test_dependencies.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/test_manager.py delete mode 100644 backend/tests/unit/infrastructure/auth/session/test_rate_limiting.py diff --git a/backend/.env.example b/backend/.env.example index 370a282c..02f3478e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -134,16 +134,16 @@ SESSION_TIMEOUT_MINUTES=30 SESSION_CLEANUP_INTERVAL_MINUTES=15 MAX_SESSIONS_PER_USER=5 SESSION_SECURE_COOKIES=true +# Session storage backend: "redis" or "memory" (memcached is no longer supported) SESSION_BACKEND=redis -SESSION_COOKIE_MAX_AGE=86400 # CSRF Protection # Set to false for development/testing to disable CSRF validation CSRF_ENABLED=true -# Login Rate Limiting -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +# Number of trusted reverse proxies in front of the app, used to resolve the client +# IP for login lockout. 0 = direct (socket peer); set 1 behind a single nginx/Caddy. +TRUSTED_PROXY_HOPS=0 # =================================== # Admin Interface (SQLAdmin) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 30df749f..b8b74f16 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", - "bcrypt>=5.0.0", + "crudauth[all]>=0.6.0,<0.7.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 5be005f0..5c48ffcd 100644 --- a/backend/src/infrastructure/app_factory.py +++ b/backend/src/infrastructure/app_factory.py @@ -14,7 +14,8 @@ from fastapi.openapi.utils import get_openapi from ..modules.common.utils.error_handler import register_exception_handlers -from .auth.session.dependencies import get_current_superuser +from .auth.dependencies import get_current_superuser +from .auth.setup import auth from .cache.initialize import close_cache, initialize_cache from .config.settings import ( CacheSettings, @@ -62,11 +63,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: await initialize_rate_limiter() + await auth.initialize() + initialization_complete.set() yield finally: + await auth.shutdown() + if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED: await close_cache() diff --git a/backend/src/infrastructure/auth/__init__.py b/backend/src/infrastructure/auth/__init__.py index 34d667a5..473263ba 100644 --- a/backend/src/infrastructure/auth/__init__.py +++ b/backend/src/infrastructure/auth/__init__.py @@ -1,8 +1,7 @@ -from .session.dependencies import authenticate_user, get_current_superuser, get_current_user, get_optional_user +from .dependencies import get_current_superuser, get_current_user, get_optional_user __all__ = [ "get_current_user", "get_optional_user", "get_current_superuser", - "authenticate_user", ] diff --git a/backend/src/infrastructure/auth/constants.py b/backend/src/infrastructure/auth/constants.py deleted file mode 100644 index e9069013..00000000 --- a/backend/src/infrastructure/auth/constants.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Authentication constants.""" - -HSTS_MAX_AGE_SECONDS = 63072000 # 2 years diff --git a/backend/src/infrastructure/auth/dependencies.py b/backend/src/infrastructure/auth/dependencies.py new file mode 100644 index 00000000..cf1d10e1 --- /dev/null +++ b/backend/src/infrastructure/auth/dependencies.py @@ -0,0 +1,88 @@ +"""Auth dependencies: resolve the crudauth ``Principal`` and the dict-compat user. + +Routes depend on these; they wrap the crudauth ``auth`` singleton so the session +engine (validation, CSRF, lockout) lives in crudauth while handlers keep their +existing dict/Principal contracts. ``get_current_user`` returns the same user +dict the rest of the app (and the API-key module) already consumes, so the public +contract is unchanged. +""" + +from typing import Annotated, Any + +from crudauth import Principal +from crudauth.exceptions import ForbiddenException, UnauthorizedException +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ...modules.user.crud import crud_users +from ..database.session import async_session +from .setup import auth + + +async def get_current_principal( + principal: Annotated[Principal, Depends(auth.current_user())], +) -> Principal: + """The authenticated crudauth ``Principal`` (session-validated, CSRF-enforced). + + A single named dependency so routes that need the session id + (``principal.metadata["session_id"]``) or the transport can depend on it and + tests can override it. Raises 401 when there is no valid session. + """ + return principal + + +async def get_optional_principal( + principal: Annotated[Principal | None, Depends(auth.current_user(optional=True))], +) -> Principal | None: + """The crudauth ``Principal`` if authenticated, else ``None`` (never raises on absence). + + Still enforces CSRF on unsafe methods when a session is present. + """ + return principal + + +async def get_current_user( + principal: Annotated[Principal | None, Depends(get_optional_principal)], + db: Annotated[AsyncSession, Depends(async_session)], +) -> dict[str, Any]: + """Get the current authenticated user as a dict (resolved by crudauth). + + crudauth validates the cookie and enforces CSRF on unsafe methods; we re-load + the full row (filtering soft-deleted users) so the return value stays the dict + the handlers expect. + + Raises: + UnauthorizedException: If not authenticated or the user doesn't exist. + """ + credentials_exception = UnauthorizedException("Not authenticated") + + if principal is None: + raise credentials_exception + + user = await crud_users.get(db=db, id=principal.user_id, is_deleted=False) + + if user is None: + raise credentials_exception + + return user + + +async def get_optional_user( + principal: Annotated[Principal | None, Depends(get_optional_principal)], + db: Annotated[AsyncSession, Depends(async_session)], +) -> dict[str, Any] | None: + """Get the current user as a dict if authenticated, None otherwise.""" + if principal is None: + return None + + return await crud_users.get(db=db, id=principal.user_id, is_deleted=False) + + +async def get_current_superuser( + current_user: Annotated[dict[str, Any], Depends(get_current_user)], +) -> dict[str, Any]: + """Get the current user as a dict, requiring superuser privileges (403 otherwise).""" + if not current_user.get("is_superuser", False): + raise ForbiddenException("Insufficient privileges") + + return current_user diff --git a/backend/src/infrastructure/auth/oauth.py b/backend/src/infrastructure/auth/oauth.py new file mode 100644 index 00000000..f13ae981 --- /dev/null +++ b/backend/src/infrastructure/auth/oauth.py @@ -0,0 +1,45 @@ +"""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 ..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}, +) diff --git a/backend/src/infrastructure/auth/oauth/__init__.py b/backend/src/infrastructure/auth/oauth/__init__.py deleted file mode 100644 index 4d1d4f26..00000000 --- a/backend/src/infrastructure/auth/oauth/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""OAuth authentication integration.""" - -from .factory import OAuthProviderFactory -from .providers.github import GitHubOAuthProvider -from .providers.google import GoogleOAuthProvider -from .schemas import OAuthState, OAuthToken, OAuthUserInfo -from .services import oauth_account_service - -__all__ = [ - "GoogleOAuthProvider", - "GitHubOAuthProvider", - "OAuthProviderFactory", - "OAuthState", - "OAuthUserInfo", - "OAuthToken", - "oauth_account_service", -] diff --git a/backend/src/infrastructure/auth/oauth/dependencies.py b/backend/src/infrastructure/auth/oauth/dependencies.py deleted file mode 100644 index 08275b7c..00000000 --- a/backend/src/infrastructure/auth/oauth/dependencies.py +++ /dev/null @@ -1,95 +0,0 @@ -from fastapi import Depends, HTTPException, status - -from ....infrastructure.config.settings import get_settings -from ....modules.user.enums import OAuthProvider -from ...logging import get_logger -from ..session.storage import AbstractSessionStorage, get_session_storage -from .factory import OAuthProviderFactory -from .provider import AbstractOAuthProvider -from .providers.github import GitHubOAuthProvider -from .providers.google import GoogleOAuthProvider -from .schemas import OAuthState - -logger = get_logger() -settings = get_settings() - -OAuthProviderFactory.register_provider(OAuthProvider.GOOGLE.value, GoogleOAuthProvider) -OAuthProviderFactory.register_provider(OAuthProvider.GITHUB.value, GitHubOAuthProvider) - - -def get_oauth_state_storage() -> AbstractSessionStorage[OAuthState]: - """Get a storage backend for OAuth state objects.""" - return get_session_storage( - backend=settings.SESSION_BACKEND, - model_type=OAuthState, - prefix="oauth_state:", - expiration=1800, - host=settings.CACHE_REDIS_HOST, - port=settings.CACHE_REDIS_PORT, - db=settings.CACHE_REDIS_DB, - password=settings.CACHE_REDIS_PASSWORD, - ) - - -def get_google_provider() -> AbstractOAuthProvider: - """ - Get the configured Google OAuth provider instance. - - Returns: - Configured Google OAuth provider - - Raises: - HTTPException: If provider is not configured properly - """ - if not settings.OAUTH_GOOGLE_CLIENT_ID or not settings.OAUTH_GOOGLE_CLIENT_SECRET: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Google OAuth credentials not configured") - - try: - return OAuthProviderFactory.create_provider( - provider_name=OAuthProvider.GOOGLE.value, - client_id=settings.OAUTH_GOOGLE_CLIENT_ID, - client_secret=settings.OAUTH_GOOGLE_CLIENT_SECRET, - redirect_uri=f"{settings.OAUTH_REDIRECT_BASE_URL}/api/v1/auth/oauth/callback/{OAuthProvider.GOOGLE.value}", - ) - except ValueError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Google OAuth provider not configured") - - -def get_github_provider() -> AbstractOAuthProvider: - """ - Get the configured GitHub OAuth provider instance. - - Returns: - Configured GitHub OAuth provider - - Raises: - HTTPException: If provider is not configured properly - """ - if not settings.OAUTH_GITHUB_CLIENT_ID or not settings.OAUTH_GITHUB_CLIENT_SECRET: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GitHub OAuth credentials not configured") - - try: - return OAuthProviderFactory.create_provider( - provider_name=OAuthProvider.GITHUB.value, - client_id=settings.OAUTH_GITHUB_CLIENT_ID, - client_secret=settings.OAUTH_GITHUB_CLIENT_SECRET, - redirect_uri=f"{settings.OAUTH_REDIRECT_BASE_URL}/api/v1/auth/oauth/callback/{OAuthProvider.GITHUB.value}", - ) - except ValueError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GitHub OAuth provider not configured") - - -async def get_oauth_state( - state: str, state_storage: AbstractSessionStorage[OAuthState] = Depends(get_oauth_state_storage) -) -> OAuthState | None: - """ - Get and validate the OAuth state from storage. - - Args: - state: State parameter from OAuth callback - state_storage: Storage backend for OAuth state - - Returns: - OAuthState if found and valid, None otherwise - """ - return await state_storage.get(state, OAuthState) diff --git a/backend/src/infrastructure/auth/oauth/factory.py b/backend/src/infrastructure/auth/oauth/factory.py deleted file mode 100644 index da63b970..00000000 --- a/backend/src/infrastructure/auth/oauth/factory.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import cast - -from .provider import AbstractOAuthProvider - - -class OAuthProviderFactory: - """Factory class for creating OAuth provider instances.""" - - _providers: dict[str, type[AbstractOAuthProvider]] = {} - - @classmethod - def register_provider(cls, provider_name: str, provider_class: type[AbstractOAuthProvider]) -> None: - """ - Register an OAuth provider class. - - Args: - provider_name: Name identifier for the provider - provider_class: The provider class to register - """ - cls._providers[provider_name] = provider_class - - @classmethod - def get_provider_class(cls, provider_name: str) -> type[AbstractOAuthProvider] | None: - """ - Get an OAuth provider class by name. - - Args: - provider_name: Name identifier for the provider - - Returns: - The provider class if registered, None otherwise - """ - return cls._providers.get(provider_name) - - @classmethod - def create_provider( - cls, provider_name: str, client_id: str, client_secret: str, redirect_uri: str - ) -> AbstractOAuthProvider: - """ - Create an instance of the requested provider with the given credentials. - - Args: - provider_name: Name of the provider to create - client_id: OAuth client ID - client_secret: OAuth client secret - redirect_uri: Callback URL for OAuth flow - - Returns: - Configured provider instance - - Raises: - ValueError: If provider not registered - """ - provider_class = cls.get_provider_class(provider_name) - if not provider_class: - raise ValueError(f"OAuth provider {provider_name} not registered") - - if hasattr(provider_class, "create"): - return cast( - AbstractOAuthProvider, - provider_class.create(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri), - ) - - return provider_class( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scopes=[], - authorize_endpoint="", - token_endpoint="", - userinfo_endpoint="", - provider_name=provider_name, - ) diff --git a/backend/src/infrastructure/auth/oauth/provider.py b/backend/src/infrastructure/auth/oauth/provider.py deleted file mode 100644 index f44e91a4..00000000 --- a/backend/src/infrastructure/auth/oauth/provider.py +++ /dev/null @@ -1,206 +0,0 @@ -import base64 -import hashlib -import secrets -from abc import ABC, abstractmethod -from typing import Any, cast -from urllib.parse import urlencode - -import httpx - -from ...logging import get_logger -from .schemas import OAuthUserInfo - -logger = get_logger() - - -class AbstractOAuthProvider(ABC): - """ - Abstract base class for OAuth 2.0 authentication providers. - - This class defines the interface that all OAuth providers must implement - and provides common functionality for the OAuth authentication flow. - - Attributes: - client_id: OAuth client ID from provider - client_secret: OAuth client secret - redirect_uri: URI to redirect after authentication - scopes: List of OAuth scopes to request - authorize_endpoint: Provider's authorization endpoint - token_endpoint: Provider's token endpoint - userinfo_endpoint: Provider's user info endpoint - name: Provider identifier (e.g. "google", "github") - """ - - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uri: str, - scopes: list[str], - authorize_endpoint: str, - token_endpoint: str, - userinfo_endpoint: str, - provider_name: str, - ): - """Initialize the OAuth provider with required configuration.""" - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes - self.authorize_endpoint = authorize_endpoint - self.token_endpoint = token_endpoint - self.userinfo_endpoint = userinfo_endpoint - self._name = provider_name - - @property - def name(self) -> str: - """Get the provider name.""" - return self._name - - def generate_state(self) -> str: - """Generate a random state parameter for CSRF protection.""" - return secrets.token_urlsafe(32) - - def generate_pkce_codes(self) -> dict[str, str]: - """Generate PKCE code challenge and verifier for auth flow.""" - code_verifier = secrets.token_urlsafe(64) - code_verifier_bytes = code_verifier.encode("ascii") - code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier_bytes).digest()).decode("ascii").rstrip("=") - - return {"code_verifier": code_verifier, "code_challenge": code_challenge} - - async def get_authorization_url( - self, state: str | None = None, pkce: bool = True, extra_params: dict[str, str] | None = None - ) -> dict[str, str]: - """ - Get the authorization URL for redirecting users to the provider. - - Args: - state: Optional state parameter for CSRF protection. If not provided, - a random state will be generated. - pkce: Whether to use PKCE extension for enhanced security - extra_params: Additional query parameters to include in the URL - - Returns: - Dict containing the authorization URL and state/pkce parameters - """ - if state is None: - state = self.generate_state() - - params = { - "client_id": self.client_id, - "redirect_uri": self.redirect_uri, - "response_type": "code", - "state": state, - "scope": " ".join(self.scopes), - } - - result = {"url": "", "state": state} - - if pkce: - pkce_codes = self.generate_pkce_codes() - params["code_challenge"] = pkce_codes["code_challenge"] - params["code_challenge_method"] = "S256" - result["code_verifier"] = pkce_codes["code_verifier"] - - if extra_params: - params.update(extra_params) - - result["url"] = f"{self.authorize_endpoint}?{urlencode(params)}" - return result - - async def exchange_code( - self, code: str, code_verifier: str | None = None, headers: dict[str, str] | None = None - ) -> dict[str, Any]: - """ - Exchange authorization code for access token. - - Args: - code: Authorization code received from provider - code_verifier: PKCE code verifier if PKCE was used - headers: Additional headers for the token request - - Returns: - Dict containing access_token and other provider response - """ - data = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "code": code, - "redirect_uri": self.redirect_uri, - "grant_type": "authorization_code", - } - - if code_verifier: - data["code_verifier"] = code_verifier - - request_headers = {"Accept": "application/json"} - if headers: - request_headers.update(headers) - - try: - async with httpx.AsyncClient() as client: - response = await client.post(self.token_endpoint, data=data, headers=request_headers) - response.raise_for_status() - return cast(dict[str, Any], response.json()) - except Exception as e: - logger.error(f"Error exchanging code for {self.name}: {str(e)}") - raise - - async def get_user_info(self, access_token: str) -> dict[str, Any]: - """ - Get user information from the provider using an access token. - - Args: - access_token: OAuth access token - - Returns: - Dict containing user profile information from provider - """ - headers = { - "Authorization": f"Bearer {access_token}", - "Accept": "application/json", - } - - try: - async with httpx.AsyncClient() as client: - response = await client.get(self.userinfo_endpoint, headers=headers) - response.raise_for_status() - return cast(dict[str, Any], response.json()) - except Exception as e: - logger.error(f"Error fetching user info for {self.name}: {str(e)}") - raise - - async def validate_token(self, access_token: str) -> bool: - """ - Validate that an access token is still valid. - - Default implementation checks if we can fetch user info. - Override for providers with specific token validation endpoints. - - Args: - access_token: OAuth access token to validate - - Returns: - True if token is valid, False otherwise - """ - try: - await self.get_user_info(access_token) - return True - except Exception: - return False - - @abstractmethod - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """ - Process provider-specific user info into a standardized format. - - Must be implemented by each provider to normalize user data. - - Args: - user_info: Raw user info from provider - - Returns: - Standardized user info - """ - pass diff --git a/backend/src/infrastructure/auth/oauth/providers/__init__.py b/backend/src/infrastructure/auth/oauth/providers/__init__.py deleted file mode 100644 index 4c9936de..00000000 --- a/backend/src/infrastructure/auth/oauth/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""OAuth provider implementations.""" diff --git a/backend/src/infrastructure/auth/oauth/providers/github.py b/backend/src/infrastructure/auth/oauth/providers/github.py deleted file mode 100644 index 05d575a8..00000000 --- a/backend/src/infrastructure/auth/oauth/providers/github.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Any - -import httpx - -from ..provider import AbstractOAuthProvider -from ..schemas import OAuthUserInfo - - -class GitHubOAuthProvider(AbstractOAuthProvider): - """ - OAuth authentication provider for GitHub Sign-In. - - This provider implements GitHub's OAuth 2.0 authentication flow, - allowing users to sign in with their GitHub accounts. It handles - the OAuth flow and standardizes the user information format. - """ - - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uri: str, - scopes: list[str] | None = None, - ): - """ - Initialize the GitHub OAuth provider. - - Args: - client_id: GitHub OAuth client ID from GitHub Developer Settings - client_secret: GitHub OAuth client secret - redirect_uri: Callback URL for OAuth flow completion - scopes: Optional list of GitHub OAuth scopes to request. - If not provided, uses default scopes for basic profile - and email access. - """ - default_scopes = ["read:user", "user:email"] - - super().__init__( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scopes=scopes or default_scopes, - authorize_endpoint="https://github.com/login/oauth/authorize", - token_endpoint="https://github.com/login/oauth/access_token", - userinfo_endpoint="https://api.github.com/user", - provider_name="github", - ) - - async def exchange_code( - self, code: str, code_verifier: str | None = None, headers: dict[str, str] | None = None - ) -> dict[str, Any]: - """ - Override to handle GitHub-specific token response. - - GitHub requires the 'Accept: application/json' header to receive - the response in JSON format instead of the default - application/x-www-form-urlencoded. - - Args: - code: The authorization code received from GitHub - code_verifier: PKCE code verifier if PKCE was used - headers: Optional additional headers for the token request - - Returns: - Dict[str, Any]: The token response containing: - - access_token: OAuth access token - - token_type: Token type (usually "bearer") - - scope: Granted scopes as a comma-separated string - """ - if headers is None: - headers = {} - - headers["Accept"] = "application/json" - return await super().exchange_code(code, code_verifier, headers) - - async def get_user_info(self, access_token: str) -> dict[str, Any]: - """ - Get both user profile and email information from GitHub. - - Makes two API calls: - 1. Fetches the user's profile from the user endpoint - 2. Fetches the user's email addresses from the emails endpoint - - GitHub requires separate API calls to get email information, - especially for users with private email addresses. - - Args: - access_token: Valid GitHub OAuth access token - - Returns: - Dict[str, Any]: Combined user profile and email data - """ - profile = await super().get_user_info(access_token) - - headers = { - "Authorization": f"Bearer {access_token}", - "Accept": "application/json", - } - - async with httpx.AsyncClient() as client: - response = await client.get("https://api.github.com/user/emails", headers=headers) - - if response.status_code == 200: - emails_data = response.json() - profile["emails"] = emails_data - - return profile - - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """ - Process GitHub user info into standardized format. - - Transforms the raw user info from GitHub's API into a consistent - format. Handles the extraction of primary email and its verification - status from the emails array. - - Args: - user_info: Raw user info from GitHub containing fields like - id, login, name, emails array, etc. - - Returns: - Standardized user info - """ - email = None - email_verified = False - - if emails := user_info.get("emails", []): - for e in emails: - if e.get("primary"): - email = e.get("email") - email_verified = e.get("verified", False) - break - - return OAuthUserInfo( - provider="github", - provider_user_id=str(user_info.get("id")), - email=email, - email_verified=email_verified, - name=user_info.get("name"), - given_name=None, - family_name=None, - username=user_info.get("login"), - picture=user_info.get("avatar_url"), - raw_data=user_info, - ) - - @classmethod - def create(cls, client_id: str, client_secret: str, redirect_uri: str) -> "GitHubOAuthProvider": - """ - Factory method to create an instance with default settings. - - Args: - client_id: GitHub OAuth client ID - client_secret: GitHub OAuth client secret - redirect_uri: Callback URL for OAuth flow completion - - Returns: - Configured GitHubOAuthProvider instance - """ - return cls(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri) diff --git a/backend/src/infrastructure/auth/oauth/providers/google.py b/backend/src/infrastructure/auth/oauth/providers/google.py deleted file mode 100644 index 7b950364..00000000 --- a/backend/src/infrastructure/auth/oauth/providers/google.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import Any - -from ..provider import AbstractOAuthProvider -from ..schemas import OAuthUserInfo - - -class GoogleOAuthProvider(AbstractOAuthProvider): - """ - OAuth authentication provider for Google Sign-In. - - This provider implements Google's OAuth 2.0 authentication flow, - allowing users to sign in with their Google accounts. It handles - the OAuth flow and standardizes the user information format. - """ - - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uri: str, - scopes: list[str] | None = None, - ): - """ - Initialize the Google OAuth provider. - - Args: - client_id: Google OAuth client ID from Google Cloud Console - client_secret: Google OAuth client secret - redirect_uri: Callback URL for OAuth flow completion - scopes: Optional list of Google OAuth scopes to request. - If not provided, uses default scopes for basic profile - and email access. - """ - default_scopes = [ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - ] - - super().__init__( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scopes=scopes or default_scopes, - authorize_endpoint="https://accounts.google.com/o/oauth2/v2/auth", - token_endpoint="https://oauth2.googleapis.com/token", - userinfo_endpoint="https://www.googleapis.com/oauth2/v3/userinfo", - provider_name="google", - ) - - async def get_authorization_url( - self, state: str | None = None, pkce: bool = True, extra_params: dict[str, str] | None = None - ) -> dict[str, str]: - """ - Get Google authorization URL with additional parameters. - - Adds Google-specific parameters like access_type=offline to - request a refresh token. - - Args: - state: Optional state parameter for CSRF protection - pkce: Whether to use PKCE for enhanced security - extra_params: Additional query parameters to include - - Returns: - Dict with authorization URL and state/PKCE parameters - """ - if extra_params is None: - extra_params = {} - - extra_params["access_type"] = "offline" - extra_params["prompt"] = "consent" - - return await super().get_authorization_url(state, pkce, extra_params) - - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """ - Process Google user info into standardized format. - - Args: - user_info: Raw user info from Google containing fields like - sub, email, name, picture, etc. - - Returns: - Standardized user info - """ - return OAuthUserInfo( - provider="google", - provider_user_id=str(user_info.get("sub", "")), - email=user_info.get("email"), - email_verified=user_info.get("email_verified", False), - name=user_info.get("name"), - given_name=user_info.get("given_name"), - family_name=user_info.get("family_name"), - username=None, - picture=user_info.get("picture"), - raw_data=user_info, - ) - - @classmethod - def create(cls, client_id: str, client_secret: str, redirect_uri: str) -> "GoogleOAuthProvider": - """ - Factory method to create an instance with default settings. - - Args: - client_id: Google OAuth client ID - client_secret: Google OAuth client secret - redirect_uri: Callback URL for OAuth flow completion - - Returns: - Configured GoogleOAuthProvider instance - """ - return cls(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri) diff --git a/backend/src/infrastructure/auth/oauth/schemas.py b/backend/src/infrastructure/auth/oauth/schemas.py deleted file mode 100644 index e6000697..00000000 --- a/backend/src/infrastructure/auth/oauth/schemas.py +++ /dev/null @@ -1,67 +0,0 @@ -from datetime import UTC, datetime -from typing import Any - -from pydantic import BaseModel, Field - - -class OAuthState(BaseModel): - """ - Store data needed for OAuth state validation. - - Used to maintain state between authorization request and callback. - """ - - state: str = Field(description="State parameter for CSRF protection") - provider: str = Field(description="OAuth provider name") - code_verifier: str | None = Field(None, description="PKCE code verifier") - redirect_to: str | None = Field(None, description="Where to redirect after authentication") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - - -class OAuthUserInfo(BaseModel): - """ - Standardized user information from OAuth providers. - - Each provider's raw user information is normalized to this format. - """ - - provider: str = Field(description="OAuth provider name") - provider_user_id: str = Field(description="User ID from the provider") - email: str | None = Field(None, description="User's email address") - email_verified: bool = Field(default=False, description="Whether email is verified") - name: str | None = Field(None, description="User's full name") - given_name: str | None = Field(None, description="User's given/first name") - family_name: str | None = Field(None, description="User's family/last name") - username: str | None = Field(None, description="Username if available") - picture: str | None = Field(None, description="URL to user's profile picture") - raw_data: dict[str, Any] = Field(default_factory=dict, description="Raw provider data") - - -class OAuthToken(BaseModel): - """ - OAuth token information. - - Stores token data received from OAuth providers. - """ - - access_token: str - token_type: str = "Bearer" - id_token: str | None = None - refresh_token: str | None = None - expires_in: int | None = None - scope: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - - @property - def is_expired(self) -> bool: - """ - Check if the token is expired. - - Returns: - True if expired, False if still valid or no expiration set - """ - if not self.expires_in: - return False - - expiry = self.created_at.timestamp() + self.expires_in - return datetime.now(UTC).timestamp() > expiry diff --git a/backend/src/infrastructure/auth/oauth/services.py b/backend/src/infrastructure/auth/oauth/services.py deleted file mode 100644 index 51aad390..00000000 --- a/backend/src/infrastructure/auth/oauth/services.py +++ /dev/null @@ -1,125 +0,0 @@ -import secrets -from datetime import UTC, datetime -from typing import Any, cast - -from sqlalchemy.ext.asyncio import AsyncSession - -from ....modules.user.crud import crud_users -from ....modules.user.enums import OAuthProvider -from ....modules.user.schemas import UserCreateInternal, UserRead -from ...auth.utils import get_password_hash -from ...logging import get_logger -from .schemas import OAuthUserInfo - -logger = get_logger() - - -class OAuthAccountService: - """ - Service for handling OAuth account creation and linking. - - This service is responsible for: - - Linking OAuth accounts to existing users - - Creating new users from OAuth account information - - Handling the user lookup during OAuth authentication - """ - - async def get_or_create_user(self, oauth_user_info: OAuthUserInfo, db: AsyncSession) -> tuple[dict[str, Any], bool]: - """ - Get existing user or create a new one from OAuth information. - - Args: - oauth_user_info: Standardized OAuth user info - db: SQLAlchemy async session - - Returns: - Tuple of (user_dict, created) where created is True if a new user was created - - Raises: - ValueError: If required user data is missing - """ - provider_field = f"{oauth_user_info.provider}_id" - provider_id_filter = {provider_field: oauth_user_info.provider_user_id} - - user = await crud_users.get(db=db, filter_by=provider_id_filter) - - if user: - logger.info(f"Found existing user by {provider_field}") - return user, False - - if oauth_user_info.email: - user = await crud_users.get(db=db, filter_by={"email": oauth_user_info.email}) - - if user: - logger.info(f"Found existing user by email {oauth_user_info.email}") - - update_data = {provider_field: oauth_user_info.provider_user_id, "oauth_updated_at": datetime.now(UTC)} - - user = await crud_users.update(db=db, object_id=user["id"], object=update_data) - return cast(dict[str, Any], user), False - - logger.info("Creating new user from OAuth information") - return await self._create_user_from_oauth(oauth_user_info, db) - - async def _create_user_from_oauth(self, oauth_user_info: OAuthUserInfo, db: AsyncSession) -> tuple[dict[str, Any], bool]: - """ - Create a new user from OAuth user information. - - Args: - oauth_user_info: Standardized OAuth user info - db: SQLAlchemy async session - - Returns: - Tuple of (user_dict, True) indicating a new user was created - - Raises: - ValueError: If required user data is missing for account creation - """ - if not oauth_user_info.email: - logger.warning("Cannot create user without email") - raise ValueError("Email is required for user creation") - - username = oauth_user_info.username - if not username: - username_base = oauth_user_info.given_name or oauth_user_info.name or oauth_user_info.email.split("@")[0] - - username_base = username_base.lower().replace(" ", "_") - username = username_base - - i = 1 - while await crud_users.exists(db=db, filter_by={"username": username}): - username = f"{username_base}{i}" - i += 1 - else: - if await crud_users.exists(db=db, filter_by={"username": username}): - username_base = username - i = 1 - while await crud_users.exists(db=db, filter_by={"username": username}): - username = f"{username_base}{i}" - i += 1 - - name = oauth_user_info.name or f"{oauth_user_info.given_name or ''} {oauth_user_info.family_name or ''}".strip() - if not name and oauth_user_info.email: - name = oauth_user_info.email.split("@")[0] - - random_password = secrets.token_urlsafe(16) - - user_data = UserCreateInternal( - username=username, - email=oauth_user_info.email, - name=name, - hashed_password=get_password_hash(random_password), - email_verified=oauth_user_info.email_verified, - google_id=oauth_user_info.provider_user_id if oauth_user_info.provider == OAuthProvider.GOOGLE.value else None, - github_id=oauth_user_info.provider_user_id if oauth_user_info.provider == OAuthProvider.GITHUB.value else None, - oauth_provider=oauth_user_info.provider, - oauth_created_at=datetime.now(UTC), - oauth_updated_at=datetime.now(UTC), - ) - - user = await crud_users.create(db=db, object=user_data, schema_to_select=UserRead) - - return user, True - - -oauth_account_service = OAuthAccountService() diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index 1714a180..ae477013 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -1,29 +1,19 @@ -import inspect -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, HTTPException, Query, Request, Response, status +from crudauth import Principal +from crudauth.exceptions import UnauthorizedException +from crudauth.oauth import OAuthState +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.responses import RedirectResponse from ...modules.user.crud import crud_users from ...modules.user.enums import OAuthProvider -from ..config.settings import get_settings -from ..dependencies import ( - AsyncSessionDep, - CurrentSessionDataDep, - GoogleOAuthProviderDep, - OAuth2FormDep, - OAuthStateStorageDep, - OptionalSessionDataDep, - SessionManagerDep, -) +from ..dependencies import AsyncSessionDep, OAuth2FormDep from ..logging import get_logger -from .http_exceptions import UnauthorizedException -from .oauth.dependencies import get_oauth_state -from .oauth.schemas import OAuthState, OAuthToken -from .oauth.services import oauth_account_service -from .session.dependencies import authenticate_user +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 -settings = get_settings() logger = get_logger() router = APIRouter(tags=["Authentication"]) @@ -46,7 +36,7 @@ """, responses={ 200: {"description": "Login successful, session created"}, - 401: {"description": "Authentication failed or rate limit exceeded"}, + 401: {"description": "Authentication failed"}, 429: {"description": "Too many login attempts, try again later"}, }, response_description="CSRF token for use in subsequent requests", @@ -56,55 +46,24 @@ async def login( response: Response, form_data: OAuth2FormDep, db: AsyncSessionDep, - session_manager: SessionManagerDep, ) -> dict[str, str]: """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. - This endpoint is protected by rate limiting to prevent brute force attacks. + 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). """ - ip_address = request.client.host if request.client and hasattr(request.client, "host") else "unknown" + user = await crud_auth.authenticate_password(db, form_data.username, form_data.password, request=request) - is_allowed, attempts_remaining = await session_manager.track_login_attempt( - ip_address=ip_address, username=form_data.username, success=False + session_id, csrf_token = await crud_auth.sessions.create_session( + request, + user_id=crud_auth.repo.user_id(user), + metadata={"login_type": "password", "username": crud_auth.repo.get(user, "username")}, ) + crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) - if not is_allowed: - logger.warning(f"Login rate limit exceeded for {form_data.username} from IP {ip_address}") - raise UnauthorizedException("Too many failed login attempts. Please try again later.") - - user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) - - if user is None: - logger.warning(f"Failed login attempt for {form_data.username} from IP {ip_address}") - raise UnauthorizedException("Incorrect username or password") - - try: - await session_manager.track_login_attempt(ip_address=ip_address, username=form_data.username, success=True) - - session_id, csrf_token = await session_manager.create_session( - request=request, - user_id=user["id"], - metadata={ - "login_type": "password", - "username": user["username"], - }, - ) - - session_manager.set_session_cookies( - response=response, - session_id=session_id, - csrf_token=csrf_token, - secure=settings.SESSION_SECURE_COOKIES, - path="/", - ) - - return {"csrf_token": csrf_token} - - except Exception as e: - logger.error(f"Error during login: {str(e)}", exc_info=True) - raise UnauthorizedException("An error occurred during login") + return {"csrf_token": csrf_token} @router.post( @@ -124,14 +83,14 @@ async def login( response_description="Confirmation of successful logout", ) async def logout( - request: Request, response: Response, - session_data: CurrentSessionDataDep, - session_manager: SessionManagerDep, + principal: Annotated[Principal, Depends(get_current_principal)], ) -> dict[str, str]: - """Logout endpoint to terminate the session and clear cookies.""" - await session_manager.terminate_session(session_data.session_id) - session_manager.clear_session_cookies(response) + """Logout endpoint to terminate the session and clear cookies (CSRF-protected).""" + session_id = principal.metadata.get("session_id") + if session_id: + await crud_auth.sessions.revoke(session_id, owner_id=principal.user_id) + crud_auth.sessions.clear_session_cookies(response) return {"message": "Logged out successfully"} @@ -155,24 +114,25 @@ async def logout( async def refresh_csrf_token( request: Request, response: Response, - session_data: CurrentSessionDataDep, - session_manager: SessionManagerDep, ) -> dict[str, str]: - """Generate a new CSRF token for the current session.""" - csrf_token = await session_manager.regenerate_csrf_token( - user_id=session_data.user_id, - session_id=session_data.session_id, - ) + """Generate a new CSRF token for the current session. - response.set_cookie( - key="csrf_token", - value=csrf_token, - max_age=int(session_manager.session_timeout.total_seconds()), - path="/", - httponly=False, - secure=settings.SESSION_SECURE_COOKIES, - samesite="lax", + Deliberately resolves the session cookie directly rather than via + ``current_user`` - requiring a valid CSRF header to refresh CSRF would defeat + the recovery purpose. The session cookie is httpOnly and the new token only + lands in the (same-origin-readable) cookie + body. + """ + sessions = crud_auth.sessions + session_id = request.cookies.get(sessions.session_cookie_name) + session = await sessions.validate_session(session_id) if session_id else None + if session is None or session_id is None: + 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 ) + sessions.set_csrf_cookie(response, csrf_token, max_age=ttl_seconds) return {"csrf_token": csrf_token} @@ -203,70 +163,24 @@ async def refresh_csrf_token( ) async def oauth_google_login( request: Request, - oauth_provider: GoogleOAuthProviderDep, - state_storage: OAuthStateStorageDep, redirect_uri: str | None = Query(None), ) -> dict[str, str]: - """ - Initiate OAuth login flow for Google. - - Args: - request: The request object - redirect_uri: Optional URI to redirect after successful authentication - - Returns: - Dict with authorization URL to redirect the user to Google - """ + """Initiate the Google OAuth flow: build the authorization URL and stash state + PKCE.""" try: - auth_data = await oauth_provider.get_authorization_url() - + auth_data = oauth_providers["google"].get_authorization_url() state_obj = OAuthState( state=auth_data["state"], provider=OAuthProvider.GOOGLE.value, redirect_to=redirect_uri, code_verifier=auth_data.get("code_verifier"), ) - - await state_storage.create(data=state_obj, session_id=auth_data["state"]) - + 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") -def _is_provider_valid(provider_value: Any, expected_provider: str) -> bool: - """Check if a provider value matches the expected provider name. - - This handles different types of values (strings, objects, mocks) safely. - - Args: - provider_value: The provider value to check (could be a string, object, or mock) - expected_provider: The expected provider name (e.g., "google" or "github") - - Returns: - bool: True if the provider is valid, False otherwise - """ - if provider_value is None: - return False - - if isinstance(provider_value, str): - return provider_value.lower() == expected_provider.lower() - - if hasattr(provider_value, "name") and isinstance(getattr(provider_value, "name", None), str): - name_value: str = getattr(provider_value, "name") - return name_value.lower() == expected_provider.lower() - - if inspect.iscoroutine(provider_value) or inspect.isawaitable(provider_value): - return expected_provider.lower() in str(provider_value).lower() - - try: - return expected_provider.lower() in str(provider_value).lower() - except Exception: - return False - - @router.get( "/oauth/callback/google", summary="Google OAuth Callback Handler", @@ -300,28 +214,13 @@ def _is_provider_valid(provider_value: Any, expected_provider: str) -> bool: async def oauth_google_callback( request: Request, response: Response, - oauth_provider: GoogleOAuthProviderDep, - state_storage: OAuthStateStorageDep, db: AsyncSessionDep, - session_manager: SessionManagerDep, code: str = Query(...), state: str = Query(...), response_format: str = Query("redirect", description="Response format, either 'redirect' or 'json'"), ): - """ - Handle OAuth callback from Google. - - Args: - request: The request object - response: The response object - code: Authorization code from Google - state: State parameter for CSRF protection - response_format: Format of the response, either 'redirect' (default) or 'json' - - Returns: - Redirect to frontend with success/error indication or JSON response with user info - """ - state_data = await get_oauth_state(state, state_storage) + """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}") @@ -332,17 +231,8 @@ async def oauth_google_callback( status_code=status.HTTP_302_FOUND, ) - provider_valid = False - try: - provider_valid = _is_provider_valid(state_data.provider, OAuthProvider.GOOGLE.value) - except Exception as e: - logger.warning(f"Error checking provider type: {e}") - provider_valid = False - - if not provider_valid: - expected = OAuthProvider.GOOGLE.value - actual = getattr(state_data, "provider", "unknown") - logger.warning(f"Provider mismatch in OAuth callback: expected {expected}, got {actual}") + 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( @@ -351,61 +241,43 @@ async def oauth_google_callback( ) try: - token_data = await oauth_provider.exchange_code(code, code_verifier=state_data.code_verifier) - - token = OAuthToken( - access_token=token_data["access_token"], - token_type=token_data.get("token_type", "Bearer"), - id_token=token_data.get("id_token"), - refresh_token=token_data.get("refresh_token"), - expires_in=token_data.get("expires_in"), - scope=token_data.get("scope"), - ) - - user_info_raw = await oauth_provider.get_user_info(token.access_token) - user_info = await oauth_provider.process_user_info(user_info_raw) + 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 session_manager.create_session( - request=request, - user_id=user["id"], + 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": user["username"], + "username": username, "is_new_user": is_new_user, }, ) + crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) - session_manager.set_session_cookies( - response=response, - session_id=session_id, - csrf_token=csrf_token, - secure=settings.SESSION_SECURE_COOKIES, - path="/", - ) - - await state_storage.delete(state) + await oauth_state_storage.delete(state) if response_format == "json": return { "success": True, - "user": {"id": user["id"], "username": user["username"], "email": user["email"], "is_new_user": is_new_user}, + "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 = "/" - try: - if state_data.redirect_to: - redirect_to = str(state_data.redirect_to) - except Exception as e: - logger.warning(f"Error getting redirect_to value: {e}, using default") - - return RedirectResponse( - url=redirect_to, - status_code=status.HTTP_302_FOUND, - ) + redirect_to = str(state_data.redirect_to) if state_data.redirect_to else "/" + return RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) except Exception as e: logger.error(f"Error in Google OAuth callback: {str(e)}", exc_info=True) @@ -423,30 +295,31 @@ async def oauth_google_callback( @router.get("/check-auth") async def check_auth( - session_data: OptionalSessionDataDep, + principal: Annotated[Principal | None, Depends(get_optional_principal)], db: AsyncSessionDep, ) -> dict[str, Any]: """ Check if the user is authenticated and return basic user information. - This is useful for clients to verify authentication status and can be used - with both cookie-based and API-based authentication. - - Args: - session_data: The session data if the user is authenticated + This is useful for clients to verify authentication status. It responds to both + authenticated and anonymous callers (anonymous gets ``authenticated: false`` + rather than a 401). Returns: - Authentication status and user information if authenticated + Authentication status and user information if authenticated. """ - if not session_data: + if principal is None: return {"authenticated": False, "message": "Not authenticated"} try: - user = await crud_users.get(db=db, id=session_data.user_id) + user = await crud_users.get(db=db, id=principal.user_id) if not user: return {"authenticated": False, "message": "User not found"} + session_id = principal.metadata.get("session_id") + session = await crud_auth.sessions.validate_session(session_id) if session_id else None + return { "authenticated": True, "user": { @@ -456,8 +329,8 @@ async def check_auth( "oauth_provider": user.get("oauth_provider"), }, "session": { - "created_at": session_data.created_at.isoformat() if session_data.created_at else None, - "last_activity": session_data.last_activity.isoformat() if session_data.last_activity else None, + "created_at": session.created_at.isoformat() if session and session.created_at else None, + "last_activity": session.last_activity.isoformat() if session and session.last_activity else None, }, } except Exception as e: diff --git a/backend/src/infrastructure/auth/session/__init__.py b/backend/src/infrastructure/auth/session/__init__.py deleted file mode 100644 index 57676ff7..00000000 --- a/backend/src/infrastructure/auth/session/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .dependencies import ( - authenticate_user, - get_current_session_data, - get_current_superuser, - get_current_user, - get_optional_user, -) -from .manager import SessionManager -from .schemas import CSRFToken, SessionData - -__all__ = [ - "get_current_user", - "get_optional_user", - "get_current_superuser", - "authenticate_user", - "get_current_session_data", - "SessionData", - "CSRFToken", - "SessionManager", -] diff --git a/backend/src/infrastructure/auth/session/backends/__init__.py b/backend/src/infrastructure/auth/session/backends/__init__.py deleted file mode 100644 index 592d6db3..00000000 --- a/backend/src/infrastructure/auth/session/backends/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .memory import MemorySessionStorage -from .redis import RedisSessionStorage - -__all__ = [ - "RedisSessionStorage", - "MemorySessionStorage", -] diff --git a/backend/src/infrastructure/auth/session/backends/memcached.py b/backend/src/infrastructure/auth/session/backends/memcached.py deleted file mode 100644 index 5abc78f4..00000000 --- a/backend/src/infrastructure/auth/session/backends/memcached.py +++ /dev/null @@ -1,350 +0,0 @@ -import hashlib -import json -from typing import TypeVar - -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 ....config.settings import get_settings -from ....logging import get_logger -from ..base import AbstractSessionStorage - -T = TypeVar("T", bound=BaseModel) -settings = get_settings() -logger = get_logger() - - -class MemcachedSessionStorage(AbstractSessionStorage[T]): - """Memcached implementation of session storage.""" - - def __init__( - self, - prefix: str = "session:", - expiration: int = 1800, - host: str = settings.CACHE_MEMCACHED_HOST, - port: int = settings.CACHE_MEMCACHED_PORT, - pool_size: int = settings.CACHE_MEMCACHED_POOL_SIZE, - ): - """Initialize the Memcached session storage. - - Args: - prefix: Prefix for all session keys - expiration: Default session expiration in seconds - host: Memcached host - port: Memcached port - pool_size: Memcached connection pool size - """ - super().__init__(prefix=prefix, expiration=expiration) - - self.client = aiomcache.Client( - host=host, - port=port, - pool_size=pool_size, - ) - - self.user_sessions_prefix = f"{prefix}user:" - - def _encode_key(self, key: str) -> bytes: - """Encode a key for Memcached. - - Memcached has a 250 byte key limit, so we hash long keys. - - Args: - key: The key to encode - - Returns: - The encoded key as bytes - """ - if len(key) > 240: - key_hash = hashlib.sha256(key.encode()).hexdigest()[:32] - key = f"{key[:200]}:{key_hash}" - return key.encode("utf-8") - - def get_user_sessions_key(self, user_id: int) -> str: - """Get the key for a user's sessions. - - Args: - user_id: The user ID - - Returns: - The Memcached key for the user's sessions - """ - return f"{self.user_sessions_prefix}{user_id}" - - async def create(self, data: T, session_id: str | None = None, expiration: int | None = None) -> str: - """Create a new session in Memcached. - - Args: - data: Session data (must be a Pydantic model) - session_id: Optional session ID. If not provided, one will be generated - expiration: Optional custom expiration in seconds - - Returns: - The session ID - """ - if session_id is None: - session_id = self.generate_session_id() - - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - json_data = data.model_dump_json().encode("utf-8") - - try: - await self.client.set(self._encode_key(key), json_data, exptime=exp) - - if hasattr(data, "user_id"): - user_id = getattr(data, "user_id") - user_sessions_key = self.get_user_sessions_key(user_id) - - user_sessions_data = await self.client.get(self._encode_key(user_sessions_key)) - - if user_sessions_data: - try: - user_sessions = json.loads(user_sessions_data.decode("utf-8")) - if session_id not in user_sessions: - user_sessions.append(session_id) - except (json.JSONDecodeError, UnicodeDecodeError): - user_sessions = [session_id] - else: - user_sessions = [session_id] - - user_sessions_json = json.dumps(user_sessions).encode("utf-8") - await self.client.set( - self._encode_key(user_sessions_key), - user_sessions_json, - exptime=exp + 3600, - ) - - logger.debug(f"Created session {session_id} with expiration {exp}s") - return session_id - except Exception as e: - logger.error(f"Error creating session: {e}") - raise - - async def get(self, session_id: str, model_class: type[T]) -> T | None: - """Get session data from Memcached. - - Args: - session_id: The session ID - model_class: The Pydantic model class to decode the data into - - Returns: - The session data or None if session doesn't exist - """ - key = self.get_key(session_id) - - try: - data = await self.client.get(self._encode_key(key)) - if data is None: - return None - - try: - json_data = json.loads(data.decode("utf-8")) - return model_class.model_validate(json_data) - except (json.JSONDecodeError, UnicodeDecodeError) as e: - logger.error(f"Error parsing session data: {e}") - return None - - except Exception as e: - logger.error(f"Error getting session: {e}") - raise - - async def update(self, session_id: str, data: T, reset_expiration: bool = True, expiration: int | None = None) -> bool: - """Update session data in Memcached. - - Args: - session_id: The session ID - data: New session data - reset_expiration: Whether to reset the expiration - expiration: Optional custom expiration in seconds - - Returns: - True if the session was updated, False if it didn't exist - """ - key = self.get_key(session_id) - - try: - if not await self.client.get(self._encode_key(key)): - return False - - json_data = data.model_dump_json().encode("utf-8") - exp = expiration if expiration is not None else self.expiration - - await self.client.set(self._encode_key(key), json_data, exptime=exp) - - if reset_expiration and hasattr(data, "user_id"): - user_id = getattr(data, "user_id") - user_sessions_key = self.get_user_sessions_key(user_id) - - user_sessions_data = await self.client.get(self._encode_key(user_sessions_key)) - - if user_sessions_data: - try: - user_sessions = json.loads(user_sessions_data.decode("utf-8")) - user_sessions_json = json.dumps(user_sessions).encode("utf-8") - await self.client.set( - self._encode_key(user_sessions_key), - user_sessions_json, - exptime=exp + 3600, - ) - except (json.JSONDecodeError, UnicodeDecodeError): - pass - - return True - - except Exception as e: - logger.error(f"Error updating session: {e}") - raise - - async def delete(self, session_id: str) -> bool: - """Delete a session from Memcached. - - Args: - session_id: The session ID - - Returns: - True if the session was deleted, False if it didn't exist - """ - key = self.get_key(session_id) - - try: - session_data = await self.client.get(self._encode_key(key)) - if session_data is None: - return False - - await self.client.delete(self._encode_key(key)) - - try: - json_data = json.loads(session_data.decode("utf-8")) - if "user_id" in json_data: - user_id = json_data["user_id"] - user_sessions_key = self.get_user_sessions_key(user_id) - - user_sessions_data = await self.client.get(self._encode_key(user_sessions_key)) - - if user_sessions_data: - try: - user_sessions = json.loads(user_sessions_data.decode("utf-8")) - if session_id in user_sessions: - user_sessions.remove(session_id) - user_sessions_json = json.dumps(user_sessions).encode("utf-8") - await self.client.set( - self._encode_key(user_sessions_key), - user_sessions_json, - exptime=3600 * 24, - ) - except (json.JSONDecodeError, UnicodeDecodeError): - pass - except (json.JSONDecodeError, UnicodeDecodeError): - pass - - return True - except Exception as e: - logger.error(f"Error deleting session: {e}") - raise - - async def extend(self, session_id: str, expiration: int | None = None) -> bool: - """Extend the expiration of a session in Memcached. - - Args: - session_id: The session ID - expiration: Optional custom expiration in seconds - - Returns: - True if the session was extended, False if it didn't exist - - Note: - Memcached doesn't allow extending expiration without updating the value. - We need to get, then set the value again with a new expiration. - """ - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - try: - session_data = await self.client.get(self._encode_key(key)) - if session_data is None: - return False - - await self.client.set(self._encode_key(key), session_data, exptime=exp) - - try: - json_data = json.loads(session_data.decode("utf-8")) - if "user_id" in json_data: - user_id = json_data["user_id"] - user_sessions_key = self.get_user_sessions_key(user_id) - - user_sessions_data = await self.client.get(self._encode_key(user_sessions_key)) - - if user_sessions_data: - await self.client.set( - self._encode_key(user_sessions_key), - user_sessions_data, - exptime=exp + 3600, - ) - except (json.JSONDecodeError, UnicodeDecodeError): - pass - - return True - except Exception as e: - logger.error(f"Error extending session: {e}") - raise - - async def exists(self, session_id: str) -> bool: - """Check if a session exists in Memcached. - - Args: - session_id: The session ID - - Returns: - True if the session exists, False otherwise - """ - key = self.get_key(session_id) - - try: - data = await self.client.get(self._encode_key(key)) - return data is not None - except Exception as e: - logger.error(f"Error checking session existence: {e}") - raise - - async def get_user_sessions(self, user_id: int) -> list[str]: - """Get all session IDs for a user. - - Args: - user_id: The user ID - - Returns: - List of session IDs for the user - """ - user_sessions_key = self.get_user_sessions_key(user_id) - - try: - data = await self.client.get(self._encode_key(user_sessions_key)) - if data is None: - return [] - - try: - user_sessions = json.loads(data.decode("utf-8")) - if isinstance(user_sessions, list): - return [str(session_id) for session_id in user_sessions] - else: - logger.error(f"User sessions data is not a list: {user_sessions}") - return [] - except (json.JSONDecodeError, UnicodeDecodeError) as e: - logger.error(f"Error parsing user sessions data: {e}") - return [] - except Exception as e: - logger.error(f"Error getting user sessions: {e}") - raise - - async def close(self) -> None: - """Close the Memcached connection.""" - await self.client.close() diff --git a/backend/src/infrastructure/auth/session/backends/memory.py b/backend/src/infrastructure/auth/session/backends/memory.py deleted file mode 100644 index 333d6adb..00000000 --- a/backend/src/infrastructure/auth/session/backends/memory.py +++ /dev/null @@ -1,229 +0,0 @@ -import json -import re -from datetime import UTC, datetime, timedelta -from re import Pattern -from typing import TypeVar - -from pydantic import BaseModel - -from ....logging import get_logger -from ..base import AbstractSessionStorage - -T = TypeVar("T", bound=BaseModel) -logger = get_logger() - - -class MemorySessionStorage(AbstractSessionStorage[T]): - """In-memory implementation of session storage for testing.""" - - def __init__( - self, - prefix: str = "session:", - expiration: int = 1800, - ): - """Initialize the in-memory session storage. - - Args: - prefix: Prefix for all session keys - expiration: Default session expiration in seconds - """ - super().__init__(prefix=prefix, expiration=expiration) - self.data: dict[str, bytes] = {} - self.expiry: dict[str, datetime] = {} - - async def create(self, data: T, session_id: str | None = None, expiration: int | None = None) -> str: - """Create a new session in memory. - - Args: - data: Session data (must be a Pydantic model) - session_id: Optional session ID. If not provided, one will be generated - expiration: Optional custom expiration in seconds - - Returns: - The session ID - """ - if session_id is None: - session_id = self.generate_session_id() - - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - json_data = data.model_dump_json() - - value_bytes = json_data.encode("utf-8") if isinstance(json_data, str) else json_data - - self.data[key] = value_bytes - self.expiry[key] = datetime.now(UTC) + timedelta(seconds=exp) - - logger.debug(f"Created session {session_id} with expiration {exp}s") - return session_id - - async def get(self, session_id: str, model_class: type[T]) -> T | None: - """Get session data from memory. - - Args: - session_id: The session ID - model_class: The Pydantic model class to decode the data into - - Returns: - The session data or None if session doesn't exist - """ - key = self.get_key(session_id) - - if self._check_expiry(key): - return None - - data_bytes = self.data.get(key) - if data_bytes is None: - return None - - try: - data_str = data_bytes.decode("utf-8") if isinstance(data_bytes, bytes) else data_bytes - json_data = json.loads(data_str) - return model_class.model_validate(json_data) - except (json.JSONDecodeError, ValueError) as e: - logger.error(f"Error parsing session data: {e}") - return None - - async def update(self, session_id: str, data: T, reset_expiration: bool = True, expiration: int | None = None) -> bool: - """Update session data in memory. - - Args: - session_id: The session ID - data: New session data - reset_expiration: Whether to reset the expiration - expiration: Optional custom expiration in seconds - - Returns: - True if the session was updated, False if it didn't exist - """ - key = self.get_key(session_id) - - if key not in self.data or self._check_expiry(key): - return False - - json_data = data.model_dump_json() - value_bytes = json_data.encode("utf-8") if isinstance(json_data, str) else json_data - - self.data[key] = value_bytes - - if reset_expiration: - exp = expiration if expiration is not None else self.expiration - self.expiry[key] = datetime.now(UTC) + timedelta(seconds=exp) - - return True - - async def delete(self, session_id: str) -> bool: - """Delete a session from memory. - - Args: - session_id: The session ID - - Returns: - True if the session was deleted, False if it didn't exist - """ - key = self.get_key(session_id) - - if key in self.data: - del self.data[key] - if key in self.expiry: - del self.expiry[key] - return True - return False - - async def extend(self, session_id: str, expiration: int | None = None) -> bool: - """Extend the expiration of a session in memory. - - Args: - session_id: The session ID - expiration: Optional custom expiration in seconds - - Returns: - True if the session was extended, False if it didn't exist - """ - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - if key in self.data and not self._check_expiry(key): - self.expiry[key] = datetime.now(UTC) + timedelta(seconds=exp) - return True - return False - - async def exists(self, session_id: str) -> bool: - """Check if a session exists in memory. - - Args: - session_id: The session ID - - Returns: - True if the session exists, False otherwise - """ - key = self.get_key(session_id) - return key in self.data and not self._check_expiry(key) - - async def _scan_iter(self, match: str | None = None) -> list[str]: - """Scan for keys matching a pattern. - - Args: - match: Pattern to match - - Returns: - List of matching keys - """ - if match: - pattern = match.replace("*", ".*").replace("?", ".") - pattern = f"^{pattern}$" - regex: Pattern = re.compile(pattern) - - matching_keys = [] - for key in list(self.data.keys()): - if self._check_expiry(key): - continue - - if regex.match(key): - matching_keys.append(key) - return matching_keys - else: - return [key for key in list(self.data.keys()) if not self._check_expiry(key)] - - def _check_expiry(self, key: str) -> bool: - """Check if a key has expired and remove it if so. - - Args: - key: The key to check - - Returns: - True if expired (and removed), False otherwise - """ - if key in self.expiry and datetime.now(UTC) > self.expiry[key]: - del self.data[key] - del self.expiry[key] - return True - return False - - async def close(self) -> None: - """Clear all data.""" - self.data.clear() - self.expiry.clear() - - async def delete_pattern(self, pattern: str) -> int: - """Delete all keys matching a pattern. - - Args: - pattern: The pattern to match keys (e.g., "login:*") - - Returns: - Number of keys deleted - """ - matching_keys = await self._scan_iter(match=pattern) - - deleted_count = 0 - for key in matching_keys: - if key in self.data: - del self.data[key] - if key in self.expiry: - del self.expiry[key] - deleted_count += 1 - - logger.debug(f"Deleted {deleted_count} keys matching pattern '{pattern}'") - return deleted_count diff --git a/backend/src/infrastructure/auth/session/backends/redis.py b/backend/src/infrastructure/auth/session/backends/redis.py deleted file mode 100644 index 6801e7cd..00000000 --- a/backend/src/infrastructure/auth/session/backends/redis.py +++ /dev/null @@ -1,364 +0,0 @@ -import json -from collections.abc import Awaitable -from typing import Any, TypeVar, cast - -try: - from redis.asyncio import Redis as AsyncRedis - 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 ....config.settings import get_settings -from ....logging import get_logger -from ..base import AbstractSessionStorage - -T = TypeVar("T", bound=BaseModel) -settings = get_settings() -logger = get_logger() - - -class RedisSessionStorage(AbstractSessionStorage[T]): - """Redis implementation of session storage.""" - - client: AsyncRedis - - def __init__( - self, - prefix: str = "session:", - expiration: int = 1800, - host: str = settings.CACHE_REDIS_HOST, - port: int = settings.CACHE_REDIS_PORT, - db: int = settings.CACHE_REDIS_DB, - password: str | None = settings.CACHE_REDIS_PASSWORD, - pool_size: int = settings.CACHE_REDIS_POOL_SIZE, - connect_timeout: int = settings.CACHE_REDIS_CONNECT_TIMEOUT, - ): - """Initialize the Redis session storage. - - Args: - prefix: Prefix for all session keys - expiration: Default session expiration in seconds - host: Redis host - port: Redis port - db: Redis database number - password: Redis password - pool_size: Redis connection pool size - connect_timeout: Redis connection timeout - """ - super().__init__(prefix=prefix, expiration=expiration) - - self.client = AsyncRedis( - host=host, - port=port, - db=db, - password=password, - socket_timeout=connect_timeout, - socket_connect_timeout=connect_timeout, - socket_keepalive=True, - decode_responses=False, - max_connections=pool_size, - ) - - self.user_sessions_prefix = f"{prefix}user:" - - def get_user_sessions_key(self, user_id: int) -> str: - """Get the key for a user's sessions set. - - Args: - user_id: The user ID - - Returns: - The Redis key for the user's sessions set - """ - return f"{self.user_sessions_prefix}{user_id}" - - async def create(self, data: T, session_id: str | None = None, expiration: int | None = None) -> str: - """Create a new session in Redis. - - Args: - data: Session data (must be a Pydantic model) - session_id: Optional session ID. If not provided, one will be generated - expiration: Optional custom expiration in seconds - - Returns: - The session ID - - Raises: - RedisError: If there is an error with Redis - """ - if session_id is None: - session_id = self.generate_session_id() - - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - json_data = data.model_dump_json() - - try: - pipeline = self.client.pipeline() - pipeline.set(key, json_data, ex=exp) - - if hasattr(data, "user_id"): - user_id = getattr(data, "user_id") - user_sessions_key = self.get_user_sessions_key(user_id) - - pipeline.sadd(user_sessions_key, session_id) - - pipeline.expire(user_sessions_key, exp + 3600) - - await pipeline.execute() - logger.debug(f"Created session {session_id} with expiration {exp}s") - return session_id - except RedisError as e: - logger.error(f"Error creating session: {e}") - raise - - async def get(self, session_id: str, model_class: type[T]) -> T | None: - """Get session data from Redis. - - Args: - session_id: The session ID - model_class: The Pydantic model class to decode the data into - - Returns: - The session data or None if session doesn't exist - - Raises: - RedisError: If there is an error with Redis - ValueError: If the data cannot be parsed - """ - key = self.get_key(session_id) - - try: - data = await self.client.get(key) - if data is None: - return None - - try: - json_data = json.loads(data) - return model_class.model_validate(json_data) - except (json.JSONDecodeError, ValueError) as e: - logger.error(f"Error parsing session data: {e}") - return None - - except RedisError as e: - logger.error(f"Error getting session: {e}") - raise - - async def update(self, session_id: str, data: T, reset_expiration: bool = True, expiration: int | None = None) -> bool: - """Update session data in Redis. - - Args: - session_id: The session ID - data: New session data - reset_expiration: Whether to reset the expiration - expiration: Optional custom expiration in seconds - - Returns: - True if the session was updated, False if it didn't exist - - Raises: - RedisError: If there is an error with Redis - """ - key = self.get_key(session_id) - - try: - if not await self.client.exists(key): - return False - - json_data = data.model_dump_json() - pipeline = self.client.pipeline() - - if reset_expiration: - exp = expiration if expiration is not None else self.expiration - pipeline.set(key, json_data, ex=exp) - - if hasattr(data, "user_id"): - user_id = getattr(data, "user_id") - user_sessions_key = self.get_user_sessions_key(user_id) - pipeline.expire(user_sessions_key, exp + 3600) - else: - ttl = await self.client.ttl(key) - if ttl > 0: - pipeline.set(key, json_data, ex=ttl) - else: - exp = expiration if expiration is not None else self.expiration - pipeline.set(key, json_data, ex=exp) - - if hasattr(data, "user_id"): - user_id = getattr(data, "user_id") - user_sessions_key = self.get_user_sessions_key(user_id) - pipeline.expire(user_sessions_key, exp + 3600) - - await pipeline.execute() - return True - - except RedisError as e: - logger.error(f"Error updating session: {e}") - raise - - async def delete(self, session_id: str) -> bool: - """Delete a session from Redis. - - Args: - session_id: The session ID - - Returns: - True if the session was deleted, False if it didn't exist - - Raises: - RedisError: If there is an error with Redis - """ - key = self.get_key(session_id) - - try: - data = await self.client.get(key) - if data is None: - return False - - pipeline = self.client.pipeline() - - pipeline.delete(key) - - try: - json_data = json.loads(data) - if "user_id" in json_data: - user_id = json_data["user_id"] - user_sessions_key = self.get_user_sessions_key(user_id) - pipeline.srem(user_sessions_key, session_id) - except (json.JSONDecodeError, ValueError): - pass - - result = await pipeline.execute() - return bool(result[0] > 0) - except RedisError as e: - logger.error(f"Error deleting session: {e}") - raise - - async def extend(self, session_id: str, expiration: int | None = None) -> bool: - """Extend the expiration of a session in Redis. - - Args: - session_id: The session ID - expiration: Optional custom expiration in seconds - - Returns: - True if the session was extended, False if it didn't exist - - Raises: - RedisError: If there is an error with Redis - """ - key = self.get_key(session_id) - exp = expiration if expiration is not None else self.expiration - - try: - data = await self.client.get(key) - if data is None: - return False - - pipeline = self.client.pipeline() - - pipeline.expire(key, exp) - - try: - json_data = json.loads(data) - if "user_id" in json_data: - user_id = json_data["user_id"] - user_sessions_key = self.get_user_sessions_key(user_id) - pipeline.expire(user_sessions_key, exp + 3600) - except (json.JSONDecodeError, ValueError): - pass - - results = await pipeline.execute() - return bool(results[0]) - - except RedisError as e: - logger.error(f"Error extending session: {e}") - raise - - async def exists(self, session_id: str) -> bool: - """Check if a session exists in Redis. - - Args: - session_id: The session ID - - Returns: - True if the session exists, False otherwise - - Raises: - RedisError: If there is an error with Redis - """ - key = self.get_key(session_id) - - try: - exists_result = await self.client.exists(key) - return bool(exists_result) - except RedisError as e: - logger.error(f"Error checking session existence: {e}") - raise - - async def get_user_sessions(self, user_id: int) -> list[str]: - """Get all session IDs for a user. - - Args: - user_id: The user ID - - Returns: - List of session IDs for the user - - Raises: - RedisError: If there is an error with Redis - """ - user_sessions_key = self.get_user_sessions_key(user_id) - - try: - members = await cast(Awaitable[set[Any]], self.client.smembers(user_sessions_key)) - return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members] - except RedisError as e: - logger.error(f"Error getting user sessions: {e}") - raise - - async def close(self) -> None: - """Close the Redis connection.""" - await self.client.close() - - async def delete_pattern(self, pattern: str) -> int: - """Delete all Redis keys matching a pattern. - - This method is useful for bulk cleanup operations like clearing - expired rate limiting keys or other grouped data. - - Args: - pattern: The pattern to match keys (e.g., "login:*") - - Returns: - Number of keys deleted - - Raises: - RedisError: If there is an error with Redis - """ - try: - matched_keys = [] - async for key in self.client.scan_iter(match=pattern): - matched_keys.append(key) - - if not matched_keys: - return 0 - - pipeline = self.client.pipeline() - for key in matched_keys: - pipeline.delete(key) - - results = await pipeline.execute() - deleted_count = sum(1 for result in results if result > 0) - - logger.debug(f"Deleted {deleted_count} keys matching pattern '{pattern}'") - return deleted_count - - except RedisError as e: - logger.error(f"Error deleting keys with pattern '{pattern}': {e}") - raise diff --git a/backend/src/infrastructure/auth/session/base.py b/backend/src/infrastructure/auth/session/base.py deleted file mode 100644 index ff4654a0..00000000 --- a/backend/src/infrastructure/auth/session/base.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Abstract base for session storage backends. - -Lives in its own module so that the concrete backend implementations -under ``backends/`` can subclass it without participating in the -``storage.py`` factory's import cycle. The factory (``storage.py``) -imports both this base and the concrete backends; concrete backends -only import this base. -""" - -from abc import ABC, abstractmethod -from typing import Generic, TypeVar -from uuid import uuid4 - -from pydantic import BaseModel - -T = TypeVar("T", bound=BaseModel) - - -class AbstractSessionStorage(Generic[T], ABC): - """Abstract base class for session storage implementations.""" - - def __init__( - self, - prefix: str = "session:", - expiration: int = 1800, - ): - """Initialize the session storage. - - Args: - prefix: Prefix for all session keys - expiration: Default session expiration in seconds - """ - self.prefix = prefix - self.expiration = expiration - - def generate_session_id(self) -> str: - """Generate a unique session ID. - - Returns: - A unique session ID string - """ - return str(uuid4()) - - def get_key(self, session_id: str) -> str: - """Generate the full key for a session ID. - - Args: - session_id: The session ID - - Returns: - The full storage key - """ - return f"{self.prefix}{session_id}" - - @abstractmethod - async def create(self, data: T, session_id: str | None = None, expiration: int | None = None) -> str: - """Create a new session. - - Args: - data: Session data (must be a Pydantic model) - session_id: Optional session ID. If not provided, one will be generated - expiration: Optional custom expiration in seconds - - Returns: - The session ID - """ - pass - - @abstractmethod - async def get(self, session_id: str, model_class: type[T]) -> T | None: - """Get session data. - - Args: - session_id: The session ID - model_class: The Pydantic model class to decode the data into - - Returns: - The session data or None if session doesn't exist - """ - pass - - @abstractmethod - async def update(self, session_id: str, data: T, reset_expiration: bool = True, expiration: int | None = None) -> bool: - """Update session data. - - Args: - session_id: The session ID - data: New session data - reset_expiration: Whether to reset the expiration - expiration: Optional custom expiration in seconds - - Returns: - True if the session was updated, False if it didn't exist - """ - pass - - @abstractmethod - async def delete(self, session_id: str) -> bool: - """Delete a session. - - Args: - session_id: The session ID - - Returns: - True if the session was deleted, False if it didn't exist - """ - pass - - @abstractmethod - async def extend(self, session_id: str, expiration: int | None = None) -> bool: - """Extend the expiration of a session. - - Args: - session_id: The session ID - expiration: Optional custom expiration in seconds - - Returns: - True if the session was extended, False if it didn't exist - """ - pass - - @abstractmethod - async def exists(self, session_id: str) -> bool: - """Check if a session exists. - - Args: - session_id: The session ID - - Returns: - True if the session exists, False otherwise - """ - pass - - @abstractmethod - async def close(self) -> None: - """Close the storage connection.""" - pass diff --git a/backend/src/infrastructure/auth/session/dependencies.py b/backend/src/infrastructure/auth/session/dependencies.py deleted file mode 100644 index 6b06994e..00000000 --- a/backend/src/infrastructure/auth/session/dependencies.py +++ /dev/null @@ -1,268 +0,0 @@ -from typing import Annotated, Any - -from fastapi import Cookie, Depends, Header, Request -from sqlalchemy.ext.asyncio import AsyncSession - -from ....infrastructure.auth.http_exceptions import ( - CSRFException, - ForbiddenException, - UnauthorizedException, -) -from ....infrastructure.database.session import async_session -from ....modules.user.crud import crud_users -from ...config.settings import get_settings -from ...logging import get_logger -from ...rate_limit.provider import get_rate_limiter_backend -from ..utils import verify_password -from .manager import SessionManager -from .schemas import SessionData -from .storage import AbstractSessionStorage, get_session_storage - -settings = get_settings() -logger = get_logger() - -_session_manager: SessionManager | None = None - - -def get_session_manager() -> SessionManager: - """Get the session manager singleton (initialized once, reused across requests).""" - global _session_manager # noqa: PLW0603 - if _session_manager is not None: - return _session_manager - - storage: AbstractSessionStorage[SessionData] = get_session_storage( - backend=settings.SESSION_BACKEND, - model_type=SessionData, - prefix="session:", - expiration=settings.SESSION_TIMEOUT_MINUTES * 60, - host=settings.CACHE_REDIS_HOST, - port=settings.CACHE_REDIS_PORT, - db=settings.CACHE_REDIS_DB, - password=settings.CACHE_REDIS_PASSWORD, - ) - - rate_limiter = None - if settings.RATE_LIMITER_ENABLED: - try: - rate_limiter = get_rate_limiter_backend(settings.RATE_LIMITER_BACKEND) - logger.info(f"Rate limiter initialized for login attempts using {settings.RATE_LIMITER_BACKEND} backend") - except Exception as e: - logger.warning(f"Failed to initialize rate limiter for login attempts: {e}") - logger.warning("Login rate limiting will be disabled") - - _session_manager = SessionManager( - session_storage=storage, - 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=rate_limiter, - login_max_attempts=settings.LOGIN_MAX_ATTEMPTS, - login_window_minutes=settings.LOGIN_WINDOW_MINUTES, - ) - return _session_manager - - -async def get_session_from_cookie( - request: Request, - session_id: str | None = Cookie(None), - session_manager: SessionManager = Depends(get_session_manager), -) -> SessionData | None: - """Get session data from cookie, validating it. - - Args: - request: The request object - session_id: The session ID from cookie - session_manager: The session manager - - Returns: - The session data or None if invalid - """ - if not session_id: - return None - - await session_manager.cleanup_expired_sessions() - - return await session_manager.validate_session(session_id) - - -async def verify_csrf_token( - request: Request, - session_data: Annotated[SessionData | None, Depends(get_session_from_cookie)], - csrf_token: str | None = Cookie(None), - x_csrf_token: str | None = Header(None, alias="X-CSRF-Token"), - session_manager: SessionManager = Depends(get_session_manager), -) -> None: - """Verify CSRF token for mutation operations. - - This should be used for POST/PUT/DELETE operations. - - Args: - request: The request object - session_data: The session data - csrf_token: The CSRF token from cookie - x_csrf_token: The CSRF token from header - session_manager: The session manager - - Raises: - CSRFException: If CSRF validation fails - """ - if request.method in ("GET", "HEAD", "OPTIONS"): - return None - - if not settings.CSRF_ENABLED: - logger.debug("CSRF validation disabled by configuration") - return None - - if not session_data: - return None - - token = x_csrf_token or csrf_token - if not token: - raise CSRFException("Missing CSRF token") - - is_valid = await session_manager.validate_csrf_token(session_data.session_id, token) - - if not is_valid: - raise CSRFException("Invalid CSRF token") - - -async def get_current_user( - session_data: Annotated[SessionData | None, Depends(get_session_from_cookie)], - db: Annotated[AsyncSession, Depends(async_session)], - _: Annotated[None, Depends(verify_csrf_token)], -) -> dict[str, Any]: - """Get the current authenticated user. - - Args: - session_data: The session data - db: The database session - - Returns: - The user data - - Raises: - UnauthorizedException: If not authenticated or user doesn't exist - """ - credentials_exception = UnauthorizedException("Not authenticated") - - if not session_data: - raise credentials_exception - - if not session_data.is_active: - raise credentials_exception - - user = await crud_users.get(db=db, id=session_data.user_id, is_deleted=False) - - if user is None: - raise credentials_exception - - return user - - -async def get_optional_user( - session_data: Annotated[SessionData | None, Depends(get_session_from_cookie)], - db: Annotated[AsyncSession, Depends(async_session)], -) -> dict[str, Any] | None: - """Get the current user if authenticated, None otherwise. - - Args: - session_data: The session data - db: The database session - - Returns: - The user data or None - """ - if not session_data: - return None - - if not session_data.is_active: - return None - - user = await crud_users.get(db=db, id=session_data.user_id, is_deleted=False) - - return user - - -async def get_current_superuser( - current_user: Annotated[dict[str, Any], Depends(get_current_user)], -) -> dict[str, Any]: - """Get the current user, requiring superuser privileges. - - Args: - current_user: The current user - - Returns: - The user data - - Raises: - ForbiddenException: If not a superuser - """ - if not current_user.get("is_superuser", False): - raise ForbiddenException("Insufficient privileges") - - return current_user - - -async def get_session_id_from_cookie(request: Request) -> str | None: - """Extract session ID from cookies. - - Args: - request: The request object - - Returns: - The session ID from cookies or None if not present - """ - return request.cookies.get("session_id") - - -async def get_current_session_data( - request: Request, - session_id: Annotated[str | None, Depends(get_session_id_from_cookie)], - session_manager: Annotated[SessionManager, Depends(get_session_manager)], -) -> SessionData: - """Get the current session data from cookie. - - Args: - request: The request object - session_id: The session ID from cookie - session_manager: The session manager - - Returns: - The session data - - Raises: - UnauthorizedException: If not authenticated or session is invalid - """ - if not session_id: - raise UnauthorizedException("Not authenticated") - - session_data = await session_manager.validate_session(session_id) - if not session_data: - raise UnauthorizedException("Invalid or expired session") - - return session_data - - -async def authenticate_user(username_or_email: str, password: str, db: AsyncSession) -> dict[str, Any] | None: - """Authenticate a user by username/email and password. - - Args: - username_or_email: The username or email - password: The plaintext password - db: The database session - - Returns: - The user data dict if authenticated, None otherwise - """ - if "@" in username_or_email: - user = await crud_users.get(db=db, email=username_or_email, is_deleted=False) - else: - user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) - - if not user: - return None - - if not await verify_password(password, user["hashed_password"]): - return None - - return user diff --git a/backend/src/infrastructure/auth/session/manager.py b/backend/src/infrastructure/auth/session/manager.py deleted file mode 100644 index 95cbaaa2..00000000 --- a/backend/src/infrastructure/auth/session/manager.py +++ /dev/null @@ -1,590 +0,0 @@ -import secrets -from datetime import UTC, datetime, timedelta -from typing import Any, Literal - -from fastapi import Request, Response - -from ...config.settings import get_settings -from ...logging import get_logger -from .schemas import CSRFToken, SessionCreate, SessionData, UserAgentInfo -from .storage import AbstractSessionStorage, get_session_storage -from .user_agents_types import parse - -settings = get_settings() -logger = get_logger() - -SamesiteType = Literal["lax", "strict", "none"] -DEV_SAMESITE: SamesiteType = "lax" -PROD_SAMESITE: SamesiteType = "strict" - - -class SessionManager: - """Session manager for handling secure authentication sessions. - - This class implements a comprehensive session-based authentication system with the following features: - - - Secure session creation and validation - - CSRF protection with token generation and validation - - Session expiration and automatic cleanup - - Device fingerprinting and user agent tracking - - Multi-device support with configurable session limits per user - - IP address tracking for security monitoring - - Session metadata for storing additional authentication context - - Rate limiting for login attempts with IP and username tracking - - Authentication Flow: - 1. When a user logs in successfully, create_session() generates a new session and CSRF token - 2. Session cookies are set via set_session_cookies() - a httpOnly session_id and a non-httpOnly csrf_token - 3. On subsequent requests, validate_session() confirms the session is valid and not expired - 4. For state-changing operations, validate_csrf_token() provides protection against CSRF attacks - 5. Sessions automatically expire after inactivity, or can be manually terminated - 6. Periodic cleanup_expired_sessions() removes stale sessions - - Security Features: - - Sessions are stored server-side with only the ID transmitted to clients - - CSRF protection through synchronized tokens - - Session hijacking protection via IP and user agent tracking - - Automatic session expiration after configurable timeout - - Forced logout of oldest sessions when session limit is reached - - Different SameSite cookie settings for development and production - - Rate limiting for login attempts to prevent brute force attacks - - Usage: - Sessions should be validated on each authenticated request, with CSRF tokens validated - for any state-changing operations. The cleanup method should be called periodically - to remove expired sessions. - """ - - def __init__( - self, - session_storage: AbstractSessionStorage[SessionData], - max_sessions_per_user: int = 5, - session_timeout_minutes: int = 30, - cleanup_interval_minutes: int = 15, - csrf_token_bytes: int = 32, - rate_limiter=None, - login_max_attempts: int = 5, - login_window_minutes: int = 15, - ): - """Initialize the session manager. - - Args: - session_storage: Storage backend for sessions - max_sessions_per_user: Maximum number of active sessions per user - session_timeout_minutes: Session timeout in minutes - cleanup_interval_minutes: Interval for cleaning up expired sessions - csrf_token_bytes: Number of bytes to use for CSRF tokens - rate_limiter: Optional rate limiter implementation for login attempts - login_max_attempts: Maximum failed login attempts before rate limiting - login_window_minutes: Time window for tracking failed login attempts - """ - self.storage = session_storage - self.max_sessions = max_sessions_per_user - self.session_timeout = timedelta(minutes=session_timeout_minutes) - self.cleanup_interval = timedelta(minutes=cleanup_interval_minutes) - self.last_cleanup = datetime.now(UTC) - self.csrf_token_bytes = csrf_token_bytes - self.rate_limiter = rate_limiter - self.login_max_attempts = login_max_attempts - self.login_window = timedelta(minutes=login_window_minutes) - - csrf_storage_settings = {"prefix": "csrf:", "expiration": session_timeout_minutes * 60} - self.csrf_storage: AbstractSessionStorage[CSRFToken] = get_session_storage( - backend=settings.SESSION_BACKEND, model_type=CSRFToken, **csrf_storage_settings - ) - - def parse_user_agent(self, user_agent_string: str) -> UserAgentInfo: - """Parse User-Agent string into structured information. - - Args: - user_agent_string: Raw User-Agent header - - Returns: - Structured UserAgentInfo - """ - ua_parser = parse(user_agent_string) - return UserAgentInfo( - browser=ua_parser.browser.family, - browser_version=ua_parser.browser.version_string, - os=ua_parser.os.family, - device=ua_parser.device.family, - is_mobile=ua_parser.is_mobile, - is_tablet=ua_parser.is_tablet, - is_pc=ua_parser.is_pc, - ) - - async def create_session(self, request: Request, user_id: int, metadata: dict[str, Any] | None = None) -> tuple[str, str]: - """Create a new session for a user and generate a CSRF token. - - Args: - request: The request object - user_id: The user ID - metadata: Optional session metadata - - Returns: - Tuple of (session_id, csrf_token) - - Raises: - ValueError: If the request client is invalid - """ - logger.info(f"Creating new session for user_id: {user_id}") - - try: - user_agent = request.headers.get("user-agent", "") - current_time = datetime.now(UTC) - - client = request.client - if client is None: - logger.error("Request client is None. Cannot retrieve IP address.") - raise ValueError("Invalid request client.") - - device_info = self.parse_user_agent(user_agent).model_dump() - - ip_address = request.headers.get("x-forwarded-for", client.host).split(",")[0].strip() - - await self._enforce_session_limit(user_id) - - session_data = SessionCreate( - user_id=user_id, - ip_address=ip_address, - user_agent=user_agent, - device_info=device_info, - last_activity=current_time, - is_active=True, - metadata=metadata or {}, - ) - - session_id = await self.storage.create(session_data, session_id=session_data.session_id) - csrf_token = await self._generate_csrf_token(user_id, session_id) - - logger.info(f"Session {session_id} created successfully") - return session_id, csrf_token - - except Exception as e: - logger.error(f"Error creating session: {str(e)}", exc_info=True) - raise - - async def validate_session(self, session_id: str, update_activity: bool = True) -> SessionData | None: - """Validate if a session is active and not timed out. - - Args: - session_id: The session ID - update_activity: Whether to update the last activity timestamp - - Returns: - The session data if valid, None otherwise - """ - if not session_id: - return None - - try: - session_data = await self.storage.get(session_id, SessionData) - if session_data is None: - logger.warning(f"Session not found: {session_id}") - return None - - if not session_data.is_active: - logger.warning(f"Session is not active: {session_id}") - return None - - current_time = datetime.now(UTC) - session_age = current_time - session_data.last_activity - - if session_age > self.session_timeout: - logger.warning(f"Session timed out: {session_id}") - await self.terminate_session(session_id) - return None - - if update_activity: - session_data.last_activity = current_time - await self.storage.update(session_id, session_data) - - return session_data - - except Exception as e: - logger.error(f"Error validating session: {str(e)}", exc_info=True) - return None - - async def validate_csrf_token( - self, - session_id: str, - csrf_token: str, - ) -> bool: - """Validate a CSRF token for a session. - - Args: - session_id: The session ID - csrf_token: The CSRF token to validate - - Returns: - True if valid, False otherwise - """ - if not session_id or not csrf_token: - logger.warning(f"Missing session_id or csrf_token: session_id={session_id}, csrf_token={csrf_token}") - return False - - try: - token_data = await self.csrf_storage.get(csrf_token, CSRFToken) - if token_data is None: - logger.warning(f"CSRF token not found in storage: {csrf_token}") - return False - - if token_data.session_id != session_id: - logger.warning( - f"CSRF token session mismatch: {csrf_token} should be for session {session_id}, " - f"but is for session {token_data.session_id}" - ) - return False - - current_time = datetime.now(UTC) - if token_data.expiry < current_time: - logger.warning( - f"CSRF token expired: {csrf_token}, expired at {token_data.expiry}, current time is {current_time}" - ) - await self.csrf_storage.delete(csrf_token) - return False - - return True - - except Exception as e: - logger.error(f"Error validating CSRF token: {str(e)}", exc_info=True) - return False - - async def regenerate_csrf_token( - self, - user_id: int, - session_id: str, - ) -> str: - """Regenerate a CSRF token for an existing session. - - Args: - user_id: The user ID - session_id: The session ID - - Returns: - The new CSRF token - """ - return await self._generate_csrf_token(user_id, session_id) - - async def _generate_csrf_token( - self, - user_id: int, - session_id: str, - ) -> str: - """Generate a new CSRF token for a session. - - Args: - user_id: The user ID - session_id: The session ID - - Returns: - The CSRF token - """ - token = secrets.token_hex(self.csrf_token_bytes) - expiry = datetime.now(UTC) + self.session_timeout - - csrf_data = CSRFToken( - token=token, - user_id=user_id, - session_id=session_id, - expiry=expiry, - ) - - await self.csrf_storage.create(csrf_data, session_id=token) - return token - - async def terminate_session(self, session_id: str) -> bool: - """Terminate a specific session. - - Args: - session_id: The session ID - - Returns: - True if the session was terminated, False otherwise - """ - try: - session_data = await self.storage.get(session_id, SessionData) - if session_data is None: - return False - - session_data.is_active = False - session_data.metadata = { - **session_data.metadata, - "terminated_at": datetime.now(UTC).isoformat(), - "termination_reason": "manual_termination", - } - - return await self.storage.update(session_id, session_data) - - except Exception as e: - logger.error(f"Error terminating session: {str(e)}", exc_info=True) - return False - - async def _enforce_session_limit(self, user_id: int) -> None: - """Enforce the maximum number of sessions per user. - - Terminates the oldest sessions if the limit is exceeded. - - Args: - user_id: The user ID - """ - try: - active_sessions = [] - - if hasattr(self.storage, "get_user_sessions"): - try: - session_ids = await self.storage.get_user_sessions(user_id) - for session_id in session_ids: - try: - session_data = await self.storage.get(session_id, SessionData) - if session_data and session_data.is_active: - active_sessions.append(session_data) - except Exception as e: - logger.warning(f"Error processing session {session_id}: {e}") - continue - except Exception as e: - logger.warning(f"Error getting user sessions: {e}") - active_sessions = await self._get_active_sessions_by_scan(user_id) - else: - active_sessions = await self._get_active_sessions_by_scan(user_id) - - if len(active_sessions) >= self.max_sessions: - active_sessions.sort(key=lambda s: s.last_activity) - - excess_count = len(active_sessions) - self.max_sessions + 1 - for i in range(excess_count): - if i < len(active_sessions): - await self.terminate_session(active_sessions[i].session_id) - - except Exception as e: - logger.error(f"Error enforcing session limit: {e}", exc_info=True) - - async def _get_active_sessions_by_scan(self, user_id: int) -> list[SessionData]: - """Get active sessions for a user by scanning all keys. - - This is a fallback method when indexed groups are not available. - - Args: - user_id: The user ID - - Returns: - List of active sessions for the user - """ - active_sessions = [] - - if hasattr(self.storage, "_scan_iter"): - keys = await self.storage._scan_iter(match=f"{self.storage.prefix}*") - for key in keys: - try: - session_data_bytes = await self.storage.get( - session_id=key[len(self.storage.prefix) :], model_class=SessionData - ) - if session_data_bytes and session_data_bytes.user_id == user_id and session_data_bytes.is_active: - active_sessions.append(session_data_bytes) - except Exception as e: - logger.warning(f"Error processing session during cleanup: {e}") - continue - elif hasattr(self.storage, "client") and hasattr(self.storage.client, "scan_iter"): - async for key in self.storage.client.scan_iter(match=f"{self.storage.prefix}*"): - try: - if isinstance(key, bytes): - key = key.decode("utf-8") - session_id = key[len(self.storage.prefix) :] - - session_data = await self.storage.get(session_id, SessionData) - if session_data and session_data.user_id == user_id and session_data.is_active: - active_sessions.append(session_data) - except Exception as e: - logger.warning(f"Error processing session during cleanup: {e}") - continue - - return active_sessions - - async def cleanup_expired_sessions(self) -> None: - """Cleanup expired and inactive sessions. - - This should be called periodically. - """ - now = datetime.now(UTC) - - if now - self.last_cleanup < self.cleanup_interval: - return - - timeout_threshold = now - self.session_timeout - - try: - if hasattr(self.storage, "_scan_iter"): - keys = await self.storage._scan_iter(match=f"{self.storage.prefix}*") - for key in keys: - try: - session_id = key[len(self.storage.prefix) :] - session_data = await self.storage.get(session_id, SessionData) - if session_data and session_data.is_active and session_data.last_activity < timeout_threshold: - session_data.is_active = False - session_data.metadata = { - **session_data.metadata, - "terminated_at": now.isoformat(), - "termination_reason": "session_timeout", - } - await self.storage.update(session_id, session_data) - except Exception as e: - logger.warning(f"Error processing session during cleanup: {e}") - continue - elif hasattr(self.storage, "client") and hasattr(self.storage.client, "scan_iter"): - async for key in self.storage.client.scan_iter(match=f"{self.storage.prefix}*"): - try: - if isinstance(key, bytes): - key = key.decode("utf-8") - session_id = key[len(self.storage.prefix) :] - - session_data = await self.storage.get(session_id, SessionData) - if session_data and session_data.is_active and session_data.last_activity < timeout_threshold: - session_data.is_active = False - session_data.metadata = { - **session_data.metadata, - "terminated_at": now.isoformat(), - "termination_reason": "session_timeout", - } - await self.storage.update(session_data.session_id, session_data) - except Exception as e: - logger.warning(f"Error processing session during cleanup: {e}") - continue - - if self.rate_limiter: - try: - await self.cleanup_rate_limits() - except Exception as e: - logger.error(f"Error cleaning up rate limits: {e}") - - self.last_cleanup = now - - except Exception as e: - logger.error(f"Error during session cleanup: {e}", exc_info=True) - - def set_session_cookies( - self, - response: Response, - session_id: str, - csrf_token: str, - max_age: int | None = None, - path: str = "/", - secure: bool = True, - ) -> None: - """Set session cookies in the response. - - Args: - response: The response object - session_id: The session ID - csrf_token: The CSRF token - max_age: Cookie max age in seconds - path: Cookie path - secure: Whether to set the Secure flag - """ - samesite: SamesiteType = DEV_SAMESITE if settings.DEBUG else PROD_SAMESITE - cookie_max_age = max_age if max_age is not None else settings.SESSION_COOKIE_MAX_AGE - - response.set_cookie( - key="session_id", - value=session_id, - httponly=True, - secure=secure, - samesite=samesite, - path=path, - max_age=cookie_max_age, - ) - - response.set_cookie( - key="csrf_token", - value=csrf_token, - httponly=False, - secure=secure, - samesite=samesite, - path=path, - max_age=cookie_max_age, - ) - - def clear_session_cookies( - self, - response: Response, - path: str = "/", - ) -> None: - """Clear session cookies from the response. - - Args: - response: The response object - path: Cookie path - """ - response.delete_cookie(key="session_id", path=path) - response.delete_cookie(key="csrf_token", path=path) - - async def track_login_attempt(self, ip_address: str, username: str, success: bool = False) -> tuple[bool, int | None]: - """Track login attempts and apply rate limiting. - - Args: - ip_address: Client IP address - username: Username being used for login - success: Whether the login attempt was successful - - Returns: - Tuple of (is_allowed, attempts_remaining) - - If rate limiting is not configured, this will always return (True, None) - but log a warning about missing rate limiting. - """ - if not self.rate_limiter: - logger.warning( - "No rate limiter configured for login attempts. " - "It is strongly recommended to configure rate limiting for security." - ) - return True, None - - try: - ip_key = f"login:ip:{ip_address}" - username_key = f"login:user:{username}" - - if success: - try: - await self.rate_limiter.delete(ip_key) - await self.rate_limiter.delete(username_key) - return True, None - except Exception as e: - logger.warning(f"Error clearing rate limit after successful login: {e}") - return True, None - - try: - expiry_seconds = int(self.login_window.total_seconds()) - ip_count = await self.rate_limiter.increment(ip_key, 1, expiry_seconds) - username_count = await self.rate_limiter.increment(username_key, 1, expiry_seconds) - except Exception as e: - logger.warning(f"Error tracking login attempt rate limits: {e}") - return True, None - - attempt_count = max(ip_count, username_count) - remaining = max(0, self.login_max_attempts - attempt_count) - - is_allowed = attempt_count <= self.login_max_attempts - - if not is_allowed: - logger.warning(f"Rate limit exceeded for login: {ip_address}, username: {username}, attempts: {attempt_count}") - - return is_allowed, remaining - - except Exception as e: - logger.error(f"Unexpected error in login rate limiting: {e}", exc_info=True) - return True, None - - async def cleanup_rate_limits(self) -> None: - """Clean up expired rate limit records. - - This should be called periodically along with session cleanup. - """ - if not self.rate_limiter: - return - - try: - if hasattr(self.rate_limiter, "delete_pattern"): - await self.rate_limiter.delete_pattern("login:*") - else: - logger.debug("Rate limiter does not support pattern-based cleanup") - except Exception as e: - logger.error(f"Error cleaning up rate limit records: {e}", exc_info=True) diff --git a/backend/src/infrastructure/auth/session/schemas.py b/backend/src/infrastructure/auth/session/schemas.py deleted file mode 100644 index 618e7623..00000000 --- a/backend/src/infrastructure/auth/session/schemas.py +++ /dev/null @@ -1,54 +0,0 @@ -from datetime import UTC, datetime -from typing import Any -from uuid import uuid4 - -from pydantic import BaseModel, Field - - -class SessionData(BaseModel): - """Common base data for any user session.""" - - user_id: int - session_id: str = Field(default_factory=lambda: str(uuid4())) - ip_address: str - user_agent: str - device_info: dict[str, Any] = Field(default_factory=dict) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC)) - is_active: bool = True - metadata: dict[str, Any] = Field(default_factory=dict) - - -class SessionCreate(SessionData): - """Schema for creating a new session.""" - - pass - - -class SessionUpdate(BaseModel): - """Schema for updating a session.""" - - last_activity: datetime | None = None - is_active: bool | None = None - metadata: dict[str, Any] | None = None - - -class UserAgentInfo(BaseModel): - """Parsed User-Agent information.""" - - browser: str - browser_version: str - os: str - device: str - is_mobile: bool - is_tablet: bool - is_pc: bool - - -class CSRFToken(BaseModel): - """CSRF token schema.""" - - token: str - user_id: int - session_id: str - expiry: datetime diff --git a/backend/src/infrastructure/auth/session/storage.py b/backend/src/infrastructure/auth/session/storage.py deleted file mode 100644 index b549b360..00000000 --- a/backend/src/infrastructure/auth/session/storage.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Session storage factory + backwards-compatible re-export of the abstract base. - -The abstract base (``AbstractSessionStorage``) lives in ``base.py`` so -that the concrete ``backends/`` implementations don't form a cycle with -this module. Existing callers that import ``AbstractSessionStorage`` -from ``.storage`` keep working via the re-export below. -""" - -from typing import Generic, TypeVar, cast - -from pydantic import BaseModel - -from ...config import SessionBackend -from ...logging import get_logger -from .backends.memcached import MemcachedSessionStorage -from .backends.memory import MemorySessionStorage -from .backends.redis import RedisSessionStorage -from .base import AbstractSessionStorage - -T = TypeVar("T", bound=BaseModel) -logger = get_logger(__name__) - -__all__ = ["AbstractSessionStorage", "SessionStorage", "get_session_storage"] - - -class SessionStorage(AbstractSessionStorage[T], Generic[T]): - def __new__(cls, backend: str = "memory", **kwargs) -> "SessionStorage[T]": - """Factory method to create the appropriate session storage backend. - - Args: - backend: The backend to use ("redis", "memcached", "memory") - **kwargs: Additional arguments to pass to the backend - - Returns: - An initialized storage backend - """ - storage: AbstractSessionStorage[T] = get_session_storage(backend, cast(type[T], BaseModel), **kwargs) - return cast("SessionStorage[T]", storage) - - -def get_session_storage(backend: str, model_type: type[BaseModel], **kwargs) -> AbstractSessionStorage[T]: - """Get the appropriate session storage backend. - - Args: - backend: The backend to use ("redis", "memcached", "memory") - model_type: The pydantic model type for type checking - **kwargs: Additional arguments to pass to the backend - - Returns: - An initialized storage backend - """ - if backend == SessionBackend.REDIS.value: - return RedisSessionStorage(**kwargs) - elif backend == SessionBackend.MEMCACHED.value: - return MemcachedSessionStorage(**kwargs) - elif backend == SessionBackend.MEMORY.value: - return MemorySessionStorage(**kwargs) - else: - raise ValueError(f"Unknown backend: {backend}") diff --git a/backend/src/infrastructure/auth/session/user_agents_types.py b/backend/src/infrastructure/auth/session/user_agents_types.py deleted file mode 100644 index 85703c4a..00000000 --- a/backend/src/infrastructure/auth/session/user_agents_types.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Type-annotated wrapper for the user_agents module. -This module provides proper type annotations for the user_agents module. -""" - -from typing import NamedTuple, cast - -# mypy: disable-error-code="import-untyped" -from user_agents import parse as _parse - - -class Browser(NamedTuple): - """Browser information.""" - - family: str - version: str | None = None - version_string: str = "" - - -class OperatingSystem(NamedTuple): - """Operating system information.""" - - family: str - version: str | None = None - version_string: str = "" - - -class Device(NamedTuple): - """Device information.""" - - family: str - brand: str | None = None - model: str | None = None - - -class UserAgent: - """User agent information with proper typing.""" - - browser: Browser - os: OperatingSystem - device: Device - is_mobile: bool - is_tablet: bool - is_pc: bool - is_bot: bool - - def __str__(self) -> str: - return f"{self.browser.family}/{self.browser.version_string} ({self.os.family})" - - -def parse(user_agent_string: str) -> UserAgent: - """ - Parse a user agent string into structured data. - - Args: - user_agent_string: The user agent string to parse - - Returns: - A UserAgent object with parsed information - """ - return cast(UserAgent, _parse(user_agent_string)) diff --git a/backend/src/infrastructure/auth/setup.py b/backend/src/infrastructure/auth/setup.py new file mode 100644 index 00000000..78ec7edc --- /dev/null +++ b/backend/src/infrastructure/auth/setup.py @@ -0,0 +1,44 @@ +"""crudauth composition root. + +A single module-level ``auth`` singleton wired over the existing ``User`` model. +It is constructed here, not in the lifespan, because routers and ``current_user`` +dependencies reference it at import time; the lifespan only opens and closes its +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. +""" + +from crudauth import CookieConfig, CRUDAuth, SessionTransport +from crudauth.ratelimit import redis_rate_limiter + +from ...modules.user.models import User +from ..config.settings import settings +from ..database.session import async_session + +_redis_password = settings.CACHE_REDIS_PASSWORD +_redis_auth = f":{_redis_password}@" if _redis_password else "" +_session_redis_url = f"redis://{_redis_auth}{settings.CACHE_REDIS_HOST}:{settings.CACHE_REDIS_PORT}/{settings.CACHE_REDIS_DB}" + +_use_redis = settings.SESSION_BACKEND == "redis" + +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(redis_url=_session_redis_url) if _use_redis else None, + trusted_proxy_hops=settings.TRUSTED_PROXY_HOPS, +) diff --git a/backend/src/infrastructure/auth/utils.py b/backend/src/infrastructure/auth/utils.py deleted file mode 100644 index faa0b704..00000000 --- a/backend/src/infrastructure/auth/utils.py +++ /dev/null @@ -1,80 +0,0 @@ -import bcrypt - - -async def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a plaintext password against its bcrypt hash. - - Performs secure password verification using bcrypt's built-in comparison - function, which includes protection against timing attacks through - constant-time comparison. - - Args: - plain_password: The plaintext password to verify. - hashed_password: The bcrypt hash to compare against. - - Returns: - True if the password matches the hash, False otherwise. - - Note: - This function uses bcrypt.checkpw() which: - - Automatically handles salt extraction from the hash - - Performs constant-time comparison to prevent timing attacks - - Works with any valid bcrypt hash format - - Is designed to be computationally expensive to prevent brute force - - Example: - ```python - # During user authentication - stored_hash = user.password_hash - entered_password = "user_entered_password" - - if await verify_password(entered_password, stored_hash): - # Password is correct - authenticate user - return authenticate_user(user) - else: - # Password is incorrect - deny access - raise AuthenticationError("Invalid password") - ``` - """ - verified: bool = bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) - return verified - - -def get_password_hash(password: str) -> str: - """Generate a secure bcrypt hash for a plaintext password. - - Creates a bcrypt hash using a randomly generated salt, providing - strong password protection against rainbow table attacks and - ensuring each password has a unique hash. - - Args: - password: The plaintext password to hash. - - Returns: - The bcrypt hash as a string, including the salt. - - Note: - This function uses bcrypt.hashpw() with bcrypt.gensalt() which: - - Generates a random salt for each password - - Uses a default cost factor (rounds) appropriate for security - - Produces hashes that are compatible with standard bcrypt libraries - - Creates hashes that include the salt and cost parameters - - Example: - ```python - # During user registration - plain_password = "user_new_password" - hashed_password = get_password_hash(plain_password) - - # Store the hash in the database - user = User( - email="user@example.com", - password_hash=hashed_password - ) - await session.add(user) - await session.commit() - ``` - """ - hashed_password: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) - decoded_password: str = hashed_password.decode() - return decoded_password diff --git a/backend/src/infrastructure/config/enums.py b/backend/src/infrastructure/config/enums.py index 047fd452..d8cfb869 100644 --- a/backend/src/infrastructure/config/enums.py +++ b/backend/src/infrastructure/config/enums.py @@ -17,11 +17,10 @@ class CacheBackend(StrEnum): class SessionBackend(StrEnum): """Session storage backend types. - Supported backends for session storage. + Supported backends for session storage (crudauth supports redis and memory only). """ REDIS = "redis" - MEMCACHED = "memcached" MEMORY = "memory" diff --git a/backend/src/infrastructure/config/settings.py b/backend/src/infrastructure/config/settings.py index 525b3c8f..e91a6d3c 100644 --- a/backend/src/infrastructure/config/settings.py +++ b/backend/src/infrastructure/config/settings.py @@ -226,21 +226,19 @@ class AuthSettings(BaseSettings): """Authentication-related settings.""" SECRET_KEY: str = config("SECRET_KEY", default="insecure-secret-key-change-this") - ALGORITHM: str = config("ALGORITHM", default="HS256") - ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES", default=30, cast=int) - REFRESH_TOKEN_EXPIRE_DAYS: int = config("REFRESH_TOKEN_EXPIRE_DAYS", default=7, cast=int) SESSION_TIMEOUT_MINUTES: int = config("SESSION_TIMEOUT_MINUTES", default=30, cast=int) SESSION_CLEANUP_INTERVAL_MINUTES: int = config("SESSION_CLEANUP_INTERVAL_MINUTES", default=15, cast=int) MAX_SESSIONS_PER_USER: int = config("MAX_SESSIONS_PER_USER", default=5, cast=int) SESSION_SECURE_COOKIES: bool = config("SESSION_SECURE_COOKIES", default=True, cast=bool) SESSION_BACKEND: str = config("SESSION_BACKEND", default=SessionBackend.REDIS.value) - SESSION_COOKIE_MAX_AGE: int = config("SESSION_COOKIE_MAX_AGE", default=86400, cast=int) CSRF_ENABLED: bool = config("CSRF_ENABLED", default=True, cast=bool) - LOGIN_MAX_ATTEMPTS: int = config("LOGIN_MAX_ATTEMPTS", default=5, cast=int) - LOGIN_WINDOW_MINUTES: int = config("LOGIN_WINDOW_MINUTES", default=15, cast=int) + # Number of trusted reverse proxies in front of the app. crudauth resolves the + # client IP for login lockout from the last hop of X-Forwarded-For; 0 = the socket + # 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) OAUTH_GOOGLE_CLIENT_ID: str = config("OAUTH_GOOGLE_CLIENT_ID", default="") OAUTH_GOOGLE_CLIENT_SECRET: str = config("OAUTH_GOOGLE_CLIENT_SECRET", default="") diff --git a/backend/src/infrastructure/dependencies.py b/backend/src/infrastructure/dependencies.py index 6331304b..0fda24ee 100644 --- a/backend/src/infrastructure/dependencies.py +++ b/backend/src/infrastructure/dependencies.py @@ -4,36 +4,16 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession -from .auth.oauth.dependencies import get_google_provider, get_oauth_state_storage -from .auth.oauth.provider import AbstractOAuthProvider -from .auth.oauth.schemas import OAuthState -from .auth.session.dependencies import ( - get_current_session_data, - get_current_superuser, - get_current_user, - get_optional_user, - get_session_from_cookie, - get_session_manager, -) -from .auth.session.manager import SessionManager -from .auth.session.schemas import SessionData -from .auth.session.storage import AbstractSessionStorage +from .auth.dependencies import get_current_superuser, get_current_user, get_optional_user from .database.session import async_session # Database AsyncSessionDep = Annotated[AsyncSession, Depends(async_session)] -# Users +# Users (dict-compat, resolved by crudauth) CurrentUserDep = Annotated[dict[str, Any], Depends(get_current_user)] CurrentSuperUserDep = Annotated[dict[str, Any], Depends(get_current_superuser)] OptionalUserDep = Annotated[dict[str, Any] | None, Depends(get_optional_user)] -# Sessions -SessionManagerDep = Annotated[SessionManager, Depends(get_session_manager)] -CurrentSessionDataDep = Annotated[SessionData, Depends(get_current_session_data)] -OptionalSessionDataDep = Annotated[SessionData | None, Depends(get_session_from_cookie)] - -# OAuth +# Auth form OAuth2FormDep = Annotated[OAuth2PasswordRequestForm, Depends()] -GoogleOAuthProviderDep = Annotated[AbstractOAuthProvider, Depends(get_google_provider)] -OAuthStateStorageDep = Annotated[AbstractSessionStorage[OAuthState], Depends(get_oauth_state_storage)] diff --git a/backend/src/infrastructure/middleware.py b/backend/src/infrastructure/middleware.py index e89b4116..49fb47bf 100644 --- a/backend/src/infrastructure/middleware.py +++ b/backend/src/infrastructure/middleware.py @@ -4,7 +4,8 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.types import ASGIApp -from .auth.constants import HSTS_MAX_AGE_SECONDS +# Two years, matching the HSTS preload-list requirement. +HSTS_MAX_AGE_SECONDS = 63072000 class ClientCacheMiddleware(BaseHTTPMiddleware): diff --git a/backend/src/interfaces/admin/views/users.py b/backend/src/interfaces/admin/views/users.py index 5cb51750..d79214f6 100644 --- a/backend/src/interfaces/admin/views/users.py +++ b/backend/src/interfaces/admin/views/users.py @@ -2,11 +2,11 @@ from typing import Any +from crudauth import get_password_hash from sqladmin import ModelView from starlette.requests import Request from wtforms import SelectField -from ....infrastructure.auth.utils import get_password_hash from ....infrastructure.database.session import local_session from ....modules.user.enums import OAuthProvider from ....modules.user.models import User diff --git a/backend/src/modules/user/models.py b/backend/src/modules/user/models.py index a294e557..8c0d7eec 100644 --- a/backend/src/modules/user/models.py +++ b/backend/src/modules/user/models.py @@ -50,5 +50,15 @@ class User(Base, TimestampMixin, SoftDeleteMixin): tier: Mapped["Tier | None"] = relationship("Tier", back_populates="users", lazy="selectin", init=False) + @property + def is_active(self) -> bool: + """Derived active flag for crudauth: a soft-deleted user is inactive. + + ``is_deleted`` stays the single source of truth; crudauth reads ``is_active`` + to gate authentication, so this maps the contract onto the existing column + without adding a new one. + """ + return not self.is_deleted + def __repr__(self) -> str: return f"{self.name} ({self.email})" diff --git a/backend/src/modules/user/service.py b/backend/src/modules/user/service.py index ba8742b0..110f872d 100644 --- a/backend/src/modules/user/service.py +++ b/backend/src/modules/user/service.py @@ -1,12 +1,12 @@ from datetime import UTC, datetime from typing import Any, cast +from crudauth import get_password_hash from fastcrud import JoinConfig from fastcrud.types import GetMultiResponseDict from sqlalchemy.exc import MultipleResultsFound, NoResultFound from sqlalchemy.ext.asyncio import AsyncSession -from ...infrastructure.auth.utils import get_password_hash from ...infrastructure.logging import get_logger from ..common.exceptions import PermissionDeniedError, TierNotFoundError, UserExistsError, UserNotFoundError, ValidationError from ..rate_limit.models import RateLimit diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a785132..6a18ecc3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,35 +1,40 @@ import os -import secrets -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -import pytest_asyncio -import redis as syncredis -import redis.asyncio as aioredis -from faker import Faker -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.orm import sessionmaker -from testcontainers.core.docker_client import DockerClient + +# 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. +os.environ.setdefault("SESSION_BACKEND", "memory") +# 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") +os.environ.setdefault("SECRET_KEY", "test_secret_key_for_tests") +os.environ.setdefault("SQLITE_URI", ":memory:") +os.environ.setdefault("SQLITE_ASYNC_PREFIX", "sqlite+aiosqlite:///") + +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 +import redis as syncredis # noqa: E402 +import redis.asyncio as aioredis # noqa: E402 +from crudauth import get_password_hash # noqa: E402 +from faker import Faker # noqa: E402 +from httpx import ASGITransport, AsyncClient # noqa: E402 +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 +from testcontainers.core.docker_client import DockerClient # noqa: E402 # mypy: disable-error-code="import-untyped" -from testcontainers.postgres import PostgresContainer - -from src.infrastructure.auth.session.backends.memory import MemorySessionStorage -from src.infrastructure.auth.session.dependencies import get_current_superuser, get_current_user -from src.infrastructure.auth.session.schemas import CSRFToken, SessionData -from src.infrastructure.auth.utils import get_password_hash -from src.infrastructure.config.settings import Settings, get_settings -from src.infrastructure.database.session import Base, async_session -from src.interfaces.main import app -from src.modules.tier.models import Tier -from src.modules.user.models import User - -os.environ["SQLITE_URI"] = ":memory:" -os.environ["SQLITE_ASYNC_PREFIX"] = "sqlite+aiosqlite:///" -os.environ["SECRET_KEY"] = "test_secret_key_for_tests" +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.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 +from src.modules.user.models import User # noqa: E402 TEST_DATABASE_URL = get_settings().DATABASE_URL @@ -255,35 +260,6 @@ async def override_get_current_superuser(): return client -@pytest.fixture(autouse=True) -def mock_session_backend(monkeypatch): - """Use in-memory session backend instead of Redis during tests.""" - memory_storage: MemorySessionStorage[SessionData] = MemorySessionStorage(prefix="session:", expiration=1800) - memory_csrf_storage: MemorySessionStorage[CSRFToken] = MemorySessionStorage(prefix="csrf:", expiration=1800) - - def override_session_dependency(backend, model_type, **kwargs): - if model_type == CSRFToken: - return memory_csrf_storage - return memory_storage - - async def mock_execute(self): - return [True] - - async def mock_create(self, data): - session_id = secrets.token_hex(16) - self.data[f"{self.prefix}{session_id}"] = data.model_dump() - return session_id - - setattr(memory_storage, "execute", mock_execute) - setattr(memory_csrf_storage, "execute", mock_execute) - setattr(memory_storage, "create", mock_create) - setattr(memory_csrf_storage, "create", mock_create) - - monkeypatch.setattr("src.infrastructure.auth.session.storage.get_session_storage", override_session_dependency) - monkeypatch.setattr("src.infrastructure.auth.session.manager.get_session_storage", override_session_dependency) - monkeypatch.setenv("SESSION_BACKEND", "memory") - - @pytest.fixture(autouse=True) def patch_redis_pipeline_for_tests(monkeypatch): """Patch Redis pipeline so tests don't need a live Redis.""" diff --git a/backend/tests/integration/auth/helpers.py b/backend/tests/integration/auth/helpers.py deleted file mode 100644 index d702a3c8..00000000 --- a/backend/tests/integration/auth/helpers.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Helper functions for auth API tests.""" - -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -from src.infrastructure.auth.oauth.provider import AbstractOAuthProvider -from src.infrastructure.auth.oauth.schemas import OAuthState -from src.infrastructure.auth.session.manager import SessionManager -from src.infrastructure.auth.session.storage import AbstractSessionStorage - - -def create_mock_oauth_provider(name: str = "google") -> AsyncMock: - """Create a mock OAuth provider for testing.""" - mock_provider = AsyncMock(spec=AbstractOAuthProvider) - mock_provider.name = name - mock_provider.get_authorization_url = AsyncMock( - return_value={ - "url": f"https://accounts.{name}.com/o/oauth2/v2/auth?dummy=params", - "state": "test-state-value", - "code_verifier": "test-code-verifier", - } - ) - mock_provider.exchange_code = AsyncMock(return_value={}) - return mock_provider - - -def create_mock_oauth_state_storage() -> AsyncMock: - """Create a mock OAuth state storage for testing.""" - mock_storage = AsyncMock(spec=AbstractSessionStorage) - mock_storage.create = AsyncMock(return_value="test-state-value") - mock_storage.delete = AsyncMock(return_value=None) - return mock_storage - - -def create_mock_session_manager() -> AsyncMock: - """Create a mock session manager for testing.""" - mock_manager = AsyncMock(spec=SessionManager) - mock_manager.create_session = AsyncMock(return_value=("session-id", "csrf-token")) - mock_manager.set_session_cookies = MagicMock() - return mock_manager - - -def create_oauth_state(provider: str = "google", state: str = "test-state-value") -> OAuthState: - """Create an OAuth state object for testing.""" - return OAuthState( - state=state, - provider=provider, - redirect_to="/", - code_verifier="test-code-verifier", - ) - - -def create_mock_session_data(user_id: int = 1): - """Create mock session data for testing.""" - mock_session = MagicMock() - mock_session.user_id = user_id - mock_session.created_at = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) - mock_session.last_activity = datetime(2023, 1, 1, 1, 0, 0, tzinfo=UTC) - return mock_session - - -def create_mock_user_data( - user_id: int = 1, username: str = "testuser", email: str = "test@example.com", provider: str = "google" -) -> dict: - """Create mock user data for testing.""" - return { - "id": user_id, - "username": username, - "email": email, - "oauth_provider": provider, - } diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index 1677950b..0ebd6df2 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -1,165 +1,143 @@ -"""Tests for OAuth authentication endpoints.""" +"""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 +FastAPI dependency to simulate authenticated / anonymous callers. +""" -from datetime import UTC, datetime -from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from crudauth import Principal +from crudauth.oauth import OAuthState from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession - -from src.infrastructure.auth.oauth.dependencies import ( - get_google_provider, - get_oauth_state, - get_oauth_state_storage, -) -from src.infrastructure.auth.oauth.provider import AbstractOAuthProvider -from src.infrastructure.auth.oauth.schemas import OAuthState -from src.infrastructure.auth.session.dependencies import get_session_from_cookie, get_session_manager -from src.infrastructure.auth.session.manager import SessionManager -from src.infrastructure.auth.session.storage import AbstractSessionStorage -from src.infrastructure.database.session import async_session + +from src.infrastructure.auth.dependencies import get_optional_principal from src.interfaces.main import app +ROUTES = "src.infrastructure.auth.routes" + @pytest.mark.asyncio -async def test_oauth_google_login(client: AsyncClient): - """Test the OAuth Google login initiation endpoint.""" - mock_provider = AsyncMock(spec=AbstractOAuthProvider) - mock_provider.name = "google" - mock_provider.get_authorization_url = AsyncMock( - return_value={ - "url": "https://accounts.google.com/o/oauth2/v2/auth?dummy=params", - "state": "test-state-value", - "code_verifier": "test-code-verifier", - } +async def test_login_success(client: AsyncClient, test_user: dict): + """A valid username/password logs in: 200, a CSRF token, and a session cookie. + + Exercises the real crudauth path end-to-end (authenticate_password against the + test DB, create_session on the in-memory backend, set_session_cookies). + """ + response = await client.post( + "/api/v1/auth/login", + data={"username": test_user["username"], "password": test_user["password"]}, ) - mock_state_storage = AsyncMock(spec=AbstractSessionStorage) - mock_state_storage.create = AsyncMock(return_value="test-state-value") + assert response.status_code == 200 + assert response.json()["csrf_token"] + assert any(cookie == "session_id" for cookie in response.cookies) - original_deps = app.dependency_overrides.copy() - - try: - app.dependency_overrides[get_google_provider] = lambda: mock_provider - app.dependency_overrides[get_oauth_state_storage] = lambda: mock_state_storage - response = await client.get("/api/v1/auth/oauth/google") +@pytest.mark.asyncio +async def test_login_wrong_password(client: AsyncClient, test_user: dict): + """An incorrect password is rejected with 401.""" + response = await client.post( + "/api/v1/auth/login", + data={"username": test_user["username"], "password": "wrong-password"}, + ) - print(f"Response status: {response.status_code}") - if response.status_code != 200: - print(f"Response body: {response.text}") + assert response.status_code == 401 - assert response.status_code == 200 - assert "url" in response.json() - assert response.json()["url"] == "https://accounts.google.com/o/oauth2/v2/auth?dummy=params" - mock_provider.get_authorization_url.assert_called_once() - mock_state_storage.create.assert_called_once() - finally: - app.dependency_overrides = original_deps +@pytest.mark.asyncio +async def test_login_then_logout(client: AsyncClient, test_user: dict): + """Logging in then logging out (echoing the CSRF token) succeeds and clears the session.""" + login = await client.post( + "/api/v1/auth/login", + data={"username": test_user["username"], "password": test_user["password"]}, + ) + assert login.status_code == 200 + csrf_token = login.json()["csrf_token"] + logout = await client.post("/api/v1/auth/logout", headers={"X-CSRF-Token": csrf_token}) -@pytest.mark.asyncio -async def test_oauth_callback_invalid_state(client: AsyncClient): - """Test the OAuth callback endpoint with an invalid state parameter.""" - original_deps = app.dependency_overrides.copy() + assert logout.status_code == 200 + assert logout.json()["message"] == "Logged out successfully" - try: - async def mock_get_oauth_state_func(state: str, storage: Any) -> None: - return None +@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") - mock_provider = AsyncMock(spec=AbstractOAuthProvider) - mock_provider.name = "google" - mock_provider.exchange_code = AsyncMock(return_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") - mock_state_storage = AsyncMock(spec=AbstractSessionStorage) - mock_state_storage.delete = AsyncMock(return_value=None) + 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() - mock_session_manager = AsyncMock(spec=SessionManager) - mock_session_manager.create_session = AsyncMock(return_value=("session-id", "csrf-token")) - mock_session_manager.set_session_cookies = MagicMock() - app.dependency_overrides[get_oauth_state] = mock_get_oauth_state_func - app.dependency_overrides[get_google_provider] = lambda: mock_provider - app.dependency_overrides[get_oauth_state_storage] = lambda: mock_state_storage - app.dependency_overrides[get_session_manager] = lambda: mock_session_manager +@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 in [302, 500] + 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 in [400, 500] - finally: - app.dependency_overrides = original_deps + assert response.status_code == 400 @pytest.mark.asyncio async def test_oauth_callback_provider_mismatch(client: AsyncClient): - """Test the OAuth callback endpoint with a state parameter for a different provider.""" - original_deps = app.dependency_overrides.copy() - - try: - mock_state = OAuthState( - state="test-state-value", - provider="github", - redirect_to="/", - code_verifier="test-code-verifier", - ) - - async def mock_get_oauth_state_func(state: str, storage: Any) -> OAuthState: - return mock_state - - mock_provider = AsyncMock(spec=AbstractOAuthProvider) - mock_provider.name = "google" - mock_provider.exchange_code = AsyncMock(return_value={}) - - mock_state_storage = AsyncMock(spec=AbstractSessionStorage) - mock_state_storage.delete = AsyncMock(return_value=None) - - mock_session_manager = AsyncMock(spec=SessionManager) - mock_session_manager.create_session = AsyncMock(return_value=("session-id", "csrf-token")) - mock_session_manager.set_session_cookies = MagicMock() - - app.dependency_overrides[get_oauth_state] = mock_get_oauth_state_func - app.dependency_overrides[get_google_provider] = lambda: mock_provider - app.dependency_overrides[get_oauth_state_storage] = lambda: mock_state_storage - app.dependency_overrides[get_session_manager] = lambda: mock_session_manager + """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 in [302, 500] + 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 in [400, 500] - finally: - app.dependency_overrides = original_deps + assert response.status_code == 400 @pytest.mark.asyncio -async def test_check_auth_authenticated(client: AsyncClient, db_session: AsyncSession): - """Test the check-auth endpoint when the user is authenticated.""" - mock_session = MagicMock() - mock_session.user_id = 1 - mock_session.created_at = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) - mock_session.last_activity = datetime(2023, 1, 1, 1, 0, 0, tzinfo=UTC) - +async def test_check_auth_authenticated(client: AsyncClient): + """check-auth returns the user info when a principal is resolved.""" mock_user = { "id": 1, "username": "testuser", @@ -168,31 +146,31 @@ async def test_check_auth_authenticated(client: AsyncClient, db_session: AsyncSe } original_deps = app.dependency_overrides.copy() - try: - app.dependency_overrides[get_session_from_cookie] = lambda: mock_session - app.dependency_overrides[async_session] = lambda: db_session + app.dependency_overrides[get_optional_principal] = lambda: Principal( + user_id=1, metadata={"session_id": "test-session"} + ) with patch("src.modules.user.crud.crud_users.get", return_value=mock_user): response = await client.get("/api/v1/auth/check-auth") - assert response.status_code == 200 - assert response.json()["authenticated"] is True - assert response.json()["user"]["id"] == 1 - assert response.json()["user"]["username"] == "testuser" - assert response.json()["user"]["oauth_provider"] == "google" - assert "session" in response.json() + assert response.status_code == 200 + body = response.json() + assert body["authenticated"] is True + assert body["user"]["id"] == 1 + assert body["user"]["username"] == "testuser" + assert body["user"]["oauth_provider"] == "google" + assert "session" in body finally: app.dependency_overrides = original_deps @pytest.mark.asyncio async def test_check_auth_not_authenticated(client: AsyncClient): - """Test the check-auth endpoint when the user is not authenticated.""" + """check-auth returns authenticated=false when the principal is None.""" original_deps = app.dependency_overrides.copy() - try: - app.dependency_overrides[get_session_from_cookie] = lambda: None + app.dependency_overrides[get_optional_principal] = lambda: None response = await client.get("/api/v1/auth/check-auth") @@ -205,21 +183,13 @@ async def test_check_auth_not_authenticated(client: AsyncClient): @pytest.mark.asyncio async def test_check_auth_no_session_cookie_returns_unauthenticated(client: AsyncClient): - """A request with no session cookie must get 200 {authenticated: false}, not a 401. + """A request with no session cookie gets 200 {authenticated: false}, not a 401. - Regression: /check-auth must answer anonymous callers (its whole purpose). It used to - depend on get_current_session_data, which raises 401 when no session exists, so the - unauthenticated branch was unreachable. Only get_session_manager is overridden here so - the real get_session_from_cookie runs against a request that carries no cookie. + No dependency override here: the real crudauth ``current_user(optional=True)`` + resolution runs against a request that carries no session cookie, proving the + endpoint answers anonymous callers rather than raising 401. """ - original_deps = app.dependency_overrides.copy() + response = await client.get("/api/v1/auth/check-auth") - try: - app.dependency_overrides[get_session_manager] = lambda: MagicMock() - - response = await client.get("/api/v1/auth/check-auth") - - assert response.status_code == 200 - assert response.json()["authenticated"] is False - finally: - app.dependency_overrides = original_deps + assert response.status_code == 200 + assert response.json()["authenticated"] is False diff --git a/backend/tests/unit/infrastructure/auth/oauth/__init__.py b/backend/tests/unit/infrastructure/auth/oauth/__init__.py deleted file mode 100644 index 351f6549..00000000 --- a/backend/tests/unit/infrastructure/auth/oauth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for OAuth implementation.""" diff --git a/backend/tests/unit/infrastructure/auth/oauth/test_factory.py b/backend/tests/unit/infrastructure/auth/oauth/test_factory.py deleted file mode 100644 index 061613ee..00000000 --- a/backend/tests/unit/infrastructure/auth/oauth/test_factory.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Tests for the OAuthProviderFactory class.""" - -from typing import Any - -import pytest - -from src.infrastructure.auth.oauth.factory import OAuthProviderFactory -from src.infrastructure.auth.oauth.provider import AbstractOAuthProvider -from src.infrastructure.auth.oauth.schemas import OAuthUserInfo - - -class MockOAuthProvider(AbstractOAuthProvider): - """Mock OAuth provider for testing factory patterns.""" - - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """Process user info from the provider.""" - return OAuthUserInfo( - provider="mock", - provider_user_id=str(user_info.get("id", "")), - email=user_info.get("email"), - email_verified=False, - name=user_info.get("name"), - given_name=None, - family_name=None, - username=None, - picture=None, - raw_data=user_info, - ) - - @classmethod - def create(cls, client_id: str, client_secret: str, redirect_uri: str) -> "MockOAuthProvider": - """Factory method to create a new instance.""" - return cls( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scopes=["email", "profile"], - authorize_endpoint="https://example.com/authorize", - token_endpoint="https://example.com/token", - userinfo_endpoint="https://example.com/userinfo", - provider_name="mock", - ) - - -class CreatelessMockProvider(AbstractOAuthProvider): - """Mock provider without a create method to test fallback instantiation.""" - - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """Process user info from the provider.""" - return OAuthUserInfo( - provider="createless", - provider_user_id=str(user_info.get("id", "")), - email=user_info.get("email"), - email_verified=False, - name=user_info.get("name"), - given_name=None, - family_name=None, - username=None, - picture=None, - raw_data=user_info, - ) - - -@pytest.fixture -def setup_factory(): - """Setup the factory with test providers and clean up after tests.""" - original_providers = OAuthProviderFactory._providers.copy() - - OAuthProviderFactory.register_provider("mock", MockOAuthProvider) - OAuthProviderFactory.register_provider("createless", CreatelessMockProvider) - - yield - - OAuthProviderFactory._providers = original_providers - - -def test_register_provider(): - """Test registering a provider class.""" - original_providers = OAuthProviderFactory._providers.copy() - - OAuthProviderFactory.register_provider("test", MockOAuthProvider) - - assert "test" in OAuthProviderFactory._providers - assert OAuthProviderFactory._providers["test"] == MockOAuthProvider - - OAuthProviderFactory._providers = original_providers - - -def test_get_provider_class_registered(setup_factory): - """Test getting a registered provider class.""" - provider_class = OAuthProviderFactory.get_provider_class("mock") - assert provider_class == MockOAuthProvider - - -def test_get_provider_class_not_registered(setup_factory): - """Test getting a non-registered provider class.""" - provider_class = OAuthProviderFactory.get_provider_class("nonexistent") - assert provider_class is None - - -def test_create_provider_with_create_method(setup_factory): - """Test creating a provider that has a create class method.""" - provider = OAuthProviderFactory.create_provider( - provider_name="mock", - client_id="test-id", - client_secret="test-secret", - redirect_uri="https://test.com/callback", - ) - - assert isinstance(provider, MockOAuthProvider) - assert provider.client_id == "test-id" - assert provider.client_secret == "test-secret" - assert provider.redirect_uri == "https://test.com/callback" - assert provider.name == "mock" - - -def test_create_provider_without_create_method(setup_factory): - """Test creating a provider without a create class method.""" - provider = OAuthProviderFactory.create_provider( - provider_name="createless", - client_id="test-id", - client_secret="test-secret", - redirect_uri="https://test.com/callback", - ) - - assert isinstance(provider, CreatelessMockProvider) - assert provider.client_id == "test-id" - assert provider.client_secret == "test-secret" - assert provider.redirect_uri == "https://test.com/callback" - assert provider.name == "createless" - - -def test_create_provider_not_registered(): - """Test creating a provider that is not registered.""" - with pytest.raises(ValueError): - OAuthProviderFactory.create_provider( - provider_name="nonexistent", - client_id="test-id", - client_secret="test-secret", - redirect_uri="https://test.com/callback", - ) diff --git a/backend/tests/unit/infrastructure/auth/oauth/test_provider.py b/backend/tests/unit/infrastructure/auth/oauth/test_provider.py deleted file mode 100644 index bc6f8dac..00000000 --- a/backend/tests/unit/infrastructure/auth/oauth/test_provider.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Tests for the AbstractOAuthProvider class.""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.infrastructure.auth.oauth.provider import AbstractOAuthProvider -from src.infrastructure.auth.oauth.schemas import OAuthUserInfo - - -class ConcreteOAuthProvider(AbstractOAuthProvider): - """Concrete implementation of AbstractOAuthProvider for testing.""" - - async def process_user_info(self, user_info: dict[str, Any]) -> OAuthUserInfo: - """Process user info from the provider.""" - return OAuthUserInfo( - provider="test", - provider_user_id=str(user_info.get("id", "")), - email=user_info.get("email"), - email_verified=user_info.get("email_verified", False), - name=user_info.get("name"), - given_name=user_info.get("given_name"), - family_name=user_info.get("family_name"), - username=user_info.get("username"), - picture=user_info.get("picture"), - raw_data=user_info, - ) - - -@pytest.fixture -def oauth_provider(): - """Create a test provider instance.""" - return ConcreteOAuthProvider( - client_id="test-client-id", - client_secret="test-client-secret", - redirect_uri="https://example.com/callback", - scopes=["openid", "email", "profile"], - authorize_endpoint="https://auth.example.com/authorize", - token_endpoint="https://auth.example.com/token", - userinfo_endpoint="https://auth.example.com/userinfo", - provider_name="test", - ) - - -def test_provider_initialization(oauth_provider): - """Test provider initialization with correct attributes.""" - assert oauth_provider.client_id == "test-client-id" - assert oauth_provider.client_secret == "test-client-secret" - assert oauth_provider.redirect_uri == "https://example.com/callback" - assert oauth_provider.scopes == ["openid", "email", "profile"] - assert oauth_provider.authorize_endpoint == "https://auth.example.com/authorize" - assert oauth_provider.token_endpoint == "https://auth.example.com/token" - assert oauth_provider.userinfo_endpoint == "https://auth.example.com/userinfo" - assert oauth_provider.name == "test" - - -def test_generate_state(oauth_provider): - """Test that generate_state returns a random string of expected length.""" - state = oauth_provider.generate_state() - assert isinstance(state, str) - assert len(state) > 32 - - -def test_generate_pkce_codes(oauth_provider): - """Test that PKCE code generation returns both verifier and challenge.""" - pkce_codes = oauth_provider.generate_pkce_codes() - assert "code_verifier" in pkce_codes - assert "code_challenge" in pkce_codes - assert isinstance(pkce_codes["code_verifier"], str) - assert isinstance(pkce_codes["code_challenge"], str) - assert len(pkce_codes["code_verifier"]) >= 43 - assert len(pkce_codes["code_challenge"]) >= 43 - - -@pytest.mark.asyncio -async def test_get_authorization_url_with_pkce(oauth_provider): - """Test authorization URL generation with PKCE.""" - state = "test-state-value" - result = await oauth_provider.get_authorization_url(state=state, pkce=True) - - assert "url" in result - assert "state" in result - assert "code_verifier" in result - assert result["state"] == state - assert "client_id=test-client-id" in result["url"] - assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in result["url"] - assert "code_challenge" in result["url"] - assert "code_challenge_method=S256" in result["url"] - - -@pytest.mark.asyncio -async def test_get_authorization_url_without_pkce(oauth_provider): - """Test authorization URL generation without PKCE.""" - state = "test-state-value" - result = await oauth_provider.get_authorization_url(state=state, pkce=False) - - assert "url" in result - assert "state" in result - assert "code_verifier" not in result - assert result["state"] == state - assert "client_id=test-client-id" in result["url"] - assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in result["url"] - assert "code_challenge" not in result["url"] - assert "code_challenge_method=S256" not in result["url"] - - -@pytest.mark.asyncio -async def test_get_authorization_url_with_extra_params(oauth_provider): - """Test authorization URL generation with extra parameters.""" - extra_params = {"prompt": "consent", "access_type": "offline"} - result = await oauth_provider.get_authorization_url(extra_params=extra_params) - - assert "url" in result - assert "prompt=consent" in result["url"] - assert "access_type=offline" in result["url"] - - -@pytest.mark.asyncio -async def test_exchange_code_successful(oauth_provider): - """Test successful code exchange for access token.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = { - "access_token": "test-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "test-refresh-token", - } - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = AsyncMock() - mock_context.__aenter__.return_value = mock_client - mock_context.__aexit__.return_value = None - - with patch("httpx.AsyncClient", return_value=mock_context): - result = await oauth_provider.exchange_code("test-code", "test-verifier") - - mock_client.post.assert_called_once() - - call_args = mock_client.post.call_args - assert call_args[0][0] == "https://auth.example.com/token" - - post_data = call_args[1]["data"] - assert post_data["client_id"] == "test-client-id" - assert post_data["client_secret"] == "test-client-secret" - assert post_data["code"] == "test-code" - assert post_data["redirect_uri"] == "https://example.com/callback" - assert post_data["code_verifier"] == "test-verifier" - - assert result["access_token"] == "test-access-token" - assert result["refresh_token"] == "test-refresh-token" - - -@pytest.mark.asyncio -async def test_get_user_info_successful(oauth_provider): - """Test successful user info retrieval.""" - test_user_info = {"id": "12345", "email": "test@example.com", "name": "Test User", "picture": "https://example.com/pic.jpg"} - - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = test_user_info - - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - - mock_context = AsyncMock() - mock_context.__aenter__.return_value = mock_client - mock_context.__aexit__.return_value = None - - with patch("httpx.AsyncClient", return_value=mock_context): - result = await oauth_provider.get_user_info("test-access-token") - - mock_client.get.assert_called_once() - - call_args = mock_client.get.call_args - assert call_args[0][0] == "https://auth.example.com/userinfo" - - headers = call_args[1]["headers"] - assert headers["Authorization"] == "Bearer test-access-token" - - assert result == test_user_info - - -@pytest.mark.asyncio -async def test_process_user_info(oauth_provider): - """Test processing of user info into standardized format.""" - test_user_info = { - "id": "12345", - "email": "test@example.com", - "email_verified": True, - "name": "Test User", - "given_name": "Test", - "family_name": "User", - "username": "testuser", - "picture": "https://example.com/pic.jpg", - } - - result = await oauth_provider.process_user_info(test_user_info) - - assert isinstance(result, OAuthUserInfo) - assert result.provider == "test" - assert result.provider_user_id == "12345" - assert result.email == "test@example.com" - assert result.email_verified is True - assert result.name == "Test User" - assert result.given_name == "Test" - assert result.family_name == "User" - assert result.username == "testuser" - assert result.picture == "https://example.com/pic.jpg" - assert result.raw_data == test_user_info - - -@pytest.mark.asyncio -async def test_validate_token_valid(oauth_provider): - """Test token validation with valid token.""" - with patch.object(oauth_provider, "get_user_info", new_callable=AsyncMock) as mock_get_user_info: - mock_get_user_info.return_value = {"id": "12345"} - - result = await oauth_provider.validate_token("valid-token") - - assert result is True - mock_get_user_info.assert_called_once_with("valid-token") - - -@pytest.mark.asyncio -async def test_validate_token_invalid(oauth_provider): - """Test token validation with invalid token.""" - with patch.object(oauth_provider, "get_user_info", new_callable=AsyncMock) as mock_get_user_info: - mock_get_user_info.side_effect = Exception("Invalid token") - - result = await oauth_provider.validate_token("invalid-token") - - assert result is False - mock_get_user_info.assert_called_once_with("invalid-token") diff --git a/backend/tests/unit/infrastructure/auth/oauth/test_providers.py b/backend/tests/unit/infrastructure/auth/oauth/test_providers.py deleted file mode 100644 index 43e4ec04..00000000 --- a/backend/tests/unit/infrastructure/auth/oauth/test_providers.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tests for the specific OAuth provider implementations.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.infrastructure.auth.oauth.providers.github import GitHubOAuthProvider -from src.infrastructure.auth.oauth.providers.google import GoogleOAuthProvider -from src.infrastructure.auth.oauth.schemas import OAuthUserInfo - - -@pytest.fixture -def google_provider(): - """Create a Google OAuth provider for testing.""" - return GoogleOAuthProvider( - client_id="google-client-id", - client_secret="google-client-secret", - redirect_uri="https://example.com/oauth/callback/google", - ) - - -@pytest.fixture -def github_provider(): - """Create a GitHub OAuth provider for testing.""" - return GitHubOAuthProvider( - client_id="github-client-id", - client_secret="github-client-secret", - redirect_uri="https://example.com/oauth/callback/github", - ) - - -def test_google_provider_initialization(google_provider): - """Test Google provider initialization with default scopes.""" - assert google_provider.client_id == "google-client-id" - assert google_provider.client_secret == "google-client-secret" - assert google_provider.redirect_uri == "https://example.com/oauth/callback/google" - assert "openid" in google_provider.scopes - assert "https://www.googleapis.com/auth/userinfo.email" in google_provider.scopes - assert "https://www.googleapis.com/auth/userinfo.profile" in google_provider.scopes - assert google_provider.authorize_endpoint == "https://accounts.google.com/o/oauth2/v2/auth" - assert google_provider.token_endpoint == "https://oauth2.googleapis.com/token" - assert google_provider.userinfo_endpoint == "https://www.googleapis.com/oauth2/v3/userinfo" - assert google_provider.name == "google" - - -def test_github_provider_initialization(github_provider): - """Test GitHub provider initialization with default scopes.""" - assert github_provider.client_id == "github-client-id" - assert github_provider.client_secret == "github-client-secret" - assert github_provider.redirect_uri == "https://example.com/oauth/callback/github" - assert "read:user" in github_provider.scopes - assert "user:email" in github_provider.scopes - assert github_provider.authorize_endpoint == "https://github.com/login/oauth/authorize" - assert github_provider.token_endpoint == "https://github.com/login/oauth/access_token" - assert github_provider.userinfo_endpoint == "https://api.github.com/user" - assert github_provider.name == "github" - - -@pytest.mark.asyncio -async def test_google_authorization_url(google_provider): - """Test Google-specific authorization URL generation.""" - auth_data = await google_provider.get_authorization_url() - - assert "url" in auth_data - assert "state" in auth_data - assert "code_verifier" in auth_data - assert "access_type=offline" in auth_data["url"] - assert "prompt=consent" in auth_data["url"] - - -@pytest.mark.asyncio -async def test_google_process_user_info(google_provider): - """Test Google-specific user info processing.""" - google_user_data = { - "sub": "123456789", - "email": "user@example.com", - "email_verified": True, - "name": "Test User", - "given_name": "Test", - "family_name": "User", - "picture": "https://example.com/photo.jpg", - } - - result = await google_provider.process_user_info(google_user_data) - - assert isinstance(result, OAuthUserInfo) - assert result.provider == "google" - assert result.provider_user_id == "123456789" - assert result.email == "user@example.com" - assert result.email_verified is True - assert result.name == "Test User" - assert result.given_name == "Test" - assert result.family_name == "User" - assert result.username is None - assert result.picture == "https://example.com/photo.jpg" - assert result.raw_data == google_user_data - - -@pytest.mark.asyncio -async def test_github_exchange_code(github_provider): - """Test GitHub-specific code exchange with Accept header.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = { - "access_token": "github-token", - "token_type": "bearer", - "scope": "read:user,user:email", - } - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = AsyncMock() - mock_context.__aenter__.return_value = mock_client - mock_context.__aexit__.return_value = None - - with patch("httpx.AsyncClient", return_value=mock_context): - result = await github_provider.exchange_code("github-code") - - headers = mock_client.post.call_args[1]["headers"] - assert headers["Accept"] == "application/json" - - assert result["access_token"] == "github-token" - assert result["token_type"] == "bearer" - - -@pytest.mark.asyncio -async def test_github_get_user_info_with_emails(github_provider): - """Test GitHub user info retrieval with separate emails endpoint call.""" - profile_data = { - "id": 12345, - "login": "testuser", - "name": "Test User", - "email": None, - "avatar_url": "https://github.com/avatars/user.jpg", - } - - emails_data = [ - {"email": "private@example.com", "primary": True, "verified": True}, - {"email": "public@example.com", "primary": False, "verified": True}, - ] - - mock_profile_response = MagicMock() - mock_profile_response.raise_for_status = MagicMock() - mock_profile_response.json.return_value = profile_data - - mock_emails_response = MagicMock() - mock_emails_response.status_code = 200 - mock_emails_response.json.return_value = emails_data - - mock_client = AsyncMock() - mock_client.get.side_effect = [mock_profile_response, mock_emails_response] - - mock_context = AsyncMock() - mock_context.__aenter__.return_value = mock_client - mock_context.__aexit__.return_value = None - - with patch("httpx.AsyncClient", return_value=mock_context): - result = await github_provider.get_user_info("github-token") - - assert mock_client.get.call_count == 2 - - assert mock_client.get.call_args_list[0][0][0] == "https://api.github.com/user" - - assert mock_client.get.call_args_list[1][0][0] == "https://api.github.com/user/emails" - - assert result["id"] == 12345 - assert result["login"] == "testuser" - assert "emails" in result - assert result["emails"] == emails_data - - -@pytest.mark.asyncio -async def test_github_process_user_info_with_primary_email(github_provider): - """Test GitHub-specific user info processing with primary email from emails array.""" - github_user_data = { - "id": 12345, - "login": "testuser", - "name": "Test User", - "email": "public@example.com", - "avatar_url": "https://github.com/avatars/user.jpg", - "emails": [ - {"email": "private@example.com", "primary": True, "verified": True}, - {"email": "public@example.com", "primary": False, "verified": False}, - ], - } - - result = await github_provider.process_user_info(github_user_data) - - assert isinstance(result, OAuthUserInfo) - assert result.provider == "github" - assert result.provider_user_id == "12345" - assert result.email == "private@example.com" - assert result.email_verified is True - assert result.name == "Test User" - assert result.username == "testuser" - assert result.picture == "https://github.com/avatars/user.jpg" - assert result.raw_data == github_user_data - - -@pytest.mark.asyncio -async def test_google_create_class_method(): - """Test Google provider's create class method.""" - provider = GoogleOAuthProvider.create( - client_id="test-id", - client_secret="test-secret", - redirect_uri="https://example.com/callback", - ) - - assert isinstance(provider, GoogleOAuthProvider) - assert provider.client_id == "test-id" - assert provider.client_secret == "test-secret" - assert provider.redirect_uri == "https://example.com/callback" - assert provider.name == "google" - assert "openid" in provider.scopes - - -@pytest.mark.asyncio -async def test_github_create_class_method(): - """Test GitHub provider's create class method.""" - provider = GitHubOAuthProvider.create( - client_id="test-id", - client_secret="test-secret", - redirect_uri="https://example.com/callback", - ) - - assert isinstance(provider, GitHubOAuthProvider) - assert provider.client_id == "test-id" - assert provider.client_secret == "test-secret" - assert provider.redirect_uri == "https://example.com/callback" - assert provider.name == "github" - assert "read:user" in provider.scopes diff --git a/backend/tests/unit/infrastructure/auth/oauth/test_services.py b/backend/tests/unit/infrastructure/auth/oauth/test_services.py deleted file mode 100644 index 70001f82..00000000 --- a/backend/tests/unit/infrastructure/auth/oauth/test_services.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Tests for the OAuthAccountService class.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from src.infrastructure.auth.oauth.schemas import OAuthUserInfo -from src.infrastructure.auth.oauth.services import OAuthAccountService -from src.modules.user.crud import crud_users -from src.modules.user.schemas import UserCreateInternal - - -@pytest.fixture -def oauth_service(): - """Create an instance of OAuthAccountService for testing.""" - return OAuthAccountService() - - -@pytest.fixture -def mock_db(): - """Create a mock database session.""" - return AsyncMock() - - -@pytest.fixture -def google_user_info(): - """Create a sample Google user info for testing.""" - return OAuthUserInfo( - provider="google", - provider_user_id="123456789", - email="user@example.com", - email_verified=True, - name="Test User", - given_name="Test", - family_name="User", - username=None, - picture="https://example.com/photo.jpg", - raw_data={ - "sub": "123456789", - "email": "user@example.com", - "email_verified": True, - "name": "Test User", - "given_name": "Test", - "family_name": "User", - "picture": "https://example.com/photo.jpg", - }, - ) - - -@pytest.fixture -def github_user_info(): - """Create a sample GitHub user info for testing.""" - return OAuthUserInfo( - provider="github", - provider_user_id="987654321", - email="user@example.com", - email_verified=True, - name="Test User", - given_name=None, - family_name=None, - username="testuser", - picture="https://github.com/avatars/user.jpg", - raw_data={ - "id": 987654321, - "login": "testuser", - "name": "Test User", - "email": "user@example.com", - "avatar_url": "https://github.com/avatars/user.jpg", - "emails": [{"email": "user@example.com", "primary": True, "verified": True}], - }, - ) - - -@pytest.mark.asyncio -async def test_get_or_create_user_existing_by_provider_id(oauth_service, mock_db, google_user_info): - """Test getting a user that already exists with the provider ID.""" - existing_user = { - "id": 1, - "username": "existinguser", - "email": "user@example.com", - "google_id": "123456789", - } - - mock_db.execute = AsyncMock() - - with patch.object(crud_users, "get", new_callable=AsyncMock) as mock_get: - mock_get.return_value = existing_user - - user, created = await oauth_service.get_or_create_user(google_user_info, mock_db) - - assert user == existing_user - assert created is False - - mock_get.assert_called_once() - assert mock_get.call_args[1]["filter_by"] == {"google_id": "123456789"} - - -@pytest.mark.asyncio -async def test_get_or_create_user_existing_by_email(oauth_service, mock_db, google_user_info): - """Test getting a user that exists by email but not provider ID.""" - existing_user = { - "id": 1, - "username": "existinguser", - "email": "user@example.com", - "google_id": None, - } - - mock_db.execute = AsyncMock() - - with ( - patch.object(crud_users, "get", new_callable=AsyncMock) as mock_get, - patch.object(crud_users, "update", new_callable=AsyncMock) as mock_update, - ): - mock_get.side_effect = [None, existing_user] - mock_update.return_value = {**existing_user, "google_id": "123456789"} - - user, created = await oauth_service.get_or_create_user(google_user_info, mock_db) - - assert user["id"] == 1 - assert user["google_id"] == "123456789" - assert created is False - - assert mock_get.call_count == 2 - assert mock_get.call_args_list[0][1]["filter_by"] == {"google_id": "123456789"} - assert mock_get.call_args_list[1][1]["filter_by"] == {"email": "user@example.com"} - - mock_update.assert_called_once() - assert mock_update.call_args[1]["object_id"] == 1 - assert mock_update.call_args[1]["object"]["google_id"] == "123456789" - assert "oauth_updated_at" in mock_update.call_args[1]["object"] - - -@pytest.mark.asyncio -async def test_get_or_create_user_new_user(oauth_service, mock_db, google_user_info): - """Test creating a new user when none exists.""" - new_user = { - "id": 1, - "username": "testuser", - "email": "user@example.com", - "google_id": "123456789", - "oauth_provider": "google", - } - - with ( - patch.object(crud_users, "get", new_callable=AsyncMock) as mock_get, - patch.object(crud_users, "exists", new_callable=AsyncMock) as mock_exists, - patch.object(crud_users, "create", new_callable=AsyncMock) as mock_create, - patch("secrets.token_urlsafe", return_value="random_password"), - ): - mock_get.return_value = None - mock_exists.return_value = False - mock_create.return_value = new_user - - user, created = await oauth_service.get_or_create_user(google_user_info, mock_db) - - assert user == new_user - assert created is True - - assert mock_get.call_count == 2 - - mock_exists.assert_called_once() - - mock_create.assert_called_once() - create_args = mock_create.call_args[1]["object"] - assert create_args.email == "user@example.com" - assert create_args.name == "Test User" - assert create_args.username.startswith("test") - assert create_args.email_verified is True - assert create_args.google_id == "123456789" - assert create_args.oauth_provider == "google" - - -@pytest.mark.asyncio -async def test_get_or_create_user_username_conflict(oauth_service, mock_db, google_user_info): - """Test username generation with conflicts.""" - new_user = { - "id": 1, - "username": "test1", - "email": "user@example.com", - "google_id": "123456789", - } - - with ( - patch.object(crud_users, "get", new_callable=AsyncMock) as mock_get, - patch.object(crud_users, "exists", new_callable=AsyncMock) as mock_exists, - patch.object(crud_users, "create", new_callable=AsyncMock) as mock_create, - patch("secrets.token_urlsafe", return_value="random_password"), - ): - mock_get.return_value = None - - mock_exists.side_effect = [True, False] - - mock_create.return_value = new_user - - user, created = await oauth_service.get_or_create_user(google_user_info, mock_db) - - assert user == new_user - assert created is True - - assert mock_exists.call_count == 2 - - mock_create.assert_called_once() - create_args = mock_create.call_args[1]["object"] - assert create_args.username == "test1" - - -@pytest.mark.asyncio -async def test_create_user_from_oauth_no_email(oauth_service, mock_db, google_user_info): - """Test that creating a user without email raises ValueError.""" - user_info_no_email = OAuthUserInfo( - provider="google", - provider_user_id="123456789", - email=None, - email_verified=False, - name="Test User", - given_name="Test", - family_name="User", - username=None, - picture="https://example.com/photo.jpg", - raw_data={}, - ) - - with pytest.raises(ValueError, match="Email is required for user creation"): - await oauth_service._create_user_from_oauth(user_info_no_email, mock_db) - - -@pytest.mark.asyncio -async def test_create_user_from_oauth_with_username(oauth_service, mock_db): - """Test creating a user with a pre-existing username.""" - user_info = OAuthUserInfo( - provider="github", - provider_user_id="987654321", - email="user@example.com", - email_verified=True, - name="Test User", - given_name=None, - family_name=None, - username="testuser", - picture="https://example.com/photo.jpg", - raw_data={}, - ) - - new_user = { - "id": 1, - "username": "testuser", - "email": "user@example.com", - "github_id": "987654321", - } - - with ( - patch.object(crud_users, "exists", new_callable=AsyncMock) as mock_exists, - patch.object(crud_users, "create", new_callable=AsyncMock) as mock_create, - patch("secrets.token_urlsafe", return_value="random_password"), - ): - mock_exists.return_value = False - mock_create.return_value = new_user - - user, created = await oauth_service._create_user_from_oauth(user_info, mock_db) - - assert user == new_user - assert created is True - - mock_exists.assert_called_once() - assert mock_exists.call_args[1]["filter_by"] == {"username": "testuser"} - - mock_create.assert_called_once() - create_args = mock_create.call_args[1]["object"] - assert create_args.username == "testuser" - assert create_args.email == "user@example.com" - assert create_args.github_id == "987654321" - assert create_args.oauth_provider == "github" - - -@pytest.mark.asyncio -async def test_oauth_user_creation_uses_hashed_password(oauth_service, mock_db, google_user_info): - """OAuth user creation must use UserCreateInternal with a bcrypt-hashed password.""" - new_user = {"id": 1, "username": "test", "email": "user@example.com", "google_id": "123456789"} - - with ( - patch.object(crud_users, "get", new_callable=AsyncMock) as mock_get, - patch.object(crud_users, "exists", new_callable=AsyncMock) as mock_exists, - patch.object(crud_users, "create", new_callable=AsyncMock) as mock_create, - ): - mock_get.return_value = None - mock_exists.return_value = False - mock_create.return_value = new_user - - await oauth_service.get_or_create_user(google_user_info, mock_db) - - create_args = mock_create.call_args[1]["object"] - - # Must use UserCreateInternal, not UserCreate - assert isinstance(create_args, UserCreateInternal), f"Expected UserCreateInternal, got {type(create_args).__name__}" - - # Must have hashed_password, not password - assert hasattr(create_args, "hashed_password"), "Missing hashed_password field" - assert not hasattr(create_args, "password"), "Should not have plaintext password field" - - # Must be a bcrypt hash (starts with $2b$) - assert create_args.hashed_password.startswith("$2b$"), ( - f"Expected bcrypt hash, got: {create_args.hashed_password[:20]}..." - ) diff --git a/backend/tests/unit/infrastructure/auth/session/__init__.py b/backend/tests/unit/infrastructure/auth/session/__init__.py deleted file mode 100644 index c7b728dd..00000000 --- a/backend/tests/unit/infrastructure/auth/session/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Session auth tests.""" diff --git a/backend/tests/unit/infrastructure/auth/session/backends/__init__.py b/backend/tests/unit/infrastructure/auth/session/backends/__init__.py deleted file mode 100644 index f7a6aefc..00000000 --- a/backend/tests/unit/infrastructure/auth/session/backends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Session backends tests.""" diff --git a/backend/tests/unit/infrastructure/auth/session/backends/test_memcached.py b/backend/tests/unit/infrastructure/auth/session/backends/test_memcached.py deleted file mode 100644 index adf32f73..00000000 --- a/backend/tests/unit/infrastructure/auth/session/backends/test_memcached.py +++ /dev/null @@ -1,221 +0,0 @@ -import hashlib -import json -from unittest.mock import AsyncMock, patch - -import pytest -from pydantic import BaseModel - -from src.infrastructure.auth.session.backends.memcached import MemcachedSessionStorage - - -class MemcachedTestSessionData(BaseModel): - """Test data model for session testing.""" - - user_id: int - session_id: str - is_active: bool = True - metadata: dict = {} - - -@pytest.fixture -def mock_memcached(): - """Create a mock Memcached client.""" - memcached_mock = AsyncMock() - memcached_mock.get = AsyncMock() - memcached_mock.set = AsyncMock() - memcached_mock.delete = AsyncMock() - return memcached_mock - - -@pytest.fixture -def memcached_storage(mock_memcached): - """Create a Memcached session storage instance with a mock client.""" - with patch("src.infrastructure.auth.session.backends.memcached.aiomcache.Client", return_value=mock_memcached): - storage: MemcachedSessionStorage[MemcachedTestSessionData] = MemcachedSessionStorage( - prefix="test_session:", expiration=1800 - ) - storage.client = mock_memcached - return storage - - -def encode_key(key): - """Helper function to encode a key the same way the storage class does.""" - if len(key) > 240: - key_hash = hashlib.sha256(key.encode()).hexdigest()[:32] - key = f"{key[:200]}:{key_hash}" - return key.encode("utf-8") - - -@pytest.mark.asyncio -async def test_create_session(memcached_storage, mock_memcached): - """Test creating a new session.""" - session_id = "test-session-id" - test_data = MemcachedTestSessionData(user_id=1, session_id=session_id) - - mock_memcached.set.return_value = True - mock_memcached.get.return_value = None - - result = await memcached_storage.create(test_data, session_id=session_id) - - assert result == session_id - assert mock_memcached.set.call_count == 2 - - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - assert mock_memcached.set.call_args_list[0][0][0] == encoded_key - - -@pytest.mark.asyncio -async def test_get_session(memcached_storage, mock_memcached): - """Test retrieving a session by ID.""" - session_id = "test-session-id" - test_data = MemcachedTestSessionData(user_id=1, session_id=session_id) - - encoded_data = test_data.model_dump_json().encode("utf-8") - mock_memcached.get.return_value = encoded_data - - result = await memcached_storage.get(session_id, MemcachedTestSessionData) - - assert result is not None - assert result.user_id == test_data.user_id - assert result.session_id == test_data.session_id - - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - mock_memcached.get.assert_called_once_with(encoded_key) - - -@pytest.mark.asyncio -async def test_get_session_not_found(memcached_storage, mock_memcached): - """Test retrieving a non-existent session.""" - session_id = "nonexistent-session-id" - - mock_memcached.get.return_value = None - result = await memcached_storage.get(session_id, MemcachedTestSessionData) - assert result is None - - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - mock_memcached.get.assert_called_once_with(encoded_key) - - -@pytest.mark.asyncio -async def test_update_session(memcached_storage, mock_memcached): - """Test updating an existing session.""" - session_id = "test-session-id" - test_data = MemcachedTestSessionData(user_id=1, session_id=session_id) - - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - - user_sessions_key = memcached_storage.get_user_sessions_key(1) - encoded_user_key = encode_key(user_sessions_key) - - def mock_get_side_effect(key): - if key == encoded_key: - return b"existing_data" - elif key == encoded_user_key: - return json.dumps(["session1", session_id]).encode("utf-8") - return None - - mock_memcached.get.side_effect = mock_get_side_effect - mock_memcached.set.return_value = True - - result = await memcached_storage.update(session_id, test_data) - - assert result is True - assert mock_memcached.get.call_count == 2 - assert encoded_key in [call_args[0][0] for call_args in mock_memcached.get.call_args_list] - assert encoded_user_key in [call_args[0][0] for call_args in mock_memcached.get.call_args_list] - - -@pytest.mark.asyncio -async def test_delete_session(memcached_storage, mock_memcached): - """Test deleting a session.""" - session_id = "test-session-id" - test_data = MemcachedTestSessionData(user_id=1, session_id=session_id) - - encoded_data = test_data.model_dump_json().encode("utf-8") - mock_memcached.get.return_value = encoded_data - - user_sessions = [session_id, "other-session"] - encoded_user_sessions = json.dumps(user_sessions).encode("utf-8") - - mock_memcached.get.side_effect = lambda key: ( - encoded_data if encode_key(memcached_storage.get_key(session_id)) == key else encoded_user_sessions - ) - - result = await memcached_storage.delete(session_id) - assert result is True - - assert mock_memcached.delete.call_count == 1 - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - mock_memcached.delete.assert_called_once_with(encoded_key) - - -@pytest.mark.asyncio -async def test_extend_session(memcached_storage, mock_memcached): - """Test extending the expiration of a session.""" - session_id = "test-session-id" - test_data = MemcachedTestSessionData(user_id=1, session_id=session_id) - encoded_data = test_data.model_dump_json().encode("utf-8") - - session_key = memcached_storage.get_key(session_id) - encoded_key = encode_key(session_key) - user_sessions_key = memcached_storage.get_user_sessions_key(1) - encoded_user_key = encode_key(user_sessions_key) - - def mock_get_side_effect(key): - if key == encoded_key: - return encoded_data - elif key == encoded_user_key: - return json.dumps(["session1", session_id]).encode("utf-8") - return None - - mock_memcached.get.side_effect = mock_get_side_effect - mock_memcached.set.return_value = True - - result = await memcached_storage.extend(session_id) - assert result is True - - assert mock_memcached.get.call_count == 2 - assert encoded_key in [call_args[0][0] for call_args in mock_memcached.get.call_args_list] - assert encoded_user_key in [call_args[0][0] for call_args in mock_memcached.get.call_args_list] - - assert mock_memcached.set.call_count >= 2 - session_set_calls = [c for c in mock_memcached.set.call_args_list if c[0][0] == encoded_key] - assert len(session_set_calls) == 1 - assert session_set_calls[0][1]["exptime"] == 1800 # Default expiration - - -@pytest.mark.asyncio -async def test_exists_session(memcached_storage, mock_memcached): - """Test checking if a session exists.""" - session_id = "test-session-id" - - mock_memcached.get.return_value = b"some_data" - result = await memcached_storage.exists(session_id) - assert result is True - - mock_memcached.get.return_value = None - result = await memcached_storage.exists(session_id) - assert result is False - - -@pytest.mark.asyncio -async def test_get_user_sessions(memcached_storage, mock_memcached): - """Test retrieving all sessions for a user.""" - user_id = 1 - session_ids = ["session1", "session2", "session3"] - - encoded_data = json.dumps(session_ids).encode("utf-8") - mock_memcached.get.return_value = encoded_data - - result = await memcached_storage.get_user_sessions(user_id) - - assert result == session_ids - - user_sessions_key = memcached_storage.get_user_sessions_key(user_id) - encoded_key = encode_key(user_sessions_key) - mock_memcached.get.assert_called_once_with(encoded_key) diff --git a/backend/tests/unit/infrastructure/auth/session/backends/test_redis.py b/backend/tests/unit/infrastructure/auth/session/backends/test_redis.py deleted file mode 100644 index db88bca1..00000000 --- a/backend/tests/unit/infrastructure/auth/session/backends/test_redis.py +++ /dev/null @@ -1,262 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from pydantic import BaseModel - -from src.infrastructure.auth.session.backends.redis import RedisSessionStorage - - -class RedisTestSessionData(BaseModel): - """Test data model for session testing.""" - - user_id: int - session_id: str - is_active: bool = True - metadata: dict = {} - - -@pytest.fixture -def mock_pipeline(): - """Create a mock Redis pipeline.""" - pipeline = MagicMock() - pipeline.set = MagicMock(return_value=pipeline) - pipeline.delete = MagicMock(return_value=pipeline) - pipeline.sadd = MagicMock(return_value=pipeline) - pipeline.srem = MagicMock(return_value=pipeline) - pipeline.expire = MagicMock(return_value=pipeline) - pipeline.execute = AsyncMock(return_value=[True, True]) - return pipeline - - -@pytest.fixture -def mock_redis(mock_pipeline): - """Create a mock Redis client with a non-coroutine pipeline.""" - redis_mock = AsyncMock() - redis_mock.get = AsyncMock() - redis_mock.set = AsyncMock() - redis_mock.delete = AsyncMock() - redis_mock.sadd = AsyncMock() - redis_mock.srem = AsyncMock() - redis_mock.smembers = AsyncMock() - redis_mock.expire = AsyncMock() - redis_mock.exists = AsyncMock() - redis_mock.ttl = AsyncMock(return_value=1000) - redis_mock.pipeline = MagicMock(return_value=mock_pipeline) - - return redis_mock - - -@pytest.fixture -def redis_storage(mock_redis): - """Create a Redis session storage instance with a mock Redis client.""" - with patch("src.infrastructure.auth.session.backends.redis.AsyncRedis", return_value=mock_redis): - storage: RedisSessionStorage[RedisTestSessionData] = RedisSessionStorage(prefix="test_session:", expiration=1800) - storage.client = mock_redis - return storage - - -@pytest.fixture -def fake_redis(): - """Create a Redis storage with a more complete mock for the delete_pattern test.""" - redis_mock = AsyncMock() - redis_mock.get = AsyncMock(return_value="{}") - redis_mock.set = AsyncMock() - redis_mock.delete = AsyncMock(return_value=1) - redis_mock.exists = AsyncMock(return_value=0) - redis_mock.scan_iter = AsyncMock() - redis_mock.keys = AsyncMock( - return_value=["test_session:1", "test_session:2", "test_session:3", "test_session:4", "test_session:5"] - ) - - # Set up pipeline for create - pipeline_mock = MagicMock() - pipeline_mock.set = MagicMock(return_value=pipeline_mock) - pipeline_mock.sadd = MagicMock(return_value=pipeline_mock) - pipeline_mock.expire = MagicMock(return_value=pipeline_mock) - pipeline_mock.delete = MagicMock(return_value=pipeline_mock) - pipeline_mock.execute = AsyncMock(return_value=[True, True, True]) - - redis_mock.pipeline = MagicMock(return_value=pipeline_mock) - - # Configure scan_iter to yield login keys - async def mock_scan_iter(**kwargs): - if kwargs.get("match") == "login:*": - for i in range(3): - yield f"login:user:test{i}" - # In async functions we can't use 'yield from' - - redis_mock.scan_iter.side_effect = mock_scan_iter - - # Create storage with the mock - with patch("src.infrastructure.auth.session.backends.redis.Redis", return_value=redis_mock): - storage: RedisSessionStorage = RedisSessionStorage() - storage.client = redis_mock - return storage - - -@pytest.mark.asyncio -async def test_create_session(redis_storage, mock_redis, mock_pipeline): - """Test creating a new session.""" - session_id = "test-session-id" - test_data = RedisTestSessionData(user_id=1, session_id=session_id) - - mock_pipeline.execute.return_value = [True, True, True] - - result = await redis_storage.create(test_data, session_id=session_id) - - assert result == session_id - - mock_pipeline.set.assert_called() - mock_pipeline.sadd.assert_called() - mock_pipeline.expire.assert_called() - mock_pipeline.execute.assert_called_once() - - -@pytest.mark.asyncio -async def test_get_session(redis_storage, mock_redis): - """Test retrieving a session by ID.""" - session_id = "test-session-id" - test_data = RedisTestSessionData(user_id=1, session_id=session_id) - - mock_redis.get.return_value = test_data.model_dump_json() - result = await redis_storage.get(session_id, RedisTestSessionData) - - assert result is not None - assert result.user_id == test_data.user_id - assert result.session_id == test_data.session_id - - mock_redis.get.assert_called_once_with(f"test_session:{session_id}") - - -@pytest.mark.asyncio -async def test_get_session_not_found(redis_storage, mock_redis): - """Test retrieving a non-existent session.""" - session_id = "nonexistent-session-id" - - mock_redis.get.return_value = None - - result = await redis_storage.get(session_id, RedisTestSessionData) - assert result is None - - mock_redis.get.assert_called_once_with(f"test_session:{session_id}") - - -@pytest.mark.asyncio -async def test_update_session(redis_storage, mock_redis, mock_pipeline): - """Test updating an existing session.""" - session_id = "test-session-id" - test_data = RedisTestSessionData(user_id=1, session_id=session_id) - - mock_redis.exists.return_value = True - mock_pipeline.execute.return_value = [True, True] - - result = await redis_storage.update(session_id, test_data) - assert result is True - - mock_redis.exists.assert_called_once() - mock_pipeline.set.assert_called() - mock_pipeline.expire.assert_called() - mock_pipeline.execute.assert_called_once() - - -@pytest.mark.asyncio -async def test_delete_session(redis_storage, mock_redis, mock_pipeline): - """Test deleting a session.""" - session_id = "test-session-id" - test_data = RedisTestSessionData(user_id=1, session_id=session_id) - - mock_redis.get.return_value = test_data.model_dump_json() - mock_pipeline.execute.return_value = [1, 1] - - result = await redis_storage.delete(session_id) - assert result is True - - mock_redis.get.assert_called_once() - mock_pipeline.delete.assert_called() - mock_pipeline.srem.assert_called() - mock_pipeline.execute.assert_called_once() - - -@pytest.mark.asyncio -async def test_extend_session(redis_storage, mock_redis, mock_pipeline): - """Test extending the expiration of a session.""" - session_id = "test-session-id" - test_data = RedisTestSessionData(user_id=1, session_id=session_id) - - mock_redis.get.return_value = test_data.model_dump_json() - mock_pipeline.execute.return_value = [True, True] - - result = await redis_storage.extend(session_id) - assert result is True - - mock_redis.get.assert_called_once() - mock_pipeline.expire.assert_called() - mock_pipeline.execute.assert_called_once() - - -@pytest.mark.asyncio -async def test_exists_session(redis_storage, mock_redis): - """Test checking if a session exists.""" - session_id = "test-session-id" - - mock_redis.exists.return_value = True - - result = await redis_storage.exists(session_id) - assert result is True - - mock_redis.exists.assert_called_once_with(f"test_session:{session_id}") - - -@pytest.mark.asyncio -async def test_get_user_sessions(redis_storage, mock_redis): - """Test retrieving all sessions for a user.""" - user_id = 1 - session_ids = ["session1", "session2", "session3"] - - mock_redis.smembers.return_value = session_ids - - result = await redis_storage.get_user_sessions(user_id) - assert result == session_ids - - mock_redis.smembers.assert_called_once_with(f"{redis_storage.user_sessions_prefix}{user_id}") - - -@pytest.mark.asyncio -async def test_delete_pattern(mock_redis): - """Test deleting keys matching a pattern from Redis.""" - storage: RedisSessionStorage = RedisSessionStorage() - storage.client = mock_redis - - login_keys = [f"login:user:test{i}".encode() for i in range(3)] - - class AsyncIterator: - def __init__(self, items): - self.items = items - self.index = 0 - - def __aiter__(self): - return self - - async def __anext__(self): - if self.index >= len(self.items): - raise StopAsyncIteration - item = self.items[self.index] - self.index += 1 - return item - - mock_redis.scan_iter = MagicMock(return_value=AsyncIterator(login_keys)) - - mock_pipeline = MagicMock() - mock_pipeline.delete = MagicMock(return_value=mock_pipeline) - mock_pipeline.execute = AsyncMock(return_value=[1, 1, 1]) - mock_redis.pipeline = MagicMock(return_value=mock_pipeline) - - deleted_count = await storage.delete_pattern("login:*") - - mock_redis.scan_iter.assert_called_once_with(match="login:*") - - mock_redis.pipeline.assert_called_once() - assert mock_pipeline.delete.call_count == 3 - mock_pipeline.execute.assert_called_once() - - assert deleted_count == 3 diff --git a/backend/tests/unit/infrastructure/auth/session/test_api.py b/backend/tests/unit/infrastructure/auth/session/test_api.py deleted file mode 100644 index dd77f11f..00000000 --- a/backend/tests/unit/infrastructure/auth/session/test_api.py +++ /dev/null @@ -1,284 +0,0 @@ -from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import Request - -from src.infrastructure.auth.session.dependencies import ( - get_current_session_data, - get_current_user, - get_session_from_cookie, - get_session_manager, - verify_csrf_token, -) -from src.infrastructure.auth.session.manager import SessionManager -from src.infrastructure.auth.session.schemas import SessionData -from src.infrastructure.database.session import async_session -from src.interfaces.main import app - - -@pytest.mark.asyncio -async def test_login_endpoint_success(client, test_user, db_session, monkeypatch): - """Test successful login with session authentication.""" - mock_manager = MagicMock(spec=SessionManager) - mock_manager.create_session = AsyncMock(return_value=("test-session-id", "test-csrf-token")) - mock_manager.set_session_cookies = MagicMock() - mock_manager.track_login_attempt = AsyncMock(return_value=(True, 5)) - - app.dependency_overrides[get_session_manager] = lambda: mock_manager - - try: - response = await client.post( - "/api/v1/auth/login", - data={ - "username": test_user["username"], - "password": "Password123!", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert "csrf_token" in data - assert data["csrf_token"] == "test-csrf-token" - - mock_manager.create_session.assert_called_once() - mock_manager.set_session_cookies.assert_called_once() - - create_args = mock_manager.create_session.call_args - assert create_args[1]["user_id"] == test_user["id"] - finally: - if get_session_manager in app.dependency_overrides: - del app.dependency_overrides[get_session_manager] - - -@pytest.mark.asyncio -async def test_login_endpoint_invalid_credentials(client, test_user, db_session, monkeypatch): - """Test login with invalid credentials.""" - response = await client.post( - "/api/v1/auth/login", - data={ - "username": test_user["username"], - "password": "WrongPassword!", - }, - ) - - assert response.status_code == 401 - data = response.json() - assert "detail" in data - assert "Incorrect username or password" in data["detail"] - - -@pytest.mark.asyncio -async def test_logout_endpoint(client, test_user, db_session, monkeypatch): - """Test logout endpoint.""" - session_id = "test-session-id" - session_data = SessionData( - session_id=session_id, - user_id=test_user["id"], - is_active=True, - ip_address="127.0.0.1", - user_agent="test-agent", - device_info={}, - last_activity=datetime.now(UTC), - metadata={}, - ) - - mock_manager = MagicMock(spec=SessionManager) - mock_manager.terminate_session = AsyncMock(return_value=True) - mock_manager.clear_session_cookies = MagicMock() - - async def mock_get_current_session_data(request: Request, session_id=None, session_manager=None): - return session_data - - app.dependency_overrides[get_session_manager] = lambda: mock_manager - app.dependency_overrides[get_current_session_data] = mock_get_current_session_data - - try: - response = await client.post("/api/v1/auth/logout") - - print(f"Status Code: {response.status_code}") - print(f"Response Body: {response.text}") - - assert response.status_code == 200 - data = response.json() - assert "message" in data - assert data["message"] == "Logged out successfully" - - mock_manager.terminate_session.assert_called_once_with(session_id) - mock_manager.clear_session_cookies.assert_called_once() - finally: - if get_session_manager in app.dependency_overrides: - del app.dependency_overrides[get_session_manager] - if get_current_session_data in app.dependency_overrides: - del app.dependency_overrides[get_current_session_data] - - -@pytest.mark.asyncio -async def test_refresh_csrf_token_endpoint(client, test_user, db_session, monkeypatch): - """Test refreshing the CSRF token.""" - session_id = "test-session-id" - session_data = SessionData( - session_id=session_id, - user_id=test_user["id"], - is_active=True, - ip_address="127.0.0.1", - user_agent="test-agent", - device_info={}, - last_activity=datetime.now(UTC), - metadata={}, - ) - - mock_manager = MagicMock(spec=SessionManager) - mock_manager.regenerate_csrf_token = AsyncMock(return_value="new-csrf-token") - mock_manager.session_timeout = timedelta(minutes=30) # Add session_timeout attribute - - async def mock_get_current_session_data(request: Request, session_id=None, session_manager=None): - return session_data - - app.dependency_overrides[get_session_manager] = lambda: mock_manager - app.dependency_overrides[get_current_session_data] = mock_get_current_session_data - - try: - response = await client.post("/api/v1/auth/refresh-csrf") - - print(f"Status Code: {response.status_code}") - print(f"Response Body: {response.text}") - - assert response.status_code == 200 - data = response.json() - assert "csrf_token" in data - assert data["csrf_token"] == "new-csrf-token" - - mock_manager.regenerate_csrf_token.assert_called_once_with( - user_id=test_user["id"], - session_id=session_id, - ) - finally: - if get_session_manager in app.dependency_overrides: - del app.dependency_overrides[get_session_manager] - if get_current_session_data in app.dependency_overrides: - del app.dependency_overrides[get_current_session_data] - - -@pytest.mark.asyncio -async def test_unauthorized_access(client, test_user, db_session): - """Test accessing protected endpoint without authentication.""" - response = await client.get("/api/v1/users/me") - - assert response.status_code == 401 - data = response.json() - assert "detail" in data - assert "Not authenticated" in data["detail"] - - -@pytest.mark.asyncio -async def test_protected_endpoint_access(client, test_user, db_session, monkeypatch): - """Test accessing protected endpoint with valid session.""" - test_user_with_image = test_user.copy() - test_user_with_image["profile_image_url"] = "https://example.com/default-avatar.png" - - app.dependency_overrides[get_current_user] = lambda: test_user_with_image - app.dependency_overrides[async_session] = lambda: db_session - app.dependency_overrides[get_session_from_cookie] = lambda request: None - app.dependency_overrides[verify_csrf_token] = lambda request, session_data=None: None - - try: - response = await client.get("/api/v1/users/me") - - print(f"Status Code: {response.status_code}") - print(f"Response Body: {response.text}") - - assert response.status_code == 200 - user_data = response.json() - assert user_data["id"] == test_user["id"] - assert user_data["username"] == test_user["username"] - assert user_data["profile_image_url"] == test_user_with_image["profile_image_url"] - finally: - if get_current_user in app.dependency_overrides: - del app.dependency_overrides[get_current_user] - if async_session in app.dependency_overrides: - del app.dependency_overrides[async_session] - if get_session_from_cookie in app.dependency_overrides: - del app.dependency_overrides[get_session_from_cookie] - if verify_csrf_token in app.dependency_overrides: - del app.dependency_overrides[verify_csrf_token] - - -@pytest.mark.asyncio -async def test_csrf_protection(client, test_user, db_session): - """Test CSRF protection for mutation operations.""" - session_data = SessionData( - session_id="test-session-id", - user_id=test_user["id"], - is_active=True, - ip_address="127.0.0.1", - user_agent="test-agent", - device_info={}, - last_activity=datetime.now(UTC), - metadata={}, - ) - - with ( - patch("src.infrastructure.auth.session.dependencies.get_session_from_cookie", return_value=session_data), - patch( - "src.infrastructure.auth.session.dependencies.verify_csrf_token", side_effect=Exception("CSRF validation failed") - ), - ): - response = await client.patch( - f"/api/v1/users/{test_user['username']}", - json={"name": "Updated Name"}, - ) - - assert response.status_code in (401, 403) - - -@pytest.mark.asyncio -async def test_login_endpoint_with_rate_limiting(client, test_user, db_session, monkeypatch): - """Test that the login endpoint correctly applies rate limiting.""" - mock_manager = MagicMock(spec=SessionManager) - - mock_manager.track_login_attempt = AsyncMock(return_value=(True, 4)) - mock_manager.create_session = AsyncMock(return_value=("test-session-id", "test-csrf-token")) - mock_manager.set_session_cookies = MagicMock() - - app.dependency_overrides[get_session_manager] = lambda: mock_manager - - try: - response = await client.post( - "/api/v1/auth/login", - data={ - "username": test_user["username"], - "password": "Password123!", - }, - ) - - assert response.status_code == 200 - data = response.json() - assert "csrf_token" in data - assert data["csrf_token"] == "test-csrf-token" - - mock_manager.track_login_attempt.assert_any_call(ip_address="127.0.0.1", username=test_user["username"], success=False) - mock_manager.track_login_attempt.assert_any_call(ip_address="127.0.0.1", username=test_user["username"], success=True) - - mock_manager.track_login_attempt.reset_mock() - mock_manager.track_login_attempt = AsyncMock(return_value=(False, 0)) - - response = await client.post( - "/api/v1/auth/login", - data={ - "username": test_user["username"], - "password": "Password123!", - }, - ) - - assert response.status_code == 401 - - mock_manager.track_login_attempt.assert_called_once_with( - ip_address="127.0.0.1", username=test_user["username"], success=False - ) - - assert mock_manager.create_session.call_count == 1 - - finally: - if get_session_manager in app.dependency_overrides: - del app.dependency_overrides[get_session_manager] diff --git a/backend/tests/unit/infrastructure/auth/session/test_dependencies.py b/backend/tests/unit/infrastructure/auth/session/test_dependencies.py deleted file mode 100644 index a22c3193..00000000 --- a/backend/tests/unit/infrastructure/auth/session/test_dependencies.py +++ /dev/null @@ -1,156 +0,0 @@ -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest -from fastapi import HTTPException, Request, status - -from src.infrastructure.auth.http_exceptions import CSRFException -from src.infrastructure.auth.session.manager import SessionManager -from src.infrastructure.auth.session.schemas import SessionData - - -async def mock_get_session_from_cookie(request, session_manager): - session_id = request.cookies.get("session_id") - if not session_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - ) - - session_data = await session_manager.validate_session(session_id) - if not session_data: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired session", - ) - - return session_data - - -async def mock_verify_csrf_token(request, session_data, session_manager): - if request.method in ("GET", "HEAD", "OPTIONS"): - return - - if not session_data: - return - - token = request.headers.get("X-CSRF-Token") - if not token: - raise CSRFException("CSRF token missing") - - is_valid = await session_manager.validate_csrf_token(session_data.session_id, token) - if not is_valid: - raise CSRFException("Invalid CSRF token") - - -@pytest.fixture -def mock_request(): - request = MagicMock(spec=Request) - request.cookies = {"session_id": "test-session-id"} - request.headers = {"X-CSRF-Token": "test-csrf-token"} - request.method = "POST" - return request - - -@pytest.fixture -def mock_session_data(): - return SessionData( - session_id="test-session-id", - user_id=1, - is_active=True, - ip_address="127.0.0.1", - user_agent="test-agent", - device_info={}, - last_activity=datetime.now(UTC), - metadata={}, - ) - - -@pytest.fixture -def mock_session_manager(): - session_manager = MagicMock(spec=SessionManager) - session_manager.validate_session = AsyncMock() - session_manager.validate_csrf_token = AsyncMock() - session_manager.cleanup_expired_sessions = AsyncMock() - return session_manager - - -@pytest.mark.asyncio -async def test_get_session_from_cookie(mock_request, mock_session_data, mock_session_manager): - """Test getting session data from a cookie.""" - mock_session_manager.validate_session.return_value = mock_session_data - - result = await mock_get_session_from_cookie(mock_request, mock_session_manager) - - assert result == mock_session_data - mock_session_manager.validate_session.assert_called_once_with("test-session-id") - - -@pytest.mark.asyncio -async def test_get_session_from_cookie_no_cookie(mock_request, mock_session_manager): - """Test when no session cookie is present.""" - mock_request.cookies = {} - - with pytest.raises(HTTPException) as exc_info: - await mock_get_session_from_cookie(mock_request, mock_session_manager) - - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert "Not authenticated" in exc_info.value.detail - - -@pytest.mark.asyncio -async def test_get_session_from_cookie_invalid_session(mock_request, mock_session_manager): - """Test when session is invalid or expired.""" - mock_session_manager.validate_session.return_value = None - - with pytest.raises(HTTPException) as exc_info: - await mock_get_session_from_cookie(mock_request, mock_session_manager) - - assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert "Invalid or expired session" in exc_info.value.detail - mock_session_manager.validate_session.assert_called_once_with("test-session-id") - - -@pytest.mark.asyncio -async def test_verify_csrf_token_valid(mock_request, mock_session_data, mock_session_manager): - """Test verifying a valid CSRF token.""" - mock_session_manager.validate_csrf_token.return_value = True - - await mock_verify_csrf_token( - request=mock_request, - session_data=mock_session_data, - session_manager=mock_session_manager, - ) - - mock_session_manager.validate_csrf_token.assert_called_once_with("test-session-id", "test-csrf-token") - - -@pytest.mark.asyncio -async def test_verify_csrf_token_missing(mock_request, mock_session_data, mock_session_manager): - """Test when CSRF token is missing.""" - mock_request.headers = {} - - with pytest.raises(CSRFException) as exc_info: - await mock_verify_csrf_token( - request=mock_request, - session_data=mock_session_data, - session_manager=mock_session_manager, - ) - - assert "CSRF token missing" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_verify_csrf_token_invalid(mock_request, mock_session_data, mock_session_manager): - """Test when CSRF token is invalid.""" - mock_session_manager.validate_csrf_token.return_value = False - - with pytest.raises(CSRFException) as exc_info: - await mock_verify_csrf_token( - request=mock_request, - session_data=mock_session_data, - session_manager=mock_session_manager, - ) - - assert "Invalid CSRF token" in str(exc_info.value) - mock_session_manager.validate_csrf_token.assert_called_once_with("test-session-id", "test-csrf-token") diff --git a/backend/tests/unit/infrastructure/auth/session/test_manager.py b/backend/tests/unit/infrastructure/auth/session/test_manager.py deleted file mode 100644 index e8e93c0c..00000000 --- a/backend/tests/unit/infrastructure/auth/session/test_manager.py +++ /dev/null @@ -1,398 +0,0 @@ -from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import Response - -from src.infrastructure.auth.session.manager import SessionManager -from src.infrastructure.auth.session.schemas import CSRFToken, SessionData, UserAgentInfo - - -@pytest.fixture -def mock_storage(): - """Create a mock session storage.""" - storage = AsyncMock() - storage.create = AsyncMock() - storage.get = AsyncMock() - storage.update = AsyncMock() - storage.delete = AsyncMock() - storage.extend = AsyncMock() - storage.exists = AsyncMock() - storage.get_user_sessions = AsyncMock(return_value=[]) - return storage - - -@pytest.fixture -def mock_csrf_storage(): - """Create a mock CSRF token storage.""" - storage = AsyncMock() - storage.create = AsyncMock() - storage.get = AsyncMock() - storage.delete = AsyncMock() - return storage - - -@pytest.fixture -def session_manager(mock_storage, mock_csrf_storage): - """Create a session manager with mock storage.""" - with ( - patch("src.infrastructure.auth.session.manager.get_session_storage", return_value=mock_storage), - patch("src.infrastructure.auth.session.storage.get_session_storage", return_value=mock_csrf_storage), - ): - manager = SessionManager(session_storage=mock_storage) - manager.csrf_storage = mock_csrf_storage - return manager - - -@pytest.fixture -def mock_request(): - """Create a mock request for session creation.""" - request = MagicMock() - request.client.host = "127.0.0.1" - request.headers = { - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0.4472.124 Safari/537.36", - "x-forwarded-for": "192.168.1.1", - } - return request - - -@pytest.fixture -def mock_response(): - """Create a mock response for cookie handling.""" - response = MagicMock(spec=Response) - response.set_cookie = MagicMock() - response.delete_cookie = MagicMock() - return response - - -@pytest.mark.asyncio -async def test_create_session(session_manager, mock_storage, mock_csrf_storage, mock_request): - """Test creating a new session with CSRF token.""" - user_id = 1 - session_id = "test-session-id" - csrf_token = "test-csrf-token" - - mock_storage.create.return_value = session_id - - with patch.object(session_manager, "_generate_csrf_token", return_value=csrf_token) as mock_gen: - result_session_id, result_csrf_token = await session_manager.create_session( - request=mock_request, - user_id=user_id, - ) - - assert result_session_id == session_id - assert result_csrf_token == csrf_token - - mock_storage.create.assert_called_once() - mock_gen.assert_called_once() - - create_args = mock_storage.create.call_args[0][0] - assert create_args.user_id == user_id - assert create_args.ip_address == "192.168.1.1" - - -@pytest.mark.asyncio -async def test_validate_session_valid(session_manager, mock_storage): - """Test validating a valid, non-expired session.""" - session_id = "test-session-id" - - current_time = datetime.now(UTC) - session_data = SessionData( - session_id=session_id, - user_id=1, - is_active=True, - ip_address="127.0.0.1", - user_agent="test_agent", - device_info={}, - last_activity=current_time - timedelta(minutes=5), - metadata={}, - ) - - mock_storage.get.return_value = session_data - mock_storage.update.return_value = True - - result = await session_manager.validate_session(session_id) - - assert result is not None - assert result.session_id == session_id - assert result.user_id == 1 - - mock_storage.get.assert_called_once_with(session_id, SessionData) - mock_storage.update.assert_called_once() - - -@pytest.mark.asyncio -async def test_validate_session_expired(session_manager, mock_storage): - """Test validating an expired session.""" - session_id = "test-session-id" - - current_time = datetime.now(UTC) - session_data = SessionData( - session_id=session_id, - user_id=1, - is_active=True, - ip_address="127.0.0.1", - user_agent="test_agent", - device_info={}, - last_activity=current_time - timedelta(minutes=45), - metadata={}, - ) - - mock_storage.get.return_value = session_data - - result = await session_manager.validate_session(session_id) - - assert result is None - - assert mock_storage.get.call_count >= 1 - assert mock_storage.get.call_args_list[0][0][0] == session_id - assert mock_storage.get.call_args_list[0][0][1] == SessionData - - assert mock_storage.update.call_count == 1 - update_args = mock_storage.update.call_args[0] - assert update_args[0] == session_id - assert update_args[1].is_active is False - assert "terminated_at" in update_args[1].metadata - - -@pytest.mark.asyncio -async def test_validate_session_inactive(session_manager, mock_storage): - """Test validating an inactive session.""" - session_id = "test-session-id" - - current_time = datetime.now(UTC) - session_data = SessionData( - session_id=session_id, - user_id=1, - is_active=False, - ip_address="127.0.0.1", - user_agent="test_agent", - device_info={}, - last_activity=current_time - timedelta(minutes=5), - metadata={}, - ) - - mock_storage.get.return_value = session_data - - result = await session_manager.validate_session(session_id) - - assert result is None - - mock_storage.get.assert_called_once_with(session_id, SessionData) - mock_storage.update.assert_not_called() - - -@pytest.mark.asyncio -async def test_validate_csrf_token_valid(session_manager, mock_csrf_storage): - """Test validating a valid CSRF token.""" - session_id = "test-session-id" - csrf_token = "test-csrf-token" - - current_time = datetime.now(UTC) - token_data = CSRFToken( - token=csrf_token, - user_id=1, - session_id=session_id, - expiry=current_time + timedelta(minutes=30), - ) - - mock_csrf_storage.get.return_value = token_data - - result = await session_manager.validate_csrf_token(session_id, csrf_token) - - assert result is True - - mock_csrf_storage.get.assert_called_once_with(csrf_token, CSRFToken) - - -@pytest.mark.asyncio -async def test_validate_csrf_token_expired(session_manager, mock_csrf_storage): - """Test validating an expired CSRF token.""" - session_id = "test-session-id" - csrf_token = "test-csrf-token" - - current_time = datetime.now(UTC) - token_data = CSRFToken( - token=csrf_token, - user_id=1, - session_id=session_id, - expiry=current_time - timedelta(minutes=5), - ) - - mock_csrf_storage.get.return_value = token_data - - result = await session_manager.validate_csrf_token(session_id, csrf_token) - - assert result is False - - mock_csrf_storage.get.assert_called_once_with(csrf_token, CSRFToken) - mock_csrf_storage.delete.assert_called_once_with(csrf_token) - - -@pytest.mark.asyncio -async def test_validate_csrf_token_mismatched_session(session_manager, mock_csrf_storage): - """Test validating a CSRF token with wrong session ID.""" - session_id = "test-session-id" - wrong_session_id = "wrong-session-id" - csrf_token = "test-csrf-token" - - current_time = datetime.now(UTC) - token_data = CSRFToken( - token=csrf_token, - user_id=1, - session_id=wrong_session_id, - expiry=current_time + timedelta(minutes=30), - ) - - mock_csrf_storage.get.return_value = token_data - - result = await session_manager.validate_csrf_token(session_id, csrf_token) - - assert result is False - - mock_csrf_storage.get.assert_called_once_with(csrf_token, CSRFToken) - - -@pytest.mark.asyncio -async def test_regenerate_csrf_token(session_manager, mock_csrf_storage): - """Test regenerating a CSRF token for an existing session.""" - user_id = 1 - session_id = "test-session-id" - new_csrf_token = "new-csrf-token" - - with patch.object(session_manager, "_generate_csrf_token", return_value=new_csrf_token) as mock_generate: - result = await session_manager.regenerate_csrf_token(user_id, session_id) - - assert result == new_csrf_token - - mock_generate.assert_called_once_with(user_id, session_id) - - -@pytest.mark.asyncio -async def test_terminate_session(session_manager, mock_storage): - """Test terminating a session.""" - session_id = "test-session-id" - - session_data = SessionData( - session_id=session_id, - user_id=1, - is_active=True, - ip_address="127.0.0.1", - user_agent="test_agent", - device_info={}, - last_activity=datetime.now(UTC), - metadata={}, - ) - - mock_storage.get.return_value = session_data - mock_storage.update.return_value = True - - result = await session_manager.terminate_session(session_id) - - assert result is True - - mock_storage.get.assert_called_once_with(session_id, SessionData) - mock_storage.update.assert_called_once() - - update_args = mock_storage.update.call_args[0] - assert update_args[0] == session_id - assert not update_args[1].is_active - assert "terminated_at" in update_args[1].metadata - assert "termination_reason" in update_args[1].metadata - - -@pytest.mark.asyncio -async def test_set_session_cookies(session_manager, mock_response): - """Test setting session cookies.""" - session_id = "test-session-id" - csrf_token = "test-csrf-token" - - session_manager.set_session_cookies( - response=mock_response, - session_id=session_id, - csrf_token=csrf_token, - ) - - assert mock_response.set_cookie.call_count == 2 - - session_cookie_args = mock_response.set_cookie.call_args_list[0][1] - assert session_cookie_args["key"] == "session_id" - assert session_cookie_args["value"] == session_id - assert session_cookie_args["httponly"] is True - - csrf_cookie_args = mock_response.set_cookie.call_args_list[1][1] - assert csrf_cookie_args["key"] == "csrf_token" - assert csrf_cookie_args["value"] == csrf_token - assert csrf_cookie_args["httponly"] is False - - -@pytest.mark.asyncio -async def test_clear_session_cookies(session_manager, mock_response): - """Test clearing session cookies.""" - session_manager.clear_session_cookies(response=mock_response) - - assert mock_response.delete_cookie.call_count == 2 - - assert mock_response.delete_cookie.call_args_list[0][1]["key"] == "session_id" - assert mock_response.delete_cookie.call_args_list[1][1]["key"] == "csrf_token" - - -@pytest.mark.asyncio -async def test_parse_user_agent(session_manager): - """Test parsing a user agent string.""" - ua_string = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - ) - - result = session_manager.parse_user_agent(ua_string) - - assert isinstance(result, UserAgentInfo) - assert result.browser == "Chrome" - assert "91.0" in result.browser_version - assert result.os == "Windows" - assert result.is_pc is True - assert result.is_mobile is False - - ua_string = ( - "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 " - "(KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1" - ) - - result = session_manager.parse_user_agent(ua_string) - - assert result.browser == "Mobile Safari" - assert result.os == "iOS" - assert result.device == "iPhone" - assert result.is_mobile is True - assert result.is_pc is False - - -@pytest.mark.asyncio -async def test_enforce_session_limit(session_manager, mock_storage): - """Test enforcing maximum sessions per user.""" - user_id = 1 - active_sessions = [] - - for i in range(6): - session_data = SessionData( - session_id=f"session-{i}", - user_id=user_id, - is_active=True, - ip_address="127.0.0.1", - user_agent="test-agent", - device_info={}, - last_activity=datetime.now(UTC) - timedelta(minutes=i), - metadata={}, - ) - active_sessions.append(session_data) - - mock_storage.get_user_sessions.return_value = [s.session_id for s in active_sessions] - mock_storage.get.side_effect = lambda sid, cls: next((s for s in active_sessions if s.session_id == sid), None) - - await session_manager._enforce_session_limit(user_id) - - assert mock_storage.update.call_count == 2 - - terminated_sessions = [args[0][0] for args in mock_storage.update.call_args_list] - assert "session-5" in terminated_sessions - assert "session-4" in terminated_sessions diff --git a/backend/tests/unit/infrastructure/auth/session/test_rate_limiting.py b/backend/tests/unit/infrastructure/auth/session/test_rate_limiting.py deleted file mode 100644 index cd893c83..00000000 --- a/backend/tests/unit/infrastructure/auth/session/test_rate_limiting.py +++ /dev/null @@ -1,146 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import Request, Response -from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.ext.asyncio import AsyncSession - -from src.infrastructure.auth.routes import login -from src.infrastructure.auth.session.manager import SessionManager - - -@pytest.mark.asyncio -async def test_track_login_attempt_with_rate_limiter(): - """Test tracking login attempts when a rate limiter is configured.""" - mock_rate_limiter = MagicMock() - mock_rate_limiter.increment = AsyncMock(side_effect=[1, 1, 2, 2, 3, 3, 4, 4, 5, 5]) - mock_rate_limiter.delete = AsyncMock(return_value=True) - - mock_storage = MagicMock() - - session_manager = SessionManager( - session_storage=mock_storage, rate_limiter=mock_rate_limiter, login_max_attempts=5, login_window_minutes=15 - ) - - is_allowed, remaining = await session_manager.track_login_attempt( - ip_address="192.168.1.1", username="testuser", success=True - ) - - assert is_allowed is True - assert remaining is None - assert mock_rate_limiter.delete.call_count == 2 - mock_rate_limiter.delete.assert_any_call("login:ip:192.168.1.1") - mock_rate_limiter.delete.assert_any_call("login:user:testuser") - - mock_rate_limiter.delete.reset_mock() - mock_rate_limiter.increment.reset_mock() - - for i in range(1, 5): - is_allowed, remaining = await session_manager.track_login_attempt( - ip_address="192.168.1.1", username="testuser", success=False - ) - - assert is_allowed is True - assert remaining == (5 - i) - assert mock_rate_limiter.increment.call_count == i * 2 - - mock_rate_limiter.increment.side_effect = [6, 6] - - is_allowed, remaining = await session_manager.track_login_attempt( - ip_address="192.168.1.1", username="testuser", success=False - ) - - assert is_allowed is False - assert remaining == 0 - - -@pytest.mark.asyncio -async def test_track_login_attempt_without_rate_limiter(): - """Test tracking login attempts when no rate limiter is configured.""" - mock_storage = MagicMock() - - session_manager = SessionManager( - session_storage=mock_storage, rate_limiter=None, login_max_attempts=5, login_window_minutes=15 - ) - - is_allowed, remaining = await session_manager.track_login_attempt( - ip_address="192.168.1.1", username="testuser", success=True - ) - - assert is_allowed is True - assert remaining is None - - is_allowed, remaining = await session_manager.track_login_attempt( - ip_address="192.168.1.1", username="testuser", success=False - ) - - assert is_allowed is True - assert remaining is None - - -@pytest.mark.asyncio -async def test_login_endpoint_rate_limiting(): - """Test that the login endpoint applies rate limiting.""" - mock_request = MagicMock(spec=Request) - mock_request.client.host = "192.168.1.1" - - mock_response = MagicMock(spec=Response) - - mock_form_data = MagicMock(spec=OAuth2PasswordRequestForm) - mock_form_data.username = "testuser" - mock_form_data.password = "password123" - - mock_db = MagicMock(spec=AsyncSession) - - mock_session_manager = MagicMock() - - mock_session_manager.track_login_attempt = AsyncMock(return_value=(True, 4)) - mock_auth_user = AsyncMock(return_value={"id": 1, "username": "testuser"}) - mock_session_manager.create_session = AsyncMock(return_value=("session_id", "csrf_token")) - - with patch("src.infrastructure.auth.routes.authenticate_user", mock_auth_user): - result = await login( - request=mock_request, - response=mock_response, - form_data=mock_form_data, - db=mock_db, - session_manager=mock_session_manager, - ) - - assert result == {"csrf_token": "csrf_token"} - assert mock_session_manager.track_login_attempt.call_count == 2 - mock_session_manager.track_login_attempt.assert_any_call(ip_address="192.168.1.1", username="testuser", success=False) - mock_session_manager.track_login_attempt.assert_any_call(ip_address="192.168.1.1", username="testuser", success=True) - - mock_session_manager.reset_mock() - mock_auth_user.reset_mock() - - mock_session_manager.track_login_attempt = AsyncMock(return_value=(False, 0)) - - with patch("src.infrastructure.auth.routes.authenticate_user", mock_auth_user): - with pytest.raises(Exception) as excinfo: - await login( - request=mock_request, - response=mock_response, - form_data=mock_form_data, - db=mock_db, - session_manager=mock_session_manager, - ) - - assert "Too many failed login attempts" in str(excinfo.value) - assert mock_auth_user.call_count == 0 - - -@pytest.mark.asyncio -async def test_cleanup_rate_limits(): - """Test cleanup_rate_limits method.""" - mock_rate_limiter = MagicMock() - mock_rate_limiter.delete_pattern = AsyncMock(return_value=5) - - mock_storage = MagicMock() - - session_manager = SessionManager(session_storage=mock_storage, rate_limiter=mock_rate_limiter) - - await session_manager.cleanup_rate_limits() - - mock_rate_limiter.delete_pattern.assert_called_once_with("login:*") diff --git a/uv.lock b/uv.lock index 49f86dcd..65addbc1 100644 --- a/uv.lock +++ b/uv.lock @@ -533,6 +533,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "crudauth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { 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" } +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" }, +] + +[package.optional-dependencies] +all = [ + { name = "httpx" }, + { name = "redis" }, + { name = "user-agents" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -628,7 +653,7 @@ dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, { name = "asyncpg" }, - { name = "bcrypt" }, + { name = "crudauth", extra = ["all"] }, { name = "faker" }, { name = "fastapi", extra = ["standard"] }, { name = "fastcrud" }, @@ -664,7 +689,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.16.4" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "bcrypt", specifier = ">=5.0.0" }, + { name = "crudauth", extras = ["all"], specifier = ">=0.6.0,<0.7.0" }, { name = "faker", specifier = ">=37.1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, { name = "fastcrud", specifier = ">=0.21.0" }, @@ -1841,6 +1866,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + [[package]] name = "pytest" version = "9.0.3" From e13ebc4c2b793bda734c00ace74a1182e7a42f04 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 22:43:17 -0300 Subject: [PATCH 2/8] filter soft-deleted users in /check-auth --- backend/src/infrastructure/auth/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index ae477013..86975207 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -312,7 +312,7 @@ async def check_auth( return {"authenticated": False, "message": "Not authenticated"} try: - user = await crud_users.get(db=db, id=principal.user_id) + user = await crud_users.get(db=db, id=principal.user_id, is_deleted=False) if not user: return {"authenticated": False, "message": "User not found"} From 614bf9d132fcb97f461cd7209e9c0b0fead1f136 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 22:43:28 -0300 Subject: [PATCH 3/8] cover is_active gating, superuser 403, oauth callback, csrf --- .../tests/integration/auth/test_endpoints.py | 157 +++++++++++++++++- .../infrastructure/auth/test_dependencies.py | 65 ++++++++ .../tests/unit/modules/user/test_models.py | 26 +++ 3 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 backend/tests/unit/infrastructure/auth/test_dependencies.py create mode 100644 backend/tests/unit/modules/user/test_models.py diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index 0ebd6df2..f423047c 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -9,12 +9,14 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from crudauth import Principal -from crudauth.oauth import OAuthState +from crudauth import Principal, get_password_hash +from crudauth.oauth import OAuthState, OAuthUserInfo from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession from src.infrastructure.auth.dependencies import get_optional_principal from src.interfaces.main import app +from src.modules.user.models import User ROUTES = "src.infrastructure.auth.routes" @@ -147,9 +149,7 @@ async def test_check_auth_authenticated(client: AsyncClient): original_deps = app.dependency_overrides.copy() try: - app.dependency_overrides[get_optional_principal] = lambda: Principal( - user_id=1, metadata={"session_id": "test-session"} - ) + app.dependency_overrides[get_optional_principal] = lambda: Principal(user_id=1, metadata={"session_id": "test-session"}) with patch("src.modules.user.crud.crud_users.get", return_value=mock_user): response = await client.get("/api/v1/auth/check-auth") @@ -193,3 +193,150 @@ async def test_check_auth_no_session_cookie_returns_unauthenticated(client: Asyn assert response.status_code == 200 assert response.json()["authenticated"] is False + + +@pytest.mark.asyncio +async def test_login_soft_deleted_user_rejected(client: AsyncClient, db_session: AsyncSession, test_tier: dict): + """A soft-deleted user cannot log in — crudauth reads User.is_active (not is_deleted). + + This is the migration's core new invariant: the derived is_active property gates + authentication, so is_deleted=True must fail login. + """ + user = User( + name="Deleted User", + username="deleted_user", + email="deleted@example.com", + hashed_password=get_password_hash("Password123!"), + tier_id=test_tier["id"], + ) + user.is_deleted = True + db_session.add(user) + await db_session.commit() + + response = await client.post( + "/api/v1/auth/login", + data={"username": "deleted_user", "password": "Password123!"}, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_unauthenticated_returns_401(client: AsyncClient): + """Logout with no session is rejected (the route depends on get_current_principal).""" + response = await client.post("/api/v1/auth/logout") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_without_csrf_token_rejected(client: AsyncClient, test_user: dict): + """A logged-in session still can't mutate without the CSRF header (403).""" + login = await client.post( + "/api/v1/auth/login", + data={"username": test_user["username"], "password": test_user["password"]}, + ) + assert login.status_code == 200 + + # POST without the X-CSRF-Token header → crudauth CSRF guard rejects with 403. + response = await client.post("/api/v1/auth/logout") + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_refresh_csrf_token_success(client: AsyncClient, test_user: dict): + """With a valid session cookie, /refresh-csrf mints a fresh token (no CSRF header needed).""" + login = await client.post( + "/api/v1/auth/login", + data={"username": test_user["username"], "password": test_user["password"]}, + ) + assert login.status_code == 200 + + response = await client.post("/api/v1/auth/refresh-csrf") + + assert response.status_code == 200 + assert response.json()["csrf_token"] + + +@pytest.mark.asyncio +async def test_refresh_csrf_token_no_session_returns_401(client: AsyncClient): + """/refresh-csrf with no session cookie is unauthorized.""" + response = await client.post("/api/v1/auth/refresh-csrf") + 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_check_auth_user_not_found(client: AsyncClient): + """A resolved principal whose user row is missing reports authenticated=false.""" + original_deps = app.dependency_overrides.copy() + try: + app.dependency_overrides[get_optional_principal] = lambda: Principal(user_id=999999, metadata={"session_id": "x"}) + + with patch("src.modules.user.crud.crud_users.get", return_value=None): + response = await client.get("/api/v1/auth/check-auth") + + assert response.status_code == 200 + assert response.json()["authenticated"] is False + assert response.json()["message"] == "User not found" + finally: + app.dependency_overrides = original_deps diff --git a/backend/tests/unit/infrastructure/auth/test_dependencies.py b/backend/tests/unit/infrastructure/auth/test_dependencies.py new file mode 100644 index 00000000..95c10a4d --- /dev/null +++ b/backend/tests/unit/infrastructure/auth/test_dependencies.py @@ -0,0 +1,65 @@ +"""Unit tests for the crudauth-backed auth dependencies. + +The integration fixtures (`auth_client` / `superuser_auth_client`) override these +functions, so the real bodies — the dict re-load, the soft-delete filter, and the +superuser 403 branch — are only exercised here, called directly with a Principal +and a mocked ``crud_users.get``. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from crudauth import Principal +from crudauth.exceptions import ForbiddenException, UnauthorizedException + +from src.infrastructure.auth import dependencies as deps + + +@pytest.mark.asyncio +async def test_get_current_user_no_principal_raises(): + with pytest.raises(UnauthorizedException): + await deps.get_current_user(principal=None, db=MagicMock()) + + +@pytest.mark.asyncio +async def test_get_current_user_missing_row_raises(): + """A valid principal whose row is gone/soft-deleted re-loads to None → 401.""" + with patch.object(deps.crud_users, "get", new=AsyncMock(return_value=None)): + with pytest.raises(UnauthorizedException): + await deps.get_current_user(principal=Principal(user_id=1), db=MagicMock()) + + +@pytest.mark.asyncio +async def test_get_current_user_returns_dict_and_filters_soft_deleted(): + user = {"id": 1, "username": "x", "is_superuser": False} + mock_get = AsyncMock(return_value=user) + with patch.object(deps.crud_users, "get", new=mock_get): + result = await deps.get_current_user(principal=Principal(user_id=1), db=MagicMock()) + + assert result == user + assert mock_get.call_args.kwargs.get("is_deleted") is False + + +@pytest.mark.asyncio +async def test_get_optional_user_none_principal_returns_none(): + assert await deps.get_optional_user(principal=None, db=MagicMock()) is None + + +@pytest.mark.asyncio +async def test_get_optional_user_returns_dict(): + user = {"id": 2} + with patch.object(deps.crud_users, "get", new=AsyncMock(return_value=user)): + result = await deps.get_optional_user(principal=Principal(user_id=2), db=MagicMock()) + assert result == user + + +@pytest.mark.asyncio +async def test_get_current_superuser_denies_non_superuser(): + with pytest.raises(ForbiddenException): + await deps.get_current_superuser(current_user={"id": 1, "is_superuser": False}) + + +@pytest.mark.asyncio +async def test_get_current_superuser_allows_superuser(): + user = {"id": 1, "is_superuser": True} + assert await deps.get_current_superuser(current_user=user) == user diff --git a/backend/tests/unit/modules/user/test_models.py b/backend/tests/unit/modules/user/test_models.py new file mode 100644 index 00000000..b2684564 --- /dev/null +++ b/backend/tests/unit/modules/user/test_models.py @@ -0,0 +1,26 @@ +"""Unit tests for the User model.""" + +from src.modules.user.models import User + + +def _make_user() -> User: + return User( + name="Test User", + username="testuser", + email="test@example.com", + hashed_password="hashed", + ) + + +def test_is_active_true_when_not_deleted(): + """A fresh user is active (is_deleted defaults to False).""" + user = _make_user() + assert user.is_deleted is False + assert user.is_active is True + + +def test_is_active_false_when_soft_deleted(): + """A soft-deleted user is inactive — this is what crudauth reads to gate auth.""" + user = _make_user() + user.is_deleted = True + assert user.is_active is False From 6066dd96ca42ec43b35f86f2e491b38e22857111 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 22:43:56 -0300 Subject: [PATCH 4/8] test: disable testcontainers Ryuk reaper for parallel runs --- backend/tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6a18ecc3..fc62c163 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,6 +11,12 @@ os.environ.setdefault("SQLITE_URI", ":memory:") os.environ.setdefault("SQLITE_ASYNC_PREFIX", "sqlite+aiosqlite:///") +# Disable the testcontainers Ryuk reaper. Under `pytest -n auto`, each xdist worker +# is a separate process that spins up its own Ryuk container, and Docker Desktop +# chokes mapping all their ports at once ("Port mapping ... port 8080 is not +# available"). Testcontainers' own `with` blocks still clean up on normal exit. +os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true") + import sys # noqa: E402 from pathlib import Path # noqa: E402 from unittest.mock import MagicMock # noqa: E402 From 63cdca1820f91459ce224040c32ad86ad3e88ef1 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 22:44:03 -0300 Subject: [PATCH 5/8] docs: update for crudauth migration; add JWT-for-mobile note --- README.md | 2 +- docs/changelog.md | 42 +++++++++ docs/cli/plugins.md | 32 +++---- docs/getting-started/configuration.md | 11 +-- docs/index.md | 2 +- docs/user-guide/admin-panel/configuration.md | 2 +- docs/user-guide/api/endpoints.md | 14 ++- docs/user-guide/api/exceptions.md | 10 +-- docs/user-guide/api/index.md | 4 +- docs/user-guide/api/versioning.md | 2 +- docs/user-guide/authentication/index.md | 71 ++++++++++----- docs/user-guide/authentication/permissions.md | 2 +- docs/user-guide/authentication/sessions.md | 87 ++++++++----------- .../authentication/user-management.md | 50 ++++------- .../configuration/environment-specific.md | 5 +- .../configuration/environment-variables.md | 17 ++-- docs/user-guide/configuration/index.md | 7 +- .../configuration/settings-classes.md | 2 +- docs/user-guide/database/crud.md | 2 +- docs/user-guide/database/models.md | 5 ++ docs/user-guide/development.md | 14 +-- docs/user-guide/production.md | 7 +- docs/user-guide/project-structure.md | 12 +-- docs/user-guide/rate-limiting/index.md | 32 +++---- docs/user-guide/testing.md | 6 +- 25 files changed, 243 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index b3a1ee64..43172504 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ * Fully async FastAPI + SQLAlchemy 2.0 * Pydantic v2 models & validation -* Server-side sessions + CSRF; OAuth (Google wired, GitHub scaffolded); API keys +* Server-side sessions + CSRF via [crudauth](https://pypi.org/project/crudauth/); OAuth (Google wired); API keys * Annotated type aliases for all FastAPI dependencies * Rate limiter with per-tier, per-path rules * FastCRUD for efficient CRUD & pagination diff --git a/docs/changelog.md b/docs/changelog.md index fc024929..29eb8d85 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,48 @@ For the full narrative on each release — rationale, decisions, migration guide ___ +## 0.19.0 - (unreleased) - The crudauth Migration + +The vendored authentication stack — the in-tree `SessionManager`, its storage backends, the OAuth provider framework, and the token/password utilities — has been replaced with the [`crudauth`](https://pypi.org/project/crudauth/) library. The boilerplate now keeps only the wiring: a single `auth = CRUDAuth(...)` singleton in `infrastructure/auth/setup.py`, the FastAPI dependencies that wrap it, the OAuth building blocks, and the route handlers. Session validation, CSRF, login lockout, and session storage all live in the library. + +This is a **breaking** release for anyone importing from the old auth modules or relying on the removed settings. See Breaking Changes below for the migration. + +--- + +#### Added + +- **`crudauth` library integration** by [@igorbenav](https://github.com/igorbenav) + - `auth = CRUDAuth(...)` composition root in `infrastructure/auth/setup.py`; the app lifespan calls `auth.initialize()` / `auth.shutdown()` + - New `Principal`-based dependencies `get_current_principal` / `get_optional_principal` alongside the dict-returning `get_current_user` / `get_optional_user` / `get_current_superuser` +- **`TRUSTED_PROXY_HOPS` setting** (default `0`) — number of trusted reverse proxies in front of the app, used by crudauth to resolve the real client IP for login lockout. Set `1` behind a single nginx/Caddy. +- **`User.is_active` property** — derived from `not is_deleted`; crudauth reads it during login so soft-deleted users can't authenticate. + +#### Changed + +- 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. + +#### Removed + +- The vendored session stack: `SessionManager`, the memory/redis/memcached session backends, session storage/schemas/dependencies, and the user-agent parsing helpers. +- The in-tree OAuth package (provider framework, factory, `providers/google.py`, `providers/github.py`, services) and `auth/utils.py` / `auth/constants.py`. +- The **memcached session backend** — sessions now support only `redis` and `memory`. (The general cache and rate limiter still support memcached.) +- The GitHub OAuth provider file. The `User` model keeps its `github_id` / `oauth_provider` columns, so the data model still anticipates GitHub, but no provider or routes ship. +- Settings `ALGORITHM`, `ACCESS_TOKEN_EXPIRE_MINUTES`, `REFRESH_TOKEN_EXPIRE_DAYS`, `SESSION_COOKIE_MAX_AGE`, `LOGIN_MAX_ATTEMPTS`, and `LOGIN_WINDOW_MINUTES`. +- The dependency aliases `SessionManagerDep`, `CurrentSessionDataDep`, `OptionalSessionDataDep`, `GoogleOAuthProviderDep`, and `OAuthStateStorageDep`. + +#### Breaking Changes + +- **Imports moved.** Replace `from ...infrastructure.auth.session.dependencies import ...` with `from ...infrastructure.auth.dependencies import ...`, and import `get_password_hash` / `verify_password` from `crudauth`. +- **Removed settings.** Delete `ALGORITHM`, `ACCESS_TOKEN_EXPIRE_MINUTES`, `REFRESH_TOKEN_EXPIRE_DAYS`, `SESSION_COOKIE_MAX_AGE`, `LOGIN_MAX_ATTEMPTS`, and `LOGIN_WINDOW_MINUTES` from your environment — they no longer exist. Add `TRUSTED_PROXY_HOPS` if you run behind a proxy. +- **Login lockout response changed** from `401` to `429` + `Retry-After`; update any client that special-cased the old status/message. +- **`SESSION_BACKEND=memcached` is no longer valid** — switch to `redis` or `memory`. +- **`SessionManager` and friends are gone.** Code that called the session manager directly (e.g. listing a user's sessions) must move to crudauth's session APIs. + +___ + ## 0.18.0 - May 24, 2026 - The Pluggable Restructure This is the release we promised in v0.17.0; the one that tears the layout apart and rebuilds it as a real plugin system. If you've been pinned to v0.17.0 waiting for it, this is your moment. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 1ed3de0c..4d29d769 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -193,14 +193,14 @@ The installer renders `template` with the plan's `template_context` and writes t #### 1. Package layout ```text -bp-feature-microsoft-oauth/ +bp-feature-audit-log/ ├── pyproject.toml └── src/ - └── bp_feature_microsoft_oauth/ + └── bp_feature_audit_log/ ├── __init__.py ├── feature.py └── templates/ - └── microsoft_provider.py.j2 + └── audit_log_module.py.j2 ``` #### 2. `feature.py` — define the feature @@ -212,47 +212,49 @@ from cli.features.base import Feature, FeatureManifest, FeaturePlan, FileOp from cli.lib.project import ProjectContext -class MicrosoftOAuthFeature(Feature): +class AuditLogFeature(Feature): def manifest(self) -> FeatureManifest: return FeatureManifest( - name="microsoft-oauth", + name="audit-log", version="1.0", - summary="Wire up Microsoft (Entra ID) as an OAuth provider.", + summary="Drop in an audit-log module that records mutating requests.", ) def plan(self, params: dict, project: ProjectContext) -> FeaturePlan: templates_root = Path(__file__).parent / "templates" - provider_path = ( + module_path = ( project.backend_dir - / "src/infrastructure/auth/oauth/providers/microsoft.py" + / "src/modules/audit_log/models.py" ) return FeaturePlan( manifest=self.manifest(), templates_root=templates_root, template_context={ - "tenant_id": params.get("tenant_id", "common"), + "retention_days": params.get("retention_days", 90), }, files=( - FileOp(template="microsoft_provider.py.j2", target=provider_path), + FileOp(template="audit_log_module.py.j2", target=module_path), ), ) # Entry-point target — can be a Feature instance or a callable that returns one. -feature = MicrosoftOAuthFeature() +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. + #### 3. `pyproject.toml` — declare the entry point ```toml [project] -name = "bp-feature-microsoft-oauth" +name = "bp-feature-audit-log" version = "0.1.0" requires-python = ">=3.11" dependencies = ["fastapi-boilerplate-cli"] [project.entry-points."bp.features"] -microsoft-oauth = "bp_feature_microsoft_oauth.feature:feature" +audit-log = "bp_feature_audit_log.feature:feature" [tool.setuptools] include-package-data = true @@ -269,9 +271,9 @@ Note `include-package-data = true` and the `package-data` glob — without these #### 4. Install ```bash -uv pip install bp-feature-microsoft-oauth +uv pip install bp-feature-audit-log # Once `bp feature` ships: -# uv run bp feature add microsoft-oauth --tenant-id +# uv run bp feature add audit-log --retention-days 90 ``` ### Best Practices for Feature Plugins diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 09597b06..5bc12838 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -74,17 +74,18 @@ SESSION_TIMEOUT_MINUTES=30 SESSION_CLEANUP_INTERVAL_MINUTES=15 MAX_SESSIONS_PER_USER=5 SESSION_SECURE_COOKIES=true -SESSION_BACKEND=redis -SESSION_COOKIE_MAX_AGE=86400 +SESSION_BACKEND=redis # redis | memory # CSRF protection (set false to disable in dev/test) CSRF_ENABLED=true -# Login rate limiting -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +# 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 ``` +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. + ### First Admin User The `setup_initial_data` script reads these on first run: diff --git a/docs/index.md b/docs/index.md index f85098e5..9a50641e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,7 +53,7 @@ This boilerplate leverages cutting-edge Python technologies: ### Security & Authentication - Server-side session authentication with secure HTTP-only cookies -- OAuth 2.0 sign-in (Google, with GitHub provider scaffolded) using PKCE +- OAuth 2.0 sign-in (Google wired; add others via crudauth's `OAuthProviderFactory`) 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/admin-panel/configuration.md b/docs/user-guide/admin-panel/configuration.md index a912b92d..5a8d280e 100644 --- a/docs/user-guide/admin-panel/configuration.md +++ b/docs/user-guide/admin-panel/configuration.md @@ -144,7 +144,7 @@ The admin panel itself doesn't change behavior between `local` / `development` / - **Cookie security**: derived from your reverse proxy / TLS setup, not from the `ENVIRONMENT` setting - **Logging**: admin actions go through the same logger configured by `infrastructure/logging/` -- **Session backend**: Starlette's `SessionMiddleware` is in-memory + cookie-based, not the same as the API's `SESSION_BACKEND` (Redis/memcached/memory). Restart-resilience for the *admin* login isn't relevant — admins re-log-in fine. +- **Session backend**: Starlette's `SessionMiddleware` is in-memory + cookie-based, not the same as the API's `SESSION_BACKEND` (Redis/memory). Restart-resilience for the *admin* login isn't relevant — admins re-log-in fine. ## Troubleshooting diff --git a/docs/user-guide/api/endpoints.md b/docs/user-guide/api/endpoints.md index 2a9b4b2a..9c8bbcdf 100644 --- a/docs/user-guide/api/endpoints.md +++ b/docs/user-guide/api/endpoints.md @@ -43,11 +43,7 @@ The boilerplate pre-defines aliases for every shared dependency in `infrastructu | `CurrentUserDep` | `Annotated[dict[str, Any], Depends(get_current_user)]` | | `CurrentSuperUserDep` | `Annotated[dict[str, Any], Depends(get_current_superuser)]` | | `OptionalUserDep` | `Annotated[dict[str, Any] \| None, Depends(get_optional_user)]` | -| `SessionManagerDep` | `Annotated[SessionManager, Depends(get_session_manager)]` | -| `CurrentSessionDataDep` | `Annotated[SessionData, Depends(get_current_session_data)]` | | `OAuth2FormDep` | `Annotated[OAuth2PasswordRequestForm, Depends()]` | -| `GoogleOAuthProviderDep` | `Annotated[AbstractOAuthProvider, Depends(get_google_provider)]` | -| `OAuthStateStorageDep` | `Annotated[AbstractSessionStorage[OAuthState], Depends(get_oauth_state_storage)]` | Per-module service aliases live in `modules//dependencies.py`: @@ -72,7 +68,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from ...infrastructure.auth.http_exceptions import HTTPException -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user from ...infrastructure.database.session import async_session from ..common.utils.error_handler import handle_exception from .schemas import WidgetCreate, WidgetRead @@ -238,12 +234,12 @@ async def delete_widget( ## Authentication -All session-based auth dependencies live in `infrastructure/auth/session/dependencies`. +All session-based auth dependencies live in `infrastructure/auth/dependencies`. ### Require Login ```python -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user @router.get("/me", response_model=WidgetRead) @@ -258,7 +254,7 @@ async def my_widget( ### Optional Auth ```python -from ...infrastructure.auth.session.dependencies import get_optional_user +from ...infrastructure.auth.dependencies import get_optional_user @router.get("/", response_model=list[WidgetRead]) @@ -273,7 +269,7 @@ async def list_widgets( ### Superuser Only ```python -from ...infrastructure.auth.session.dependencies import get_current_superuser +from ...infrastructure.auth.dependencies import get_current_superuser @router.delete("/{widget_id}/permanent") diff --git a/docs/user-guide/api/exceptions.md b/docs/user-guide/api/exceptions.md index fb53087e..d5b8ea4b 100644 --- a/docs/user-guide/api/exceptions.md +++ b/docs/user-guide/api/exceptions.md @@ -267,16 +267,14 @@ Then re-export it via `__all__` and import it where needed. ### Generic Messages for Auth -Authentication routes already follow this pattern in `infrastructure/auth/routes.py`: +The login flow in `infrastructure/auth/routes.py` delegates credential checking to the `crudauth` `auth` singleton. Its `authenticate_password` does a timing-equalized check and raises `UnauthorizedException("Incorrect username or password")` on bad credentials (and a `429` once the escalating lockout trips): ```python -user = await authenticate_user(...) -if user is None: - logger.warning(f"Failed login attempt for {form_data.username} from IP {ip_address}") - raise UnauthorizedException("Incorrect username or password") +# crudauth's hardened check: timing-equalized, disabled-account guard, escalating lockout +user = await auth.authenticate_password(db, form_data.username, form_data.password, request=request) ``` -It doesn't say "username not found" or "wrong password" — both reveal whether the username exists. +The message doesn't say "username not found" or "wrong password" — both would reveal whether the username exists. ### Hide Resource Existence diff --git a/docs/user-guide/api/index.md b/docs/user-guide/api/index.md index 36113761..c96d7575 100644 --- a/docs/user-guide/api/index.md +++ b/docs/user-guide/api/index.md @@ -57,10 +57,10 @@ Final URL: `POST /api/v1/users/`. ### Built-in Authentication -Session-based auth with HTTP-only cookies. Pull the current user from `infrastructure/auth/session/dependencies`: +Session-based auth with HTTP-only cookies. Pull the current user from `infrastructure/auth/dependencies`: ```python -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user @router.get("/me", response_model=UserRead) async def get_profile( diff --git a/docs/user-guide/api/versioning.md b/docs/user-guide/api/versioning.md index d3a09343..86200b45 100644 --- a/docs/user-guide/api/versioning.md +++ b/docs/user-guide/api/versioning.md @@ -103,7 +103,7 @@ from fastapi import APIRouter, Depends from fastcrud import PaginatedListResponse, compute_offset, paginated_response from sqlalchemy.ext.asyncio import AsyncSession -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user from ...infrastructure.database.session import async_session from .schemas_v2 import UserReadV2 from .service import UserService diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md index 2deee345..70ef1110 100644 --- a/docs/user-guide/authentication/index.md +++ b/docs/user-guide/authentication/index.md @@ -1,6 +1,6 @@ # Authentication & Security -The boilerplate uses **server-side sessions with HTTP-only cookies** — not JWT. Sessions are stored in Redis (or memory/memcached, configurable), CSRF-protected, and rate-limited at the login endpoint. +The boilerplate uses **server-side sessions with HTTP-only cookies** — not JWT. Auth is provided by the [`crudauth`](https://pypi.org/project/crudauth/) library: sessions are stored in Redis (or memory, configurable), CSRF-protected, and lockout-throttled at the login endpoint. The composition root is the `auth = CRUDAuth(...)` singleton in `infrastructure/auth/setup.py` (see [Sessions → Auth Architecture](sessions.md#auth-architecture)). For machine-to-machine clients, the boilerplate ships **API keys** with per-key permissions and usage tracking. @@ -22,6 +22,38 @@ The original boilerplate used JWT with refresh tokens and a token blacklist. We If you specifically need stateless tokens (e.g. for inter-service auth where you can't share a session store), use **API keys** — they're stateless from the client's perspective and authenticated server-side. +### Need JWT for mobile or native apps? + +Cookies and CSRF are awkward for mobile apps, native clients, and CLIs. crudauth handles this with a **bearer (JWT) transport** that runs *alongside* sessions — the boilerplate just doesn't enable it by default. Both transports resolve to the same `Principal`, so your route protection (`CurrentUserDep`, `get_current_user`, etc.) doesn't change; only how the client authenticates does. + +To turn it on, add a `BearerTransport` to the `transports` list in `infrastructure/auth/setup.py` and mount crudauth's bearer router (which adds `POST /token` to log in and `POST /refresh` to mint a new access token): + +```python +# infrastructure/auth/setup.py +from crudauth import BearerTransport, CookieConfig, CRUDAuth, SessionTransport + +auth = CRUDAuth( + session=async_session, + user_model=User, + SECRET_KEY=settings.SECRET_KEY, + cookies=CookieConfig(secure=settings.SESSION_SECURE_COOKIES), + transports=[ + SessionTransport(...), # browsers (unchanged) + BearerTransport(access_ttl=900, refresh="body"), # mobile / API clients + ], + ... +) +``` + +```python +# wherever the auth router is included (e.g. interfaces/api/v1) +app.include_router(auth.bearer_router, prefix="/api/v1/auth") +``` + +Mobile clients typically want `refresh="body"` so the refresh token comes back in the JSON response (to store themselves) rather than as a cookie. Clients then send the access token as `Authorization: Bearer `. When both a session cookie and a bearer token are present, the **first transport in the list wins**. + +For the full walkthrough — token lifecycle, refresh strategies, scopes, and running session + bearer together — see crudauth's [Bearer tokens](https://benavlabs.github.io/crudauth/guides/auth/bearer/) and [Multiple transports](https://benavlabs.github.io/crudauth/guides/auth/multiple-transports/) guides. + ## Authentication Mechanisms The boilerplate supports three auth pathways. They coexist; you pick the right one per endpoint. @@ -59,7 +91,7 @@ curl http://localhost:8000/api/v1/auth/oauth/google # The server creates a session and either redirects or returns JSON. ``` -A GitHub OAuth provider is **scaffolded** in `infrastructure/auth/oauth/providers/github.py` but no GitHub callback routes are wired yet. Wire those up in `infrastructure/auth/routes.py` if you need GitHub sign-in. +Only Google is wired (in the `oauth_providers` dict in `infrastructure/auth/oauth.py`). The data model still anticipates GitHub — the `User` model keeps `github_id` and `oauth_provider` columns — but there's no GitHub provider or routes. To add another provider, register it with crudauth's `OAuthProviderFactory` in `infrastructure/auth/oauth.py` and add the matching routes in `infrastructure/auth/routes.py`. ### 3. API Keys (Machine-to-Machine) @@ -80,10 +112,10 @@ The full key is returned only on creation. Each key has its own permissions, usa ### Server-Side Sessions -- **Session storage**: Redis by default; memory/memcached available (`SESSION_BACKEND` env var) +- **Session storage**: Redis by default; memory available (`SESSION_BACKEND` env var) - **HTTP-only cookies**: `session_id` cookie cannot be read by JavaScript - **CSRF tokens**: Returned on login, also set as a cookie, must be sent in `X-CSRF-Token` for state-changing requests -- **Configurable timeout**: `SESSION_TIMEOUT_MINUTES`, `SESSION_COOKIE_MAX_AGE` +- **Configurable timeout**: `SESSION_TIMEOUT_MINUTES` - **Per-user limits**: `MAX_SESSIONS_PER_USER` caps simultaneous sessions per account - **Automatic cleanup**: `SESSION_CLEANUP_INTERVAL_MINUTES` controls expiry sweeps @@ -101,25 +133,18 @@ The full key is returned only on creation. Each key has its own permissions, usa - **Tier-based** access via the `Tier` model — every user belongs to a tier, and rate limits are configured per tier path - **Resource ownership** checks live in services (the route doesn't decide who owns what) -### Login Rate Limiting - -The login endpoint tracks failed attempts per IP+username. Configurable: - -```env -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 -``` +### Login Lockout -When the limit is hit, `POST /api/v1/auth/login` returns `401 Unauthorized: Too many failed login attempts. Please try again later.` +`crudauth` throttles the login endpoint internally with an escalating per-IP / per-identifier lockout — there are no env vars to tune. When the limit is hit, `POST /api/v1/auth/login` returns `429 Too Many Requests` with a `Retry-After` header telling the client how long to wait. Behind a reverse proxy, set `TRUSTED_PROXY_HOPS` so the lockout keys on the real client IP rather than the proxy's. ## Authentication Patterns -All session deps live in `src/infrastructure/auth/session/dependencies.py`. +All auth deps live in `src/infrastructure/auth/dependencies.py` (they wrap the `crudauth` `auth` singleton). ### Required Authentication ```python -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user @router.get("/me", response_model=UserRead) async def me( @@ -133,7 +158,7 @@ Returns 401 if the session cookie is missing or invalid. ### Optional Authentication ```python -from ...infrastructure.auth.session.dependencies import get_optional_user +from ...infrastructure.auth.dependencies import get_optional_user @router.get("/") async def list_things( @@ -148,7 +173,7 @@ async def list_things( ### Superuser Only ```python -from ...infrastructure.auth.session.dependencies import get_current_superuser +from ...infrastructure.auth.dependencies import get_current_superuser @router.delete("/{username}/permanent") async def gdpr_delete_user( @@ -217,21 +242,19 @@ SESSION_TIMEOUT_MINUTES=30 SESSION_CLEANUP_INTERVAL_MINUTES=15 MAX_SESSIONS_PER_USER=5 SESSION_SECURE_COOKIES=true -SESSION_BACKEND=redis # redis | memory | memcached -SESSION_COOKIE_MAX_AGE=86400 +SESSION_BACKEND=redis # redis | memory # CSRF CSRF_ENABLED=true # set false for dev/test -# Login rate limiting -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +# Trusted reverse proxies in front of the app (real client IP for login lockout) +TRUSTED_PROXY_HOPS=0 # OAuth OAUTH_REDIRECT_BASE_URL=http://localhost:8000 OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_SECRET= -OAUTH_GITHUB_CLIENT_ID= # provider scaffolded; routes not wired +OAUTH_GITHUB_CLIENT_ID= # data model anticipates GitHub; no provider/routes wired OAUTH_GITHUB_CLIENT_SECRET= # Security @@ -293,7 +316,7 @@ You can combine the built-in deps to enforce tier checks: from typing import Annotated, Any from fastapi import Depends, HTTPException -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user async def require_tier( diff --git a/docs/user-guide/authentication/permissions.md b/docs/user-guide/authentication/permissions.md index 2cf44cbd..648b37db 100644 --- a/docs/user-guide/authentication/permissions.md +++ b/docs/user-guide/authentication/permissions.md @@ -29,7 +29,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends -from ...infrastructure.auth.session.dependencies import get_current_superuser +from ...infrastructure.auth.dependencies import get_current_superuser router = APIRouter() diff --git a/docs/user-guide/authentication/sessions.md b/docs/user-guide/authentication/sessions.md index 1f3c429b..bcb243d7 100644 --- a/docs/user-guide/authentication/sessions.md +++ b/docs/user-guide/authentication/sessions.md @@ -2,6 +2,17 @@ Sessions are the boilerplate's default authentication mechanism. All built-in API routes use session auth. +## Auth Architecture + +Authentication is provided by the [`crudauth`](https://pypi.org/project/crudauth/) library. The composition root is a single singleton in `infrastructure/auth/setup.py`: + +```python +# infrastructure/auth/setup.py +auth = CRUDAuth(session=async_session, user_model=User, SECRET_KEY=settings.SECRET_KEY, ...) +``` + +Routers and dependencies reference `auth` at import time, and the app lifespan calls `auth.initialize()` on startup and `auth.shutdown()` on teardown (wired in `app_factory`) to open and close the session backend connections. Everything below — the dependencies, login flow, CSRF, lockout, and session storage — is this singleton in action; the boilerplate only supplies the wiring and route handlers. + ## Protecting Routes Import the session dependencies and add them to your routes: @@ -10,7 +21,7 @@ Import the session dependencies and add them to your routes: from typing import Annotated, Any from fastapi import APIRouter, Depends -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user router = APIRouter() @@ -26,7 +37,7 @@ If the request doesn't have a valid session, the boilerplate returns `401 Unauth ### Available Dependencies -All from `src/infrastructure/auth/session/dependencies.py`. +All from `src/infrastructure/auth/dependencies.py`. They wrap the `crudauth` `auth` singleton, so cookie validation, CSRF, and login lockout live in the library while your handlers keep working with plain user dicts. **`get_current_user`** — Returns the authenticated user dict. Raises 401 if not authenticated. @@ -62,7 +73,7 @@ async def list_products( ... ``` -**`get_current_session_data`** — Returns the full `SessionData` object (id, user_id, ip, device info, timestamps). Useful for endpoints like `/check-auth` that need to expose session metadata. +**`get_current_principal`** — Returns the crudauth `Principal` (session-validated, CSRF-enforced). Use it when you need the session id (`principal.metadata["session_id"]`) or the `user_id` directly rather than the full user dict. `get_optional_principal` is the never-raises variant. ### Protecting Entire Routers @@ -85,22 +96,22 @@ Note: router-level dependencies don't inject values into handlers. If you need t ## How Sessions Work -When a user hits `POST /api/v1/auth/login`: +The login route delegates to the `crudauth` `auth` singleton (see [Auth Architecture](#auth-architecture)). When a user hits `POST /api/v1/auth/login`, crudauth: -1. Login rate limiter checks IP+username (`LOGIN_MAX_ATTEMPTS` per `LOGIN_WINDOW_MINUTES`) -2. `authenticate_user(...)` validates the credentials -3. `SessionManager.create_session(...)` writes a record to the configured backend (Redis by default) -4. A new CSRF token is generated and bound to the session -5. Two cookies are set on the response: +1. Applies its per-IP / per-identifier login lockout (returns `429` + `Retry-After` if tripped) +2. Validates the credentials against the user row (soft-deleted users — `is_active == False` — are rejected) +3. Writes a session record to the configured backend (Redis by default) +4. Generates a CSRF token bound to the session +5. Sets two cookies on the response: - `session_id` — HTTP-only, the session identifier - `csrf_token` — readable by JS, mirrors the CSRF token returned in the JSON body -On every subsequent request, the session dependency: +On every subsequent request, the auth dependency (via crudauth): 1. Reads `session_id` from cookies 2. Looks it up in the configured backend; rejects expired or missing sessions 3. For mutating requests (POST/PUT/DELETE/PATCH), validates the CSRF token if `CSRF_ENABLED=true` -4. Returns the user record (joined with the `Tier` relationship via `lazy="selectin"`) +4. Hands back a `Principal`; `get_current_user` then re-loads the full user row (joined with the `Tier` relationship via `lazy="selectin"`) Logout (`POST /api/v1/auth/logout`) terminates the session record and clears the cookies. @@ -131,36 +142,11 @@ For dev/test environments where CSRF gets in the way, set `CSRF_ENABLED=false`. ## Device Tracking -Sessions capture the IP address and parsed User-Agent fields. Inspect via the session dep: - -```python -from typing import Annotated, Any -from fastapi import Depends +`crudauth` records session metadata (IP address, User-Agent, timestamps) internally as part of each session record. The boilerplate does **not** surface a device-listing route or a `SessionData` schema — that metadata lives inside the library's session store. If you need an "active sessions" UI, build it on crudauth's session APIs (`auth.sessions`) rather than expecting a ready-made dependency here. -from src.infrastructure.auth.session.dependencies import get_current_session_data -from src.infrastructure.auth.session.schemas import SessionData +## Login Lockout - -@router.get("/my-current-session") -async def my_session( - session_data: Annotated[SessionData, Depends(get_current_session_data)], -) -> dict[str, Any]: - return { - "ip": session_data.ip_address, - "user_agent": session_data.user_agent, - "device_info": session_data.device_info, # browser, os, is_mobile, etc. - "created_at": session_data.created_at, - "last_activity": session_data.last_activity, - } -``` - -This makes it straightforward to build "your active sessions" UIs or detect suspicious activity. - -## Login Rate Limiting - -Failed login attempts are tracked per IP+username. After `LOGIN_MAX_ATTEMPTS` failures within `LOGIN_WINDOW_MINUTES`, further attempts on `/api/v1/auth/login` are blocked. - -This happens automatically in the login route — you don't need to wire it up. The defaults (5 attempts in 15 minutes) are conservative; tune per your threat model. +Failed login attempts are throttled by `crudauth` itself. It applies an **escalating per-IP / per-identifier lockout** and, once tripped, returns `429 Too Many Requests` with a `Retry-After` header on `/api/v1/auth/login`. This happens automatically inside the login flow — there's nothing to wire up and no env vars to tune. Behind a reverse proxy, set `TRUSTED_PROXY_HOPS` so the lockout keys on the real client IP rather than the proxy's. ## Session Limits @@ -173,21 +159,19 @@ Sessions are stored server-side. Configure via `SESSION_BACKEND`: | Value | When to use | |-------|-------------| | `redis` *(default)* | Production. Supports key expiration, pattern scans for cleanup, persists across restarts | -| `memcached` | Production alternative — choose based on what your infrastructure already runs | | `memory` | Tests only. Cleared on restart, not safe for multi-process deploys | -Storage backends live in `src/infrastructure/auth/session/backends/`. +The backends ship inside the `crudauth` library, not the boilerplate — `setup.py` just selects `redis` or `memory` based on `SESSION_BACKEND`. (Memcached is no longer a session option; it remains available for the general cache and rate limiter.) ## Configuration ```env # Backend -SESSION_BACKEND=redis +SESSION_BACKEND=redis # redis | memory # Lifetime SESSION_TIMEOUT_MINUTES=30 # inactive sessions expire SESSION_CLEANUP_INTERVAL_MINUTES=15 # how often the storage backend sweeps expired entries -SESSION_COOKIE_MAX_AGE=86400 # 1 day — total cookie lifetime # Per-user cap MAX_SESSIONS_PER_USER=5 @@ -198,9 +182,8 @@ SESSION_SECURE_COOKIES=true # CSRF CSRF_ENABLED=true -# Login rate limiting -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +# Trusted reverse proxies in front of the app (real client IP for login lockout) +TRUSTED_PROXY_HOPS=0 ``` For development you'll typically set `SESSION_SECURE_COOKIES=false` and `CSRF_ENABLED=false` so cookies work over plain HTTP and curl/Postman aren't blocked. Re-enable both for staging and production. @@ -258,13 +241,15 @@ Terminates the session and clears the cookies. | Component | Location | |-----------|----------| -| Dependencies | `backend/src/infrastructure/auth/session/dependencies.py` | -| Session manager | `backend/src/infrastructure/auth/session/manager.py` | -| Storage backends | `backend/src/infrastructure/auth/session/backends/` | -| Schemas | `backend/src/infrastructure/auth/session/schemas.py` | -| Login/logout routes | `backend/src/infrastructure/auth/routes.py` | +| `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` | +| Login/logout/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`) | +Session storage, CSRF, and lockout themselves live in the `crudauth` library, not the boilerplate. + --- [← Authentication Overview](index.md){ .md-button } [User Management →](user-management.md){ .md-button .md-button--primary } diff --git a/docs/user-guide/authentication/user-management.md b/docs/user-guide/authentication/user-management.md index 9d645eab..6fb34400 100644 --- a/docs/user-guide/authentication/user-management.md +++ b/docs/user-guide/authentication/user-management.md @@ -84,48 +84,25 @@ class UserCreate(UserBase): ## Authentication -Authentication happens via `POST /api/v1/auth/login`. See [Sessions](sessions.md) for the full flow. The function that does the credential check is `authenticate_user`: +Authentication happens via `POST /api/v1/auth/login`. See [Sessions](sessions.md) for the full flow. The credential check itself now lives inside the `crudauth` library — the login route delegates to the `auth` singleton (`infrastructure/auth/setup.py`), which looks the user up, verifies the password, and creates the session. The boilerplate no longer ships an `authenticate_user` helper. -```python -# infrastructure/auth/session/dependencies.py -async def authenticate_user( - username_or_email: str, password: str, db: AsyncSession -) -> dict[str, Any] | None: - # Look up by email if "@" present, else username — both with is_deleted=False - if "@" in username_or_email: - user = await crud_users.get(db=db, email=username_or_email, is_deleted=False) - else: - user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) - - if not user: - return None - if not await verify_password(password, user["hashed_password"]): - return None - return user -``` - -Two things to note: +Two things to note about the login behavior: - **Username or email** — both forms work in the same field -- **Soft-deleted users can't log in** — `is_deleted=False` filters them out +- **Soft-deleted users can't log in** — crudauth reads `User.is_active` (defined as `not is_deleted`), so deactivated accounts are rejected before the password is even checked ### Password Hashing (bcrypt) -`infrastructure/auth/utils.py`: +Password hashing helpers come from `crudauth`: ```python -import bcrypt - +from crudauth import get_password_hash, verify_password -async def verify_password(plain_password: str, hashed_password: str) -> bool: - return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) - - -def get_password_hash(password: str) -> str: - return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() +hashed = get_password_hash("plaintext") # bcrypt, salt generated automatically +ok = await verify_password("plaintext", hashed) # constant-time compare ``` -bcrypt handles salt generation automatically and is computationally expensive enough to defeat brute force at scale. +bcrypt handles salt generation automatically and is computationally expensive enough to defeat brute force at scale. `UserService.create` uses `get_password_hash` when persisting a new account (see [Registration](#registration)). ## Profile Operations @@ -347,6 +324,11 @@ class User(Base, TimestampMixin, SoftDeleteMixin): github_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None) oauth_provider: Mapped[str | None] = mapped_column(String(20), default=None) email_verified: Mapped[bool] = mapped_column(default=False) + + @property + def is_active(self) -> bool: + # Derived, not stored. is_deleted stays the single source of truth. + return not self.is_deleted ``` Mixins from `infrastructure/database/models`: @@ -354,6 +336,8 @@ Mixins from `infrastructure/database/models`: - `TimestampMixin` — `created_at`, `updated_at` - `SoftDeleteMixin` — `is_deleted`, `deleted_at` +The `is_active` property is **derived** from `not is_deleted` — there's no separate column. `crudauth` reads it during login so a soft-deleted user can't authenticate; `is_deleted` remains the single source of truth. + Table name is **`user`** (singular). ## Common CRUD Tasks @@ -464,9 +448,9 @@ class UserClient { All input validation runs server-side via Pydantic schemas. Client-side checks are nice for UX but don't replace server validation. -### Login rate limiting +### Login lockout -The login endpoint is automatically rate-limited via `LOGIN_MAX_ATTEMPTS` per `LOGIN_WINDOW_MINUTES`. See [Sessions](sessions.md#login-rate-limiting). +The login endpoint is automatically throttled by `crudauth` — an escalating per-IP / per-identifier lockout that returns `429` with a `Retry-After` header. There are no env vars to tune it. See [Sessions](sessions.md#login-lockout). ### Generic auth error messages diff --git a/docs/user-guide/configuration/environment-specific.md b/docs/user-guide/configuration/environment-specific.md index b2f83697..79267db9 100644 --- a/docs/user-guide/configuration/environment-specific.md +++ b/docs/user-guide/configuration/environment-specific.md @@ -159,8 +159,9 @@ SESSION_SECURE_COOKIES=true SESSION_TIMEOUT_MINUTES=30 SESSION_BACKEND=redis CSRF_ENABLED=true -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +# Trusted reverse proxies in front of the app, so crudauth keys login lockout on +# the real client IP. Set 1 behind a single nginx/Caddy, 2 if Cloudflare is also in front. +TRUSTED_PROXY_HOPS=1 # Strict CORS CORS_ORIGINS=https://example.com,https://www.example.com diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index 2dc664ac..56b47ffc 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -194,8 +194,12 @@ SESSION_TIMEOUT_MINUTES=30 SESSION_CLEANUP_INTERVAL_MINUTES=15 MAX_SESSIONS_PER_USER=5 SESSION_SECURE_COOKIES=true -SESSION_BACKEND=redis -SESSION_COOKIE_MAX_AGE=86400 +SESSION_BACKEND=redis # redis | memory + +# Number of trusted reverse proxies in front of the app. crudauth uses this to +# resolve the real client IP (from X-Forwarded-For) for login lockout. +# 0 = no proxy; set 1 behind a single nginx/Caddy, 2 if Cloudflare is also in front. +TRUSTED_PROXY_HOPS=0 ``` ### CSRF @@ -205,12 +209,9 @@ SESSION_COOKIE_MAX_AGE=86400 CSRF_ENABLED=true ``` -### Login Rate Limiting +### Login Lockout -```env -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 -``` +There are no env vars for login throttling. `crudauth` applies an escalating per-IP / per-identifier lockout internally and returns `429 Too Many Requests` with a `Retry-After` header once the threshold is hit. Behind a proxy, set `TRUSTED_PROXY_HOPS` (above) so the lockout keys on the real client IP rather than the proxy's. ### OAuth @@ -221,7 +222,7 @@ OAUTH_REDIRECT_BASE_URL=http://localhost:8000 OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_SECRET= -# GitHub OAuth (provider scaffolded; routes not yet wired) +# GitHub OAuth (data model anticipates it; no provider/routes wired — see Authentication) OAUTH_GITHUB_CLIENT_ID= OAUTH_GITHUB_CLIENT_SECRET= ``` diff --git a/docs/user-guide/configuration/index.md b/docs/user-guide/configuration/index.md index 68788f59..987a05f7 100644 --- a/docs/user-guide/configuration/index.md +++ b/docs/user-guide/configuration/index.md @@ -112,12 +112,13 @@ SECRET_KEY=your-super-secret-key-here SESSION_TIMEOUT_MINUTES=30 SESSION_SECURE_COOKIES=true -SESSION_BACKEND=redis +SESSION_BACKEND=redis # redis | memory CSRF_ENABLED=true -LOGIN_MAX_ATTEMPTS=5 -LOGIN_WINDOW_MINUTES=15 +TRUSTED_PROXY_HOPS=0 # trusted reverse proxies in front of the app ``` +Login lockout is applied internally by `crudauth` (escalating per-IP / per-identifier; returns `429` with `Retry-After`) — it is not configured via env vars. + ### Cache (Redis or Memcached) ```env diff --git a/docs/user-guide/configuration/settings-classes.md b/docs/user-guide/configuration/settings-classes.md index aaf795b4..3ccbd86e 100644 --- a/docs/user-guide/configuration/settings-classes.md +++ b/docs/user-guide/configuration/settings-classes.md @@ -63,7 +63,7 @@ The actual classes that ship with the boilerplate, all in `src/infrastructure/co | `CORSSettings` | `CORS_*` | | `CompressionSettings` | `GZIP_*` | | `APIDocSettings` | `ENABLE_DOCS_IN_PRODUCTION`, `OPENAPI_PREFIX` | -| `AuthSettings` | `SECRET_KEY`, `SESSION_*`, `CSRF_*`, `LOGIN_*`, `OAUTH_*` | +| `AuthSettings` | `SECRET_KEY`, `SESSION_*`, `CSRF_ENABLED`, `TRUSTED_PROXY_HOPS`, `OAUTH_*` | | `APISettings` | API path overrides (`API_PREFIX`, `DOCS_URL`, `REDOC_URL`) | | `AppSettings` | `APP_NAME`, `APP_DESCRIPTION`, `VERSION`, `DEBUG`, contact info | | `AdminSettings` | `ADMIN_NAME`, `ADMIN_EMAIL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `DEFAULT_TIER_NAME` | diff --git a/docs/user-guide/database/crud.md b/docs/user-guide/database/crud.md index 38b0f6d3..e3f4d34c 100644 --- a/docs/user-guide/database/crud.md +++ b/docs/user-guide/database/crud.md @@ -324,7 +324,7 @@ from src.modules.user.schemas import ( UserRead, UserUpdate, ) -from src.infrastructure.auth.utils import get_password_hash +from crudauth import get_password_hash async def user_lifecycle(db: AsyncSession) -> None: diff --git a/docs/user-guide/database/models.md b/docs/user-guide/database/models.md index 1f3c9e24..d19b3c1c 100644 --- a/docs/user-guide/database/models.md +++ b/docs/user-guide/database/models.md @@ -176,6 +176,10 @@ class User(Base, TimestampMixin, SoftDeleteMixin): github_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None) oauth_provider: Mapped[str | None] = mapped_column(String(20), default=None) email_verified: Mapped[bool] = mapped_column(default=False) + + @property + def is_active(self) -> bool: + return not self.is_deleted ``` Key points: @@ -183,6 +187,7 @@ Key points: - `init=False` excludes the field from the dataclass `__init__` (used for the primary key and timestamps you don't want callers to set). - `index=True` adds a database index on lookup-heavy columns (`username`, `email`, `tier_id`, OAuth IDs). - `unique=True` enforces uniqueness at the DB level. +- `is_active` is a **derived property**, not a column — it returns `not is_deleted`. The `crudauth` auth library reads it during login so soft-deleted users can't authenticate, while `is_deleted` stays the single source of truth. ## The RateLimit Model diff --git a/docs/user-guide/development.md b/docs/user-guide/development.md index 4ba8375b..7ca30c39 100644 --- a/docs/user-guide/development.md +++ b/docs/user-guide/development.md @@ -159,7 +159,7 @@ Order matters — middleware added later runs **earlier** in the request path. T ## Adding a Custom Dependency -Dependencies belong with the feature they serve. For session-aware dependencies, look at `infrastructure/auth/session/dependencies.py:get_current_user` for a template. +Dependencies belong with the feature they serve. For session-aware dependencies, look at `infrastructure/auth/dependencies.py:get_current_user` for a template. ### Define the factory @@ -167,7 +167,7 @@ Dependencies belong with the feature they serve. For session-aware dependencies, # modules/workspace/dependencies.py from fastapi import Request -from ...infrastructure.auth.session.dependencies import get_current_user +from ...infrastructure.auth.dependencies import get_current_user from ...infrastructure.dependencies import CurrentUserDep @@ -235,12 +235,12 @@ Each subsystem uses a different Redis DB number; see `.env.example` for the conv ### Watch sessions live -If a user reports being logged out unexpectedly, check the session backend directly: +Session storage is managed by the `crudauth` library, so there's no boilerplate `SessionManager` to call. If a user reports being logged out unexpectedly, inspect the session store directly in Redis: -```python -from src.infrastructure.auth.session import SessionManager -manager = SessionManager() -sessions = await manager.get_user_sessions(user_id=42) +```bash +redis-cli +> SELECT 1 # session backend DB (SESSION_REDIS_DB) +> KEYS 'session:*' ``` See [Authentication → Sessions](authentication/sessions.md) for full details. diff --git a/docs/user-guide/production.md b/docs/user-guide/production.md index 2a48ecf0..297cf8d8 100644 --- a/docs/user-guide/production.md +++ b/docs/user-guide/production.md @@ -66,11 +66,12 @@ CACHE_REDIS_HOST= CACHE_REDIS_PASSWORD= # Sessions -SESSION_BACKEND=redis +SESSION_BACKEND=redis # redis | memory SESSION_REDIS_HOST= SESSION_REDIS_PASSWORD= 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_LIMITER_ENABLED=true @@ -232,6 +233,8 @@ The proxy must: - Pass through cookies (`Set-Cookie`) untouched - Set `Host` correctly so the API's URL building works +Set `TRUSTED_PROXY_HOPS` to the number of reverse proxies you've put in front of the app (1 for a single nginx/Caddy, 2 if Cloudflare is also in front). crudauth uses it to read the real client IP from the last trusted hop of `X-Forwarded-For` when applying login lockout — otherwise every request would appear to come from the proxy and the lockout would key on a single IP. + `CORS_ORIGINS` should list your **frontend** origins, not the API origin. Wildcard (`*`) is incompatible with credentialed requests anyway — the validator warns on it for a reason. ## Logging in Production @@ -328,7 +331,7 @@ Read the message — it tells you which check failed. Don't bypass it; fix the u ### "Sessions invalidate after every deploy" -You're on `SESSION_BACKEND=memory`. Switch to `redis` (or `memcached`) and add the relevant `*_REDIS_*` env vars. +You're on `SESSION_BACKEND=memory`. Switch to `redis` and add the relevant `SESSION_REDIS_*` env vars. (Sessions support only `redis` and `memory`; memcached is not a session backend.) ### "Sudden burst of 429s after a config change" diff --git a/docs/user-guide/project-structure.md b/docs/user-guide/project-structure.md index 49e5ec58..6ff6a1c8 100644 --- a/docs/user-guide/project-structure.md +++ b/docs/user-guide/project-structure.md @@ -92,12 +92,12 @@ infrastructure/ │ ├── settings.py │ └── enums.py ├── database/ # SQLAlchemy engine, session, base model -├── auth/ # Session auth, OAuth, HTTP exceptions, route handlers -│ ├── session/ # Server-side sessions (memory/redis/memcached backends) -│ ├── oauth/ # OAuth provider abstractions (Google, GitHub stub) +├── 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, /oauth/google, /check-auth -│ ├── http_exceptions.py -│ └── utils.py +│ └── http_exceptions.py # fastcrud HTTP exception re-export ├── cache/ # Redis/Memcached cache + decorator │ └── backends/ ├── rate_limit/ # Rate limiter middleware + Redis/Memcached backends @@ -213,7 +213,7 @@ Each `modules//` folder owns the entire stack for that feature. Adding FastAPI's `Depends` is used throughout: - **Database session** — `Depends(async_session)` from `infrastructure.database.session` -- **Current user** — `Depends(get_current_user)` from `infrastructure.auth.session.dependencies` +- **Current user** — `Depends(get_current_user)` from `infrastructure.auth.dependencies` - **Superuser only** — `Depends(get_current_superuser)` - **Service instances** — Each module's `routes.py` defines its own `get__service()` factory diff --git a/docs/user-guide/rate-limiting/index.md b/docs/user-guide/rate-limiting/index.md index 820a0feb..ba7607d0 100644 --- a/docs/user-guide/rate-limiting/index.md +++ b/docs/user-guide/rate-limiting/index.md @@ -128,24 +128,26 @@ else: 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 -# infrastructure/auth/rate_limit_user_middleware.py -from starlette.middleware.base import BaseHTTPMiddleware -from src.infrastructure.auth.session.dependencies import _resolve_session_user # pseudo - - -class AttachUserToRateLimitMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request, call_next): - try: - user = await _resolve_session_user(request) - if user: - request.state.user = user - except Exception: - pass - return await call_next(request) +# 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 ``` -Mount this **before** `RateLimiterMiddleware`. The exact resolution code depends on how you reuse your session backend — if this is a setup you need, copy the validation logic from `infrastructure/auth/session/dependencies.py:get_current_user` into the middleware. +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. diff --git a/docs/user-guide/testing.md b/docs/user-guide/testing.md index 6ddd7c03..40856a03 100644 --- a/docs/user-guide/testing.md +++ b/docs/user-guide/testing.md @@ -54,11 +54,13 @@ tests/ │ ├── modules/ │ │ ├── user/ │ │ │ ├── test_service.py -│ │ │ └── test_schemas.py +│ │ │ ├── test_schemas.py +│ │ │ └── test_models.py # e.g. User.is_active derives from not is_deleted │ │ └── tier/ │ │ └── test_service.py │ └── infrastructure/ -│ └── test_session_manager.py +│ └── auth/ +│ └── test_dependencies.py # get_current_user / _superuser / _optional_user └── integration/ ├── api/ │ ├── test_auth.py From bf36fc39e7513f48db480fcf8d29770772b69517 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 23:07:09 -0300 Subject: [PATCH 6/8] docs update --- docs/user-guide/authentication/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md index 70ef1110..ebff3bc3 100644 --- a/docs/user-guide/authentication/index.md +++ b/docs/user-guide/authentication/index.md @@ -91,7 +91,7 @@ curl http://localhost:8000/api/v1/auth/oauth/google # The server creates a session and either redirects or returns JSON. ``` -Only Google is wired (in the `oauth_providers` dict in `infrastructure/auth/oauth.py`). The data model still anticipates GitHub — the `User` model keeps `github_id` and `oauth_provider` columns — but there's no GitHub provider or routes. To add another provider, register it with crudauth's `OAuthProviderFactory` in `infrastructure/auth/oauth.py` and add the matching routes in `infrastructure/auth/routes.py`. +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. ### 3. API Keys (Machine-to-Machine) From e572f3677a34d002afe7280e6d8e21b532c3a88d Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 23:29:25 -0300 Subject: [PATCH 7/8] update pre release --- docs/changelog.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 29eb8d85..55e116d0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,18 +1,22 @@ -# FastAPI Boilerplate Changelog +# Fastro - The Benav Labs FastAPI boilerplate Changelog ## Introduction -The Changelog documents all notable changes to FastAPI Boilerplate, organized by version. For releases before v0.18.0, see [GitHub releases](https://github.com/benavlabs/FastAPI-boilerplate/releases). +The Changelog documents all notable changes to the Fastro FastAPI boilerplate, organized by version. For releases before v0.18.0, see [GitHub releases](https://github.com/benavlabs/FastAPI-boilerplate/releases). For the full narrative on each release — rationale, decisions, migration guide — see the corresponding GitHub release page. ___ -## 0.19.0 - (unreleased) - The crudauth Migration +## Unreleased -The vendored authentication stack — the in-tree `SessionManager`, its storage backends, the OAuth provider framework, and the token/password utilities — has been replaced with the [`crudauth`](https://pypi.org/project/crudauth/) library. The boilerplate now keeps only the wiring: a single `auth = CRUDAuth(...)` singleton in `infrastructure/auth/setup.py`, the FastAPI dependencies that wrap it, the OAuth building blocks, and the route handlers. Session validation, CSRF, login lockout, and session storage all live in the library. +Three changes since v0.18.0: route dependency injection moved to centralized `Annotated[...]` type aliases ([#261](https://github.com/benavlabs/FastAPI-boilerplate/pull/261)), the app metadata (`APP_NAME` / `APP_DESCRIPTION` / `VERSION`) became environment-configurable, and — the headline — the vendored authentication stack was replaced with the [`crudauth`](https://pypi.org/project/crudauth/) library. -This is a **breaking** release for anyone importing from the old auth modules or relying on the removed settings. See Breaking Changes below for the migration. +**The crudauth migration.** The vendored authentication stack — the in-tree `SessionManager`, its storage backends, the OAuth provider framework, and the token/password utilities — has been replaced with crudauth. The boilerplate now keeps only the wiring: a single `auth = CRUDAuth(...)` singleton in `infrastructure/auth/setup.py`, the FastAPI dependencies that wrap it, the OAuth building blocks, and the route handlers. Session validation, CSRF, login lockout, and session storage all live in the library. + +**Annotated type-alias DI** ([#261](https://github.com/benavlabs/FastAPI-boilerplate/pull/261), by [@emiliano-gandini-outeda](https://github.com/emiliano-gandini-outeda)). Route signatures moved from inline `Depends(...)` to centralized `Annotated[..., Depends(...)]` aliases in `infrastructure/dependencies.py`, with per-module `dependencies.py` files holding the service aliases for `user`, `tier`, `rate_limit`, and `api_keys`. + +This is a **breaking** change for anyone importing from the old auth modules or relying on the removed settings. See Breaking Changes below for the migration. --- @@ -23,6 +27,7 @@ This is a **breaking** release for anyone importing from the old auth modules or - New `Principal`-based dependencies `get_current_principal` / `get_optional_principal` alongside the dict-returning `get_current_user` / `get_optional_user` / `get_current_superuser` - **`TRUSTED_PROXY_HOPS` setting** (default `0`) — number of trusted reverse proxies in front of the app, used by crudauth to resolve the real client IP for login lockout. Set `1` behind a single nginx/Caddy. - **`User.is_active` property** — derived from `not is_deleted`; crudauth reads it during login so soft-deleted users can't authenticate. +- **Annotated type-alias dependency injection** ([#261](https://github.com/benavlabs/FastAPI-boilerplate/pull/261)) by [@emiliano-gandini-outeda](https://github.com/emiliano-gandini-outeda) — centralized `Annotated[..., Depends(...)]` aliases in `infrastructure/dependencies.py` and per-module `dependencies.py` (`user`, `tier`, `rate_limit`, `api_keys`) with service aliases; route signatures migrated to use them. #### Changed @@ -30,6 +35,8 @@ This is a **breaking** release for anyone importing from the old auth modules or - `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. +- `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)). #### Removed From d5cde16f20b5b7505696839cd2b8362dce9e00e5 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 23 Jun 2026 23:38:35 -0300 Subject: [PATCH 8/8] some final changes --- backend/pyproject.toml | 2 +- backend/src/interfaces/main.py | 2 +- docs/changelog.md | 5 ++++- docs/user-guide/configuration/environment-variables.md | 2 +- uv.lock | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b8b74f16..781878a0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fastapi-boilerplate" -version = "0.18.0" +version = "0.19.0" description = "Modular FastAPI starter — vertical slices, swappable infrastructure, plugin-ready." authors = [{ name = "Benav Labs", email = "contact@benav.io" }] license = { text = "MIT" } diff --git a/backend/src/interfaces/main.py b/backend/src/interfaces/main.py index 9c3f3037..0156222b 100644 --- a/backend/src/interfaces/main.py +++ b/backend/src/interfaces/main.py @@ -48,7 +48,7 @@ async def lifespan_with_security(app: FastAPI) -> AsyncGenerator[None, None]: * Swappable cache, queue, and rate-limit backends * SQLAdmin admin UI """, - version="0.18.0", + version="0.19.0", contact={ "name": "Benav Labs", "url": "https://github.com/benavlabs/FastAPI-boilerplate", diff --git a/docs/changelog.md b/docs/changelog.md index 55e116d0..622cec9c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,7 +8,7 @@ For the full narrative on each release — rationale, decisions, migration guide ___ -## Unreleased +## 0.19.0 - June 23, 2026 - The crudauth Migration Three changes since v0.18.0: route dependency injection moved to centralized `Annotated[...]` type aliases ([#261](https://github.com/benavlabs/FastAPI-boilerplate/pull/261)), the app metadata (`APP_NAME` / `APP_DESCRIPTION` / `VERSION`) became environment-configurable, and — the headline — the vendored authentication stack was replaced with the [`crudauth`](https://pypi.org/project/crudauth/) library. @@ -55,6 +55,9 @@ This is a **breaking** change for anyone importing from the old auth modules or - **`SESSION_BACKEND=memcached` is no longer valid** — switch to `redis` or `memory`. - **`SessionManager` and friends are gone.** Code that called the session manager directly (e.g. listing a user's sessions) must move to crudauth's session APIs. +**Full release notes**: https://github.com/benavlabs/FastAPI-boilerplate/releases/tag/v0.19.0 +**Full changelog**: https://github.com/benavlabs/FastAPI-boilerplate/compare/v0.18.0...v0.19.0 + ___ ## 0.18.0 - May 24, 2026 - The Pluggable Restructure diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index 56b47ffc..7665399e 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -239,7 +239,7 @@ ADMIN_ENABLED=true # enables /admin DEBUG=false APP_NAME=FastAPI Boilerplate APP_DESCRIPTION=Modular FastAPI starter -VERSION=0.18.0 +VERSION=0.19.0 CONTACT_NAME=Support CONTACT_EMAIL=support@example.com LICENSE_NAME=MIT diff --git a/uv.lock b/uv.lock index 65addbc1..b53e0c3d 100644 --- a/uv.lock +++ b/uv.lock @@ -646,7 +646,7 @@ standard = [ [[package]] name = "fastapi-boilerplate" -version = "0.18.0" +version = "0.19.0" source = { editable = "backend" } dependencies = [ { name = "aiomcache" },