fastapi-startkit-auth 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_startkit_auth/__init__.py +67 -0
- fastapi_startkit_auth/apitokens/__init__.py +13 -0
- fastapi_startkit_auth/apitokens/manager.py +122 -0
- fastapi_startkit_auth/apitokens/models.py +29 -0
- fastapi_startkit_auth/apitokens/repository.py +120 -0
- fastapi_startkit_auth/apitokens/sql.py +152 -0
- fastapi_startkit_auth/application.py +38 -0
- fastapi_startkit_auth/clients/__init__.py +4 -0
- fastapi_startkit_auth/clients/models.py +29 -0
- fastapi_startkit_auth/clients/repository.py +69 -0
- fastapi_startkit_auth/config.py +103 -0
- fastapi_startkit_auth/dependencies.py +93 -0
- fastapi_startkit_auth/exceptions.py +90 -0
- fastapi_startkit_auth/facade.py +107 -0
- fastapi_startkit_auth/grants/__init__.py +13 -0
- fastapi_startkit_auth/grants/authorization_code.py +83 -0
- fastapi_startkit_auth/grants/client_credentials.py +19 -0
- fastapi_startkit_auth/grants/password.py +32 -0
- fastapi_startkit_auth/grants/pkce.py +27 -0
- fastapi_startkit_auth/grants/refresh.py +13 -0
- fastapi_startkit_auth/guards/__init__.py +5 -0
- fastapi_startkit_auth/guards/guard.py +85 -0
- fastapi_startkit_auth/guards/session.py +81 -0
- fastapi_startkit_auth/guards/token.py +58 -0
- fastapi_startkit_auth/manager.py +320 -0
- fastapi_startkit_auth/middleware/__init__.py +4 -0
- fastapi_startkit_auth/middleware/csrf.py +160 -0
- fastapi_startkit_auth/middleware/session.py +117 -0
- fastapi_startkit_auth/passwords/__init__.py +4 -0
- fastapi_startkit_auth/passwords/broker.py +69 -0
- fastapi_startkit_auth/passwords/repository.py +44 -0
- fastapi_startkit_auth/provider.py +69 -0
- fastapi_startkit_auth/providers/__init__.py +4 -0
- fastapi_startkit_auth/providers/base.py +38 -0
- fastapi_startkit_auth/providers/memory.py +69 -0
- fastapi_startkit_auth/providers/model.py +70 -0
- fastapi_startkit_auth/publishable/cors.py +44 -0
- fastapi_startkit_auth/py.typed +0 -0
- fastapi_startkit_auth/routes.py +362 -0
- fastapi_startkit_auth/routes_spa.py +34 -0
- fastapi_startkit_auth/security/__init__.py +4 -0
- fastapi_startkit_auth/security/hashing.py +43 -0
- fastapi_startkit_auth/security/jwt.py +49 -0
- fastapi_startkit_auth/sessions/__init__.py +5 -0
- fastapi_startkit_auth/sessions/models.py +29 -0
- fastapi_startkit_auth/sessions/sql.py +158 -0
- fastapi_startkit_auth/sessions/state.py +11 -0
- fastapi_startkit_auth/sessions/store.py +100 -0
- fastapi_startkit_auth/startkit.py +38 -0
- fastapi_startkit_auth/tokens/__init__.py +9 -0
- fastapi_startkit_auth/tokens/models.py +68 -0
- fastapi_startkit_auth/tokens/repository.py +129 -0
- fastapi_startkit_auth/tokens/service.py +189 -0
- fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
- fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
- fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
- fastapi_startkit_auth-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
from ..exceptions import InvalidSession
|
|
8
|
+
from ..providers.base import UserProvider
|
|
9
|
+
from ..sessions.models import SessionRecord
|
|
10
|
+
from ..sessions.state import FORGET_KEY, SESSION_KEY
|
|
11
|
+
from ..sessions.store import SessionStore
|
|
12
|
+
from .guard import AuthContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionGuard:
|
|
16
|
+
"""Cookie/session guard: resolves the server-side session to a user.
|
|
17
|
+
|
|
18
|
+
Corresponds to a ``{"driver": "session"}`` guard entry. The middleware puts
|
|
19
|
+
the loaded :class:`SessionRecord` on ``request.state``; this guard turns it
|
|
20
|
+
into an :class:`AuthContext` and owns the login/logout primitives used by
|
|
21
|
+
the ``Auth`` facade. Session-authenticated requests get the wildcard scope,
|
|
22
|
+
like a first-party browser login.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, name: str, store: SessionStore, provider: UserProvider, ttl: float | None) -> None:
|
|
26
|
+
self.name = name
|
|
27
|
+
self.store = store
|
|
28
|
+
self.provider = provider
|
|
29
|
+
self.ttl = ttl
|
|
30
|
+
|
|
31
|
+
def user_from_token(self, access_token: str) -> AuthContext:
|
|
32
|
+
"""Resolve a raw session id (the cookie value) to an ``AuthContext``."""
|
|
33
|
+
record = self.store.find(access_token)
|
|
34
|
+
if record is None:
|
|
35
|
+
raise InvalidSession("Not authenticated.")
|
|
36
|
+
return self._context(record)
|
|
37
|
+
|
|
38
|
+
def authenticate(self, request: Request) -> AuthContext:
|
|
39
|
+
"""Resolve the session attached to the request by the middleware."""
|
|
40
|
+
record = getattr(request.state, SESSION_KEY, None)
|
|
41
|
+
if record is None:
|
|
42
|
+
raise InvalidSession("Not authenticated.")
|
|
43
|
+
return self._context(record)
|
|
44
|
+
|
|
45
|
+
def login(self, request: Request, user: Any) -> SessionRecord:
|
|
46
|
+
"""Start a fresh session for ``user``.
|
|
47
|
+
|
|
48
|
+
Always issues a new session id (fixation protection): any session that
|
|
49
|
+
arrived with the request is invalidated server-side and the middleware
|
|
50
|
+
sends the new id in the cookie.
|
|
51
|
+
"""
|
|
52
|
+
previous = getattr(request.state, SESSION_KEY, None)
|
|
53
|
+
record = self.store.create(
|
|
54
|
+
user_id=self.provider.get_identifier(user),
|
|
55
|
+
guard=self.name,
|
|
56
|
+
ttl=self.ttl,
|
|
57
|
+
)
|
|
58
|
+
if previous is not None:
|
|
59
|
+
self.store.invalidate(previous.id)
|
|
60
|
+
setattr(request.state, SESSION_KEY, record)
|
|
61
|
+
setattr(request.state, FORGET_KEY, False)
|
|
62
|
+
return record
|
|
63
|
+
|
|
64
|
+
def logout(self, request: Request) -> None:
|
|
65
|
+
"""Invalidate the server-side session and mark the cookie for deletion."""
|
|
66
|
+
record = getattr(request.state, SESSION_KEY, None)
|
|
67
|
+
if record is not None:
|
|
68
|
+
self.store.invalidate(record.id)
|
|
69
|
+
setattr(request.state, SESSION_KEY, None)
|
|
70
|
+
setattr(request.state, FORGET_KEY, True)
|
|
71
|
+
|
|
72
|
+
def _context(self, record: SessionRecord) -> AuthContext:
|
|
73
|
+
if record.user_id is None:
|
|
74
|
+
# Guest session (issued by /__auth__/csrf-cookie): it carries a CSRF
|
|
75
|
+
# token but no user, and must survive 401s so the SPA can log in.
|
|
76
|
+
raise InvalidSession("Not authenticated.")
|
|
77
|
+
user = self.provider.retrieve_by_id(record.user_id)
|
|
78
|
+
if user is None:
|
|
79
|
+
self.store.invalidate(record.id)
|
|
80
|
+
raise InvalidSession("The session user no longer exists.")
|
|
81
|
+
return AuthContext(user=user, scopes=["*"])
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
|
|
5
|
+
from ..apitokens.manager import ApiTokenManager
|
|
6
|
+
from ..exceptions import InvalidToken
|
|
7
|
+
from ..providers.base import UserProvider
|
|
8
|
+
from .guard import AuthContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenGuard:
|
|
12
|
+
"""Opaque API-token guard (Sanctum-style).
|
|
13
|
+
|
|
14
|
+
Corresponds to a ``{"driver": "token"}`` guard entry. Resolves
|
|
15
|
+
``Authorization: Bearer {id}|{secret}`` — or the raw token in a configured
|
|
16
|
+
custom header — through :class:`ApiTokenManager` and turns the record's
|
|
17
|
+
abilities into the ``AuthContext`` scopes, so ``require_scopes`` works
|
|
18
|
+
unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
tokens: ApiTokenManager,
|
|
25
|
+
provider: UserProvider,
|
|
26
|
+
header: str = "Authorization",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.name = name
|
|
29
|
+
self._tokens = tokens
|
|
30
|
+
self.provider = provider
|
|
31
|
+
self.header = header
|
|
32
|
+
|
|
33
|
+
def authenticate(self, request: Request) -> AuthContext:
|
|
34
|
+
"""Resolve the request's configured token header to an ``AuthContext``."""
|
|
35
|
+
token = self._extract(request)
|
|
36
|
+
if not token:
|
|
37
|
+
raise InvalidToken("Not authenticated.")
|
|
38
|
+
return self.user_from_token(token)
|
|
39
|
+
|
|
40
|
+
def user_from_token(self, access_token: str) -> AuthContext:
|
|
41
|
+
record = self._tokens.verify(access_token)
|
|
42
|
+
user = self.provider.retrieve_by_id(record.user_id)
|
|
43
|
+
if user is None:
|
|
44
|
+
# Orphaned token (user deleted): revoke it, fail generically.
|
|
45
|
+
self._tokens.revoke(record.id)
|
|
46
|
+
raise InvalidToken("The API token is invalid.")
|
|
47
|
+
return AuthContext(user=user, scopes=list(record.abilities))
|
|
48
|
+
|
|
49
|
+
def _extract(self, request: Request) -> str | None:
|
|
50
|
+
raw = request.headers.get(self.header)
|
|
51
|
+
if raw is None:
|
|
52
|
+
return None
|
|
53
|
+
if self.header.lower() == "authorization":
|
|
54
|
+
scheme, _, value = raw.partition(" ")
|
|
55
|
+
if scheme.lower() != "bearer":
|
|
56
|
+
return None
|
|
57
|
+
return value.strip()
|
|
58
|
+
return raw.strip()
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .apitokens.manager import ApiTokenManager
|
|
8
|
+
from .apitokens.repository import ApiTokenRepository, InMemoryApiTokenRepository
|
|
9
|
+
from .apitokens.sql import SqlApiTokenRepository
|
|
10
|
+
from .clients.repository import InMemoryClientRepository
|
|
11
|
+
from .config import AuthConfig, as_config_class
|
|
12
|
+
from .grants.authorization_code import AuthorizationCodeGrant
|
|
13
|
+
from .grants.client_credentials import ClientCredentialsGrant
|
|
14
|
+
from .grants.password import PasswordGrant
|
|
15
|
+
from .grants.refresh import RefreshTokenGrant
|
|
16
|
+
from .guards.guard import Guard, PassportGuard
|
|
17
|
+
from .guards.session import SessionGuard
|
|
18
|
+
from .guards.token import TokenGuard
|
|
19
|
+
from .passwords.broker import PasswordBroker
|
|
20
|
+
from .passwords.repository import InMemoryPasswordResetRepository
|
|
21
|
+
from .providers.base import UserProvider
|
|
22
|
+
from .providers.memory import InMemoryUserProvider
|
|
23
|
+
from .providers.model import ModelUserProvider
|
|
24
|
+
from .security.hashing import BcryptHasher
|
|
25
|
+
from .security.jwt import JWTEncoder
|
|
26
|
+
from .sessions.sql import SqlSessionStore
|
|
27
|
+
from .sessions.store import InMemorySessionStore, SessionStore
|
|
28
|
+
from .tokens.repository import InMemoryTokenRepository
|
|
29
|
+
from .tokens.service import TokenService
|
|
30
|
+
|
|
31
|
+
GuardFactory = Callable[[str, dict[str, Any]], Guard]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_connection(connection: Any) -> Any:
|
|
35
|
+
"""Return an open DB-API connection from either a connection or a factory.
|
|
36
|
+
|
|
37
|
+
A raw connection (e.g. ``sqlite3.Connection``) is itself callable, so we
|
|
38
|
+
duck-type on ``execute`` — which every SQL store relies on — instead of
|
|
39
|
+
``callable()`` to tell an already-open connection apart from a zero-arg
|
|
40
|
+
factory that produces one.
|
|
41
|
+
"""
|
|
42
|
+
if hasattr(connection, "execute"):
|
|
43
|
+
return connection
|
|
44
|
+
if callable(connection):
|
|
45
|
+
return connection()
|
|
46
|
+
return connection
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthManager:
|
|
50
|
+
"""Central registry built from an :class:`AuthConfig`.
|
|
51
|
+
|
|
52
|
+
Wires the shared hasher, JWT encoder, token store/service, client registry,
|
|
53
|
+
guards, providers, grant handlers, and password brokers, and resolves them by
|
|
54
|
+
the names declared in config. This is what ``AuthProvider`` registers onto the
|
|
55
|
+
application and what the FastAPI dependencies read from.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: Any) -> None:
|
|
59
|
+
self._config = as_config_class(config)
|
|
60
|
+
cfg = self._config
|
|
61
|
+
|
|
62
|
+
rounds = cfg.get("bcrypt_rounds", 12)
|
|
63
|
+
self.hasher = BcryptHasher(rounds=rounds)
|
|
64
|
+
|
|
65
|
+
key = cfg.get("key")
|
|
66
|
+
if not key:
|
|
67
|
+
key = secrets.token_urlsafe(48)
|
|
68
|
+
warnings.warn(
|
|
69
|
+
"AuthConfig.key is not set; generated an ephemeral signing key. "
|
|
70
|
+
"Tokens will be invalidated on restart. Set a stable key in production.",
|
|
71
|
+
stacklevel=2,
|
|
72
|
+
)
|
|
73
|
+
self.encoder = JWTEncoder(secret=key, algorithm=cfg.get("algorithm", "HS256"))
|
|
74
|
+
|
|
75
|
+
self.token_repository = InMemoryTokenRepository()
|
|
76
|
+
self.token_service = TokenService(
|
|
77
|
+
encoder=self.encoder,
|
|
78
|
+
repository=self.token_repository,
|
|
79
|
+
access_ttl=cfg.get("access_token_ttl", 3600),
|
|
80
|
+
refresh_ttl=cfg.get("refresh_token_ttl", 1209600),
|
|
81
|
+
personal_access_ttl=cfg.get("personal_access_token_ttl", 31536000),
|
|
82
|
+
)
|
|
83
|
+
self.client_repository = InMemoryClientRepository(hasher=self.hasher)
|
|
84
|
+
|
|
85
|
+
self._notifier = cfg.get("password_reset_notifier")
|
|
86
|
+
self.debug_expose_reset_token = bool(cfg.get("debug_expose_reset_token", False))
|
|
87
|
+
|
|
88
|
+
self.session_config = {**AuthConfig.session, **cfg.get("session", {})}
|
|
89
|
+
self.spa_config = {**AuthConfig.spa, **cfg.get("spa", {})}
|
|
90
|
+
self.api_tokens_config = {**AuthConfig.api_tokens, **cfg.get("api_tokens", {})}
|
|
91
|
+
self._session_store: SessionStore | None = None
|
|
92
|
+
self._api_tokens: ApiTokenManager | None = None
|
|
93
|
+
|
|
94
|
+
self._providers: dict[str, UserProvider] = {
|
|
95
|
+
name: self._build_provider(spec) for name, spec in cfg.get("providers", {}).items()
|
|
96
|
+
}
|
|
97
|
+
self._guard_drivers: dict[str, GuardFactory] = {}
|
|
98
|
+
self.register_guard_driver("passport", self._build_passport_guard)
|
|
99
|
+
self.register_guard_driver("session", self._build_session_guard)
|
|
100
|
+
self.register_guard_driver("token", self._build_token_guard)
|
|
101
|
+
self._guards: dict[str, Guard] = {
|
|
102
|
+
name: self._build_guard(name, spec) for name, spec in cfg.get("guards", {}).items()
|
|
103
|
+
}
|
|
104
|
+
self._brokers: dict[str, PasswordBroker] = {
|
|
105
|
+
name: self._build_broker(spec) for name, spec in cfg.get("passwords", {}).items()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# --- construction helpers -----------------------------------------
|
|
109
|
+
def _build_provider(self, spec: dict[str, Any]) -> UserProvider:
|
|
110
|
+
driver = spec.get("driver", "instance")
|
|
111
|
+
if driver == "instance":
|
|
112
|
+
return spec["instance"]
|
|
113
|
+
if driver == "factory":
|
|
114
|
+
return spec["factory"]()
|
|
115
|
+
if driver == "memory":
|
|
116
|
+
provider = InMemoryUserProvider(
|
|
117
|
+
hasher=self.hasher,
|
|
118
|
+
username_field=spec.get("username_field", "email"),
|
|
119
|
+
)
|
|
120
|
+
for user in spec.get("users", []):
|
|
121
|
+
provider.add(user)
|
|
122
|
+
return provider
|
|
123
|
+
if driver in ("masoniteorm", "orm", "model"):
|
|
124
|
+
return ModelUserProvider(
|
|
125
|
+
model=spec["model"],
|
|
126
|
+
hasher=self.hasher,
|
|
127
|
+
username_field=spec.get("username_field", "email"),
|
|
128
|
+
password_field=spec.get("password_field", "password"),
|
|
129
|
+
id_field=spec.get("id_field", "id"),
|
|
130
|
+
)
|
|
131
|
+
raise ValueError(f"Unknown user provider driver: {driver!r}")
|
|
132
|
+
|
|
133
|
+
def register_guard_driver(self, driver: str, factory: GuardFactory) -> None:
|
|
134
|
+
"""Register a factory that builds a guard for a config ``driver`` key.
|
|
135
|
+
|
|
136
|
+
Guards are constructed by their ``spec["driver"]`` rather than hardcoded,
|
|
137
|
+
so app-defined drivers register alongside the built-in ``passport``,
|
|
138
|
+
``session``, and ``token`` drivers without touching the resolution
|
|
139
|
+
logic.
|
|
140
|
+
|
|
141
|
+
Ordering: config-declared guards are built during ``__init__`` right after
|
|
142
|
+
the ``passport`` driver registers, so calling this post-construction does
|
|
143
|
+
NOT retroactively build config-declared guards — register custom drivers
|
|
144
|
+
before or at construction (e.g. in a subclass ``__init__`` before
|
|
145
|
+
``super().__init__``, or by extending this manager).
|
|
146
|
+
"""
|
|
147
|
+
self._guard_drivers[driver] = factory
|
|
148
|
+
|
|
149
|
+
def _build_guard(self, name: str, spec: dict[str, Any]) -> Guard:
|
|
150
|
+
driver = spec.get("driver", "passport")
|
|
151
|
+
factory = self._guard_drivers.get(driver)
|
|
152
|
+
if factory is None:
|
|
153
|
+
raise ValueError(f"Unknown auth guard driver: {driver!r}")
|
|
154
|
+
return factory(name, spec)
|
|
155
|
+
|
|
156
|
+
def _build_passport_guard(self, name: str, spec: dict[str, Any]) -> Guard:
|
|
157
|
+
provider = self._require_provider(spec.get("provider"))
|
|
158
|
+
return PassportGuard(name=name, token_service=self.token_service, provider=provider)
|
|
159
|
+
|
|
160
|
+
def _build_session_guard(self, name: str, spec: dict[str, Any]) -> Guard:
|
|
161
|
+
provider = self._require_provider(spec.get("provider"))
|
|
162
|
+
if not self.session_config.get("secure", True):
|
|
163
|
+
warnings.warn(
|
|
164
|
+
"AuthConfig.session['secure'] is False; the session cookie will be sent "
|
|
165
|
+
"over plain HTTP. Enable it in production.",
|
|
166
|
+
stacklevel=2,
|
|
167
|
+
)
|
|
168
|
+
return SessionGuard(
|
|
169
|
+
name=name,
|
|
170
|
+
store=self.session_store,
|
|
171
|
+
provider=provider,
|
|
172
|
+
ttl=self.session_config.get("ttl"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _build_token_guard(self, name: str, spec: dict[str, Any]) -> Guard:
|
|
176
|
+
provider = self._require_provider(spec.get("provider"))
|
|
177
|
+
return TokenGuard(
|
|
178
|
+
name=name,
|
|
179
|
+
tokens=self.api_tokens,
|
|
180
|
+
provider=provider,
|
|
181
|
+
header=self.api_tokens_config.get("header", "Authorization"),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def api_tokens(self) -> ApiTokenManager:
|
|
186
|
+
"""The API-token manager, built on first use.
|
|
187
|
+
|
|
188
|
+
Lazy for the same reason as :attr:`session_store`: apps without a token
|
|
189
|
+
guard never pay for (or have to configure) the repository — notably the
|
|
190
|
+
SQL connection.
|
|
191
|
+
"""
|
|
192
|
+
if self._api_tokens is None:
|
|
193
|
+
self._api_tokens = ApiTokenManager(
|
|
194
|
+
repository=self._build_api_token_repository(),
|
|
195
|
+
default_ttl=self.api_tokens_config.get("ttl"),
|
|
196
|
+
purge_interval=self.api_tokens_config.get("purge_interval", 300),
|
|
197
|
+
)
|
|
198
|
+
return self._api_tokens
|
|
199
|
+
|
|
200
|
+
def _build_api_token_repository(self) -> ApiTokenRepository:
|
|
201
|
+
spec = self.api_tokens_config
|
|
202
|
+
kind = spec.get("store", "memory")
|
|
203
|
+
if kind == "memory":
|
|
204
|
+
return InMemoryApiTokenRepository()
|
|
205
|
+
if kind == "sql":
|
|
206
|
+
connection = spec.get("connection")
|
|
207
|
+
if connection is None:
|
|
208
|
+
raise ValueError('AuthConfig.api_tokens with store "sql" requires a "connection".')
|
|
209
|
+
connection = _resolve_connection(connection)
|
|
210
|
+
return SqlApiTokenRepository(
|
|
211
|
+
connection,
|
|
212
|
+
table=spec.get("table", "personal_api_tokens"),
|
|
213
|
+
)
|
|
214
|
+
if kind == "instance":
|
|
215
|
+
return spec["instance"]
|
|
216
|
+
raise ValueError(f"Unknown api_tokens store: {kind!r}")
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def session_store(self) -> SessionStore:
|
|
220
|
+
"""The configured session store, built on first use.
|
|
221
|
+
|
|
222
|
+
Lazy so apps without a session guard never pay for (or have to
|
|
223
|
+
configure) a store — notably the SQL connection.
|
|
224
|
+
"""
|
|
225
|
+
if self._session_store is None:
|
|
226
|
+
self._session_store = self._build_session_store()
|
|
227
|
+
return self._session_store
|
|
228
|
+
|
|
229
|
+
def _build_session_store(self) -> SessionStore:
|
|
230
|
+
spec = self.session_config
|
|
231
|
+
kind = spec.get("store", "memory")
|
|
232
|
+
idle_ttl = spec.get("idle_ttl")
|
|
233
|
+
if kind == "memory":
|
|
234
|
+
return InMemorySessionStore(idle_ttl=idle_ttl)
|
|
235
|
+
if kind == "sql":
|
|
236
|
+
connection = spec.get("connection")
|
|
237
|
+
if connection is None:
|
|
238
|
+
raise ValueError('AuthConfig.session with store "sql" requires a "connection".')
|
|
239
|
+
connection = _resolve_connection(connection)
|
|
240
|
+
return SqlSessionStore(
|
|
241
|
+
connection,
|
|
242
|
+
table=spec.get("table", "sessions"),
|
|
243
|
+
idle_ttl=idle_ttl,
|
|
244
|
+
purge_interval=spec.get("purge_interval", 300),
|
|
245
|
+
)
|
|
246
|
+
if kind == "instance":
|
|
247
|
+
return spec["instance"]
|
|
248
|
+
raise ValueError(f"Unknown session store: {kind!r}")
|
|
249
|
+
|
|
250
|
+
def session_guard_name(self) -> str | None:
|
|
251
|
+
"""Name of the first configured session guard, or ``None``."""
|
|
252
|
+
return next(
|
|
253
|
+
(name for name, guard in self._guards.items() if isinstance(guard, SessionGuard)),
|
|
254
|
+
None,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def has_session_guard(self) -> bool:
|
|
258
|
+
return self.session_guard_name() is not None
|
|
259
|
+
|
|
260
|
+
def spa_enabled(self) -> bool:
|
|
261
|
+
return bool(self.spa_config.get("enabled"))
|
|
262
|
+
|
|
263
|
+
def _build_broker(self, spec: dict[str, Any]) -> PasswordBroker:
|
|
264
|
+
provider = self._resolve_broker_provider(spec.get("provider"))
|
|
265
|
+
return PasswordBroker(
|
|
266
|
+
user_provider=provider,
|
|
267
|
+
repository=InMemoryPasswordResetRepository(),
|
|
268
|
+
hasher=self.hasher,
|
|
269
|
+
expire_minutes=spec.get("expire", 60),
|
|
270
|
+
throttle_seconds=spec.get("throttle", 60),
|
|
271
|
+
notifier=self._notifier,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _resolve_broker_provider(self, name: str | None) -> UserProvider:
|
|
275
|
+
# The passwords `provider` key may reference a providers entry; fall back
|
|
276
|
+
# to the default guard's provider if the name doesn't resolve.
|
|
277
|
+
if name and name in self._providers:
|
|
278
|
+
return self._providers[name]
|
|
279
|
+
default_guard = self._config.get("default", {}).get("guard")
|
|
280
|
+
guard_spec = self._config.get("guards", {}).get(default_guard, {})
|
|
281
|
+
return self._require_provider(guard_spec.get("provider"))
|
|
282
|
+
|
|
283
|
+
def _require_provider(self, name: str | None) -> UserProvider:
|
|
284
|
+
if name not in self._providers:
|
|
285
|
+
raise ValueError(f"Auth provider {name!r} is not defined in AuthConfig.providers")
|
|
286
|
+
return self._providers[name]
|
|
287
|
+
|
|
288
|
+
# --- public accessors ---------------------------------------------
|
|
289
|
+
def guard(self, name: str | None = None) -> Guard:
|
|
290
|
+
name = name or self._config.get("default", {}).get("guard")
|
|
291
|
+
if name not in self._guards:
|
|
292
|
+
raise ValueError(f"Auth guard {name!r} is not defined in AuthConfig.guards")
|
|
293
|
+
return self._guards[name]
|
|
294
|
+
|
|
295
|
+
def provider(self, name: str) -> UserProvider:
|
|
296
|
+
return self._require_provider(name)
|
|
297
|
+
|
|
298
|
+
def broker(self, name: str | None = None) -> PasswordBroker:
|
|
299
|
+
name = name or self._config.get("default", {}).get("passwords")
|
|
300
|
+
if name not in self._brokers:
|
|
301
|
+
raise ValueError(f"Password broker {name!r} is not defined in AuthConfig.passwords")
|
|
302
|
+
return self._brokers[name]
|
|
303
|
+
|
|
304
|
+
def default_guard_name(self) -> str:
|
|
305
|
+
return self._config.get("default", {}).get("guard")
|
|
306
|
+
|
|
307
|
+
# --- grant factories ----------------------------------------------
|
|
308
|
+
def password_grant(self, guard: str | None = None) -> PasswordGrant:
|
|
309
|
+
return PasswordGrant(self.token_service, self.guard(guard).provider)
|
|
310
|
+
|
|
311
|
+
def client_credentials_grant(self) -> ClientCredentialsGrant:
|
|
312
|
+
return ClientCredentialsGrant(self.token_service)
|
|
313
|
+
|
|
314
|
+
def refresh_grant(self) -> RefreshTokenGrant:
|
|
315
|
+
return RefreshTokenGrant(self.token_service)
|
|
316
|
+
|
|
317
|
+
def authorization_code_grant(self) -> AuthorizationCodeGrant:
|
|
318
|
+
return AuthorizationCodeGrant(
|
|
319
|
+
self.token_service, code_ttl=self._config.get("authorization_code_ttl", 600)
|
|
320
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from http.cookies import SimpleCookie
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from starlette.datastructures import MutableHeaders
|
|
8
|
+
from starlette.responses import JSONResponse, Response
|
|
9
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
10
|
+
|
|
11
|
+
from ..exceptions import CsrfTokenMismatch
|
|
12
|
+
from ..sessions.state import FORGET_KEY, SESSION_KEY
|
|
13
|
+
|
|
14
|
+
UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CsrfMiddleware:
|
|
18
|
+
"""Session-bound double-submit CSRF protection for SPA cookie auth.
|
|
19
|
+
|
|
20
|
+
Runs *inside* :class:`~.session.SessionMiddleware` (added to the app before
|
|
21
|
+
it), so the session record is already on the request state.
|
|
22
|
+
|
|
23
|
+
Request phase — unsafe methods (POST/PUT/PATCH/DELETE) on requests that
|
|
24
|
+
carry a live session must present the configured header (default
|
|
25
|
+
``X-XSRF-TOKEN``) matching the session's ``csrf_token``; the comparison is
|
|
26
|
+
constant-time and failure is a 403 ``csrf_token_mismatch``. Requests
|
|
27
|
+
without a session (bearer/token clients — no ambient credential) and safe
|
|
28
|
+
methods are exempt, as are configured exempt paths. When
|
|
29
|
+
``stateful_origins`` is set, a foreign ``Origin`` header is rejected before
|
|
30
|
+
the token is even checked (defense in depth).
|
|
31
|
+
|
|
32
|
+
Response phase — whenever the session's CSRF token differs from the
|
|
33
|
+
incoming CSRF cookie (first issue, or rotation on login), the cookie
|
|
34
|
+
(default ``XSRF-TOKEN``) is (re)set. It is deliberately **not** HttpOnly:
|
|
35
|
+
the SPA must read it to echo it in the header, and it is useless to an
|
|
36
|
+
attacker without the HttpOnly session cookie. A stale cookie with no
|
|
37
|
+
backing session is deleted.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
app: ASGIApp,
|
|
43
|
+
cookie: str = "XSRF-TOKEN",
|
|
44
|
+
header: str = "X-XSRF-TOKEN",
|
|
45
|
+
exempt_paths: Iterable[str] = (),
|
|
46
|
+
stateful_origins: Iterable[str] = (),
|
|
47
|
+
ttl: float | None = 7200,
|
|
48
|
+
same_site: str = "lax",
|
|
49
|
+
secure: bool = True,
|
|
50
|
+
domain: str | None = None,
|
|
51
|
+
path: str = "/",
|
|
52
|
+
) -> None:
|
|
53
|
+
self.app = app
|
|
54
|
+
self.cookie = cookie
|
|
55
|
+
self.header = header.lower().encode("latin-1")
|
|
56
|
+
self.exempt_paths = tuple(exempt_paths)
|
|
57
|
+
self.stateful_origins = frozenset(stateful_origins)
|
|
58
|
+
self.ttl = ttl
|
|
59
|
+
self.same_site = same_site
|
|
60
|
+
self.secure = secure
|
|
61
|
+
self.domain = domain
|
|
62
|
+
self.path = path
|
|
63
|
+
|
|
64
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
65
|
+
if scope["type"] != "http":
|
|
66
|
+
await self.app(scope, receive, send)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
state = scope.setdefault("state", {})
|
|
70
|
+
record = state.get(SESSION_KEY)
|
|
71
|
+
if scope["method"] in UNSAFE_METHODS and record is not None:
|
|
72
|
+
rejection = self._reject(scope, record)
|
|
73
|
+
if rejection is not None:
|
|
74
|
+
await rejection(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
incoming_token = self._read_cookie(scope)
|
|
78
|
+
|
|
79
|
+
async def send_wrapper(message: Message) -> None:
|
|
80
|
+
if message["type"] == "http.response.start":
|
|
81
|
+
header = self._cookie_header(state, incoming_token)
|
|
82
|
+
if header is not None:
|
|
83
|
+
MutableHeaders(scope=message).append("set-cookie", header)
|
|
84
|
+
await send(message)
|
|
85
|
+
|
|
86
|
+
await self.app(scope, receive, send_wrapper)
|
|
87
|
+
|
|
88
|
+
def _reject(self, scope: Scope, record: Any) -> Response | None:
|
|
89
|
+
origin = self._header_value(scope, b"origin")
|
|
90
|
+
if self.stateful_origins and origin is not None and origin not in self.stateful_origins:
|
|
91
|
+
return self._forbidden("Origin is not in the configured stateful origins.")
|
|
92
|
+
if self._exempt(scope["path"]):
|
|
93
|
+
return None
|
|
94
|
+
supplied = self._header_value(scope, self.header)
|
|
95
|
+
if supplied is None or not secrets.compare_digest(
|
|
96
|
+
supplied.encode("latin-1"), record.csrf_token.encode("latin-1")
|
|
97
|
+
):
|
|
98
|
+
return self._forbidden("CSRF token missing or invalid.")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _forbidden(description: str) -> Response:
|
|
103
|
+
error = CsrfTokenMismatch(description)
|
|
104
|
+
return JSONResponse(status_code=error.status_code, content=error.to_dict())
|
|
105
|
+
|
|
106
|
+
def _exempt(self, path: str) -> bool:
|
|
107
|
+
for pattern in self.exempt_paths:
|
|
108
|
+
if pattern.endswith("*"):
|
|
109
|
+
if path.startswith(pattern[:-1]):
|
|
110
|
+
return True
|
|
111
|
+
elif path == pattern:
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _header_value(scope: Scope, name: bytes) -> str | None:
|
|
117
|
+
raw = next((value for key, value in scope.get("headers", []) if key == name), None)
|
|
118
|
+
return raw.decode("latin-1") if raw is not None else None
|
|
119
|
+
|
|
120
|
+
def _read_cookie(self, scope: Scope) -> str | None:
|
|
121
|
+
raw = self._header_value(scope, b"cookie")
|
|
122
|
+
if raw is None:
|
|
123
|
+
return None
|
|
124
|
+
jar: SimpleCookie = SimpleCookie()
|
|
125
|
+
jar.load(raw)
|
|
126
|
+
morsel = jar.get(self.cookie)
|
|
127
|
+
return morsel.value if morsel is not None else None
|
|
128
|
+
|
|
129
|
+
def _cookie_header(self, state: dict[str, Any], incoming_token: str | None) -> str | None:
|
|
130
|
+
record = state.get(SESSION_KEY)
|
|
131
|
+
response = Response()
|
|
132
|
+
if record is not None:
|
|
133
|
+
if incoming_token == record.csrf_token:
|
|
134
|
+
return None
|
|
135
|
+
response.set_cookie(
|
|
136
|
+
self.cookie,
|
|
137
|
+
record.csrf_token,
|
|
138
|
+
max_age=int(self.ttl) if self.ttl is not None else None,
|
|
139
|
+
path=self.path,
|
|
140
|
+
domain=self.domain,
|
|
141
|
+
secure=self.secure,
|
|
142
|
+
httponly=False,
|
|
143
|
+
samesite=self.same_site,
|
|
144
|
+
)
|
|
145
|
+
elif state.get(FORGET_KEY) or incoming_token is not None:
|
|
146
|
+
response.delete_cookie(
|
|
147
|
+
self.cookie,
|
|
148
|
+
path=self.path,
|
|
149
|
+
domain=self.domain,
|
|
150
|
+
secure=self.secure,
|
|
151
|
+
httponly=False,
|
|
152
|
+
samesite=self.same_site,
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
return None
|
|
156
|
+
return next(
|
|
157
|
+
value.decode("latin-1")
|
|
158
|
+
for name, value in response.raw_headers
|
|
159
|
+
if name == b"set-cookie"
|
|
160
|
+
)
|