Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion backend/src/infrastructure/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
3 changes: 1 addition & 2 deletions backend/src/infrastructure/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
3 changes: 0 additions & 3 deletions backend/src/infrastructure/auth/constants.py

This file was deleted.

88 changes: 88 additions & 0 deletions backend/src/infrastructure/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions backend/src/infrastructure/auth/oauth.py
Original file line number Diff line number Diff line change
@@ -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},
)
17 changes: 0 additions & 17 deletions backend/src/infrastructure/auth/oauth/__init__.py

This file was deleted.

95 changes: 0 additions & 95 deletions backend/src/infrastructure/auth/oauth/dependencies.py

This file was deleted.

Loading
Loading