Skip to content

Commit cf4f10f

Browse files
authored
Merge pull request #265 from benavlabs/crudauth-migration
Auth: replace the forked auth stack with the crudauth library
2 parents 637c406 + d5cde16 commit cf4f10f

82 files changed

Lines changed: 965 additions & 6175 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
* Fully async FastAPI + SQLAlchemy 2.0
4141
* Pydantic v2 models & validation
42-
* Server-side sessions + CSRF; OAuth (Google wired, GitHub scaffolded); API keys
42+
* Server-side sessions + CSRF via [crudauth](https://pypi.org/project/crudauth/); OAuth (Google wired); API keys
4343
* Annotated type aliases for all FastAPI dependencies
4444
* Rate limiter with per-tier, per-path rules
4545
* FastCRUD for efficient CRUD & pagination

backend/.env.example

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,16 @@ SESSION_TIMEOUT_MINUTES=30
134134
SESSION_CLEANUP_INTERVAL_MINUTES=15
135135
MAX_SESSIONS_PER_USER=5
136136
SESSION_SECURE_COOKIES=true
137+
# Session storage backend: "redis" or "memory" (memcached is no longer supported)
137138
SESSION_BACKEND=redis
138-
SESSION_COOKIE_MAX_AGE=86400
139139

140140
# CSRF Protection
141141
# Set to false for development/testing to disable CSRF validation
142142
CSRF_ENABLED=true
143143

144-
# Login Rate Limiting
145-
LOGIN_MAX_ATTEMPTS=5
146-
LOGIN_WINDOW_MINUTES=15
144+
# Number of trusted reverse proxies in front of the app, used to resolve the client
145+
# IP for login lockout. 0 = direct (socket peer); set 1 behind a single nginx/Caddy.
146+
TRUSTED_PROXY_HOPS=0
147147

148148
# ===================================
149149
# Admin Interface (SQLAdmin)

backend/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "fastapi-boilerplate"
7-
version = "0.18.0"
7+
version = "0.19.0"
88
description = "Modular FastAPI starter — vertical slices, swappable infrastructure, plugin-ready."
99
authors = [{ name = "Benav Labs", email = "contact@benav.io" }]
1010
license = { text = "MIT" }
@@ -15,7 +15,7 @@ dependencies = [
1515
"aiosqlite>=0.21.0",
1616
"alembic>=1.16.4",
1717
"asyncpg>=0.30.0",
18-
"bcrypt>=5.0.0",
18+
"crudauth[all]>=0.6.0,<0.7.0",
1919
"faker>=37.1.0",
2020
"fastapi[standard]>=0.115.8",
2121
"fastcrud>=0.21.0",

backend/src/infrastructure/app_factory.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from fastapi.openapi.utils import get_openapi
1515

1616
from ..modules.common.utils.error_handler import register_exception_handlers
17-
from .auth.session.dependencies import get_current_superuser
17+
from .auth.dependencies import get_current_superuser
18+
from .auth.setup import auth
1819
from .cache.initialize import close_cache, initialize_cache
1920
from .config.settings import (
2021
CacheSettings,
@@ -62,11 +63,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
6263
if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED:
6364
await initialize_rate_limiter()
6465

66+
await auth.initialize()
67+
6568
initialization_complete.set()
6669

6770
yield
6871

6972
finally:
73+
await auth.shutdown()
74+
7075
if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED:
7176
await close_cache()
7277

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from .session.dependencies import authenticate_user, get_current_superuser, get_current_user, get_optional_user
1+
from .dependencies import get_current_superuser, get_current_user, get_optional_user
22

33
__all__ = [
44
"get_current_user",
55
"get_optional_user",
66
"get_current_superuser",
7-
"authenticate_user",
87
]

backend/src/infrastructure/auth/constants.py

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Auth dependencies: resolve the crudauth ``Principal`` and the dict-compat user.
2+
3+
Routes depend on these; they wrap the crudauth ``auth`` singleton so the session
4+
engine (validation, CSRF, lockout) lives in crudauth while handlers keep their
5+
existing dict/Principal contracts. ``get_current_user`` returns the same user
6+
dict the rest of the app (and the API-key module) already consumes, so the public
7+
contract is unchanged.
8+
"""
9+
10+
from typing import Annotated, Any
11+
12+
from crudauth import Principal
13+
from crudauth.exceptions import ForbiddenException, UnauthorizedException
14+
from fastapi import Depends
15+
from sqlalchemy.ext.asyncio import AsyncSession
16+
17+
from ...modules.user.crud import crud_users
18+
from ..database.session import async_session
19+
from .setup import auth
20+
21+
22+
async def get_current_principal(
23+
principal: Annotated[Principal, Depends(auth.current_user())],
24+
) -> Principal:
25+
"""The authenticated crudauth ``Principal`` (session-validated, CSRF-enforced).
26+
27+
A single named dependency so routes that need the session id
28+
(``principal.metadata["session_id"]``) or the transport can depend on it and
29+
tests can override it. Raises 401 when there is no valid session.
30+
"""
31+
return principal
32+
33+
34+
async def get_optional_principal(
35+
principal: Annotated[Principal | None, Depends(auth.current_user(optional=True))],
36+
) -> Principal | None:
37+
"""The crudauth ``Principal`` if authenticated, else ``None`` (never raises on absence).
38+
39+
Still enforces CSRF on unsafe methods when a session is present.
40+
"""
41+
return principal
42+
43+
44+
async def get_current_user(
45+
principal: Annotated[Principal | None, Depends(get_optional_principal)],
46+
db: Annotated[AsyncSession, Depends(async_session)],
47+
) -> dict[str, Any]:
48+
"""Get the current authenticated user as a dict (resolved by crudauth).
49+
50+
crudauth validates the cookie and enforces CSRF on unsafe methods; we re-load
51+
the full row (filtering soft-deleted users) so the return value stays the dict
52+
the handlers expect.
53+
54+
Raises:
55+
UnauthorizedException: If not authenticated or the user doesn't exist.
56+
"""
57+
credentials_exception = UnauthorizedException("Not authenticated")
58+
59+
if principal is None:
60+
raise credentials_exception
61+
62+
user = await crud_users.get(db=db, id=principal.user_id, is_deleted=False)
63+
64+
if user is None:
65+
raise credentials_exception
66+
67+
return user
68+
69+
70+
async def get_optional_user(
71+
principal: Annotated[Principal | None, Depends(get_optional_principal)],
72+
db: Annotated[AsyncSession, Depends(async_session)],
73+
) -> dict[str, Any] | None:
74+
"""Get the current user as a dict if authenticated, None otherwise."""
75+
if principal is None:
76+
return None
77+
78+
return await crud_users.get(db=db, id=principal.user_id, is_deleted=False)
79+
80+
81+
async def get_current_superuser(
82+
current_user: Annotated[dict[str, Any], Depends(get_current_user)],
83+
) -> dict[str, Any]:
84+
"""Get the current user as a dict, requiring superuser privileges (403 otherwise)."""
85+
if not current_user.get("is_superuser", False):
86+
raise ForbiddenException("Insufficient privileges")
87+
88+
return current_user
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""crudauth OAuth building blocks for the boilerplate's own OAuth routes.
2+
3+
Runs the existing ``/oauth/google`` routes on crudauth's hardened OAuth
4+
(PKCE + signed state + verified-email account linking) without mounting crudauth's
5+
own oauth router - which would change the URLs. We construct the provider, a
6+
per-request state store, and the account-linking service here and drive them from
7+
the route handlers in ``routes.py``.
8+
"""
9+
10+
from crudauth.oauth import OAuthAccountService, OAuthProviderFactory
11+
from crudauth.storage import get_session_storage
12+
13+
from ..config.settings import settings
14+
from .setup import _session_redis_url, _use_redis, auth
15+
16+
OAUTH_STATE_TTL_SECONDS = 1800
17+
18+
_redirect_base = settings.OAUTH_REDIRECT_BASE_URL.rstrip("/")
19+
20+
21+
def _build_provider(name: str, client_id: str, client_secret: str):
22+
return OAuthProviderFactory.create_provider(
23+
name,
24+
client_id=client_id,
25+
client_secret=client_secret,
26+
redirect_uri=f"{_redirect_base}/api/v1/auth/oauth/callback/{name}",
27+
)
28+
29+
30+
# Only Google has a wired route; add a "github" entry here (and its routes) to enable it.
31+
oauth_providers = {
32+
"google": _build_provider("google", settings.OAUTH_GOOGLE_CLIENT_ID, settings.OAUTH_GOOGLE_CLIENT_SECRET),
33+
}
34+
35+
oauth_state_storage = get_session_storage(
36+
"redis" if _use_redis else "memory",
37+
prefix="oauth_state:",
38+
expiration=OAUTH_STATE_TTL_SECONDS,
39+
redis_url=_session_redis_url if _use_redis else None,
40+
)
41+
42+
oauth_account_service = OAuthAccountService(
43+
repo=auth.repo,
44+
new_user_fields=lambda ctx: {"name": ctx.suggested_name},
45+
)

backend/src/infrastructure/auth/oauth/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

backend/src/infrastructure/auth/oauth/dependencies.py

Lines changed: 0 additions & 95 deletions
This file was deleted.

0 commit comments

Comments
 (0)