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,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http.cookies import SimpleCookie
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from starlette.datastructures import MutableHeaders
|
|
7
|
+
from starlette.responses import Response
|
|
8
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from ..sessions.state import FORGET_KEY, LOADED_ID_KEY, SESSION_KEY
|
|
11
|
+
from ..sessions.store import SessionStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionMiddleware:
|
|
15
|
+
"""Loads the server-side session from the cookie and issues the cookie back.
|
|
16
|
+
|
|
17
|
+
Before the app runs: parse the configured cookie, look the id up in the
|
|
18
|
+
store, and attach the live :class:`SessionRecord` (or ``None``) to
|
|
19
|
+
``request.state``. After the app decides (via ``Auth.login`` / ``logout``):
|
|
20
|
+
|
|
21
|
+
- a session with an id different from the incoming cookie gets ``Set-Cookie``
|
|
22
|
+
with the configured flags (HttpOnly / SameSite / Secure);
|
|
23
|
+
- an explicit logout — or a stale cookie that resolved to no session —
|
|
24
|
+
gets a deletion ``Set-Cookie``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
app: ASGIApp,
|
|
30
|
+
store: SessionStore,
|
|
31
|
+
cookie: str = "startkit_session",
|
|
32
|
+
ttl: float | None = 7200,
|
|
33
|
+
http_only: bool = True,
|
|
34
|
+
same_site: str = "lax",
|
|
35
|
+
secure: bool = True,
|
|
36
|
+
domain: str | None = None,
|
|
37
|
+
path: str = "/",
|
|
38
|
+
) -> None:
|
|
39
|
+
self.app = app
|
|
40
|
+
self.store = store
|
|
41
|
+
self.cookie = cookie
|
|
42
|
+
self.ttl = ttl
|
|
43
|
+
self.http_only = http_only
|
|
44
|
+
self.same_site = same_site
|
|
45
|
+
self.secure = secure
|
|
46
|
+
self.domain = domain
|
|
47
|
+
self.path = path
|
|
48
|
+
|
|
49
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
50
|
+
if scope["type"] != "http":
|
|
51
|
+
await self.app(scope, receive, send)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
state = scope.setdefault("state", {})
|
|
55
|
+
incoming_id = self._read_cookie(scope)
|
|
56
|
+
record = self.store.find(incoming_id) if incoming_id else None
|
|
57
|
+
if record is not None:
|
|
58
|
+
self.store.touch(record.id)
|
|
59
|
+
state[SESSION_KEY] = record
|
|
60
|
+
state[LOADED_ID_KEY] = incoming_id
|
|
61
|
+
state[FORGET_KEY] = False
|
|
62
|
+
|
|
63
|
+
async def send_wrapper(message: Message) -> None:
|
|
64
|
+
if message["type"] == "http.response.start":
|
|
65
|
+
header = self._cookie_header(state)
|
|
66
|
+
if header is not None:
|
|
67
|
+
MutableHeaders(scope=message).append("set-cookie", header)
|
|
68
|
+
await send(message)
|
|
69
|
+
|
|
70
|
+
await self.app(scope, receive, send_wrapper)
|
|
71
|
+
|
|
72
|
+
def _read_cookie(self, scope: Scope) -> str | None:
|
|
73
|
+
raw = next(
|
|
74
|
+
(value for name, value in scope.get("headers", []) if name == b"cookie"),
|
|
75
|
+
None,
|
|
76
|
+
)
|
|
77
|
+
if raw is None:
|
|
78
|
+
return None
|
|
79
|
+
jar: SimpleCookie = SimpleCookie()
|
|
80
|
+
jar.load(raw.decode("latin-1"))
|
|
81
|
+
morsel = jar.get(self.cookie)
|
|
82
|
+
return morsel.value if morsel is not None else None
|
|
83
|
+
|
|
84
|
+
def _cookie_header(self, state: dict[str, Any]) -> str | None:
|
|
85
|
+
record = state.get(SESSION_KEY)
|
|
86
|
+
incoming_id = state.get(LOADED_ID_KEY)
|
|
87
|
+
response = Response()
|
|
88
|
+
if record is not None:
|
|
89
|
+
if record.id == incoming_id:
|
|
90
|
+
return None
|
|
91
|
+
response.set_cookie(
|
|
92
|
+
self.cookie,
|
|
93
|
+
record.id,
|
|
94
|
+
max_age=int(self.ttl) if self.ttl is not None else None,
|
|
95
|
+
path=self.path,
|
|
96
|
+
domain=self.domain,
|
|
97
|
+
secure=self.secure,
|
|
98
|
+
httponly=self.http_only,
|
|
99
|
+
samesite=self.same_site,
|
|
100
|
+
)
|
|
101
|
+
elif state.get(FORGET_KEY) or incoming_id:
|
|
102
|
+
# Explicit logout, or a cookie that resolved to no live session.
|
|
103
|
+
response.delete_cookie(
|
|
104
|
+
self.cookie,
|
|
105
|
+
path=self.path,
|
|
106
|
+
domain=self.domain,
|
|
107
|
+
secure=self.secure,
|
|
108
|
+
httponly=self.http_only,
|
|
109
|
+
samesite=self.same_site,
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
return None
|
|
113
|
+
return next(
|
|
114
|
+
value.decode("latin-1")
|
|
115
|
+
for name, value in response.raw_headers
|
|
116
|
+
if name == b"set-cookie"
|
|
117
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ..exceptions import InvalidGrant, ThrottleException
|
|
7
|
+
from ..providers.base import UserProvider
|
|
8
|
+
from ..security.hashing import BcryptHasher, Hasher
|
|
9
|
+
from .repository import InMemoryPasswordResetRepository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PasswordBroker:
|
|
13
|
+
"""Password reset flow backed by the ``password_reset_tokens`` table.
|
|
14
|
+
|
|
15
|
+
Tokens are stored hashed, expire after ``expire_minutes``, and are
|
|
16
|
+
rate-limited per user by ``throttle_seconds`` — matching Laravel's broker.
|
|
17
|
+
The plaintext token is returned from ``send_reset_link`` so the caller can
|
|
18
|
+
deliver it (e.g. by email); only its hash is persisted.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
user_provider: UserProvider,
|
|
24
|
+
repository: InMemoryPasswordResetRepository | None = None,
|
|
25
|
+
hasher: Hasher | None = None,
|
|
26
|
+
expire_minutes: int = 60,
|
|
27
|
+
throttle_seconds: int = 60,
|
|
28
|
+
notifier: Callable[[str, str], None] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._users = user_provider
|
|
31
|
+
self._repo = repository or InMemoryPasswordResetRepository()
|
|
32
|
+
self._hasher = hasher or BcryptHasher()
|
|
33
|
+
self._expire_minutes = expire_minutes
|
|
34
|
+
self._throttle_seconds = throttle_seconds
|
|
35
|
+
self._notifier = notifier
|
|
36
|
+
|
|
37
|
+
def _find_user(self, email: str):
|
|
38
|
+
return self._users.retrieve_by_credentials({"email": email, "username": email})
|
|
39
|
+
|
|
40
|
+
def send_reset_link(self, email: str) -> str:
|
|
41
|
+
user = self._find_user(email)
|
|
42
|
+
if user is None:
|
|
43
|
+
# Do equivalent hashing work before failing so response timing does
|
|
44
|
+
# not reveal whether the email is registered.
|
|
45
|
+
self._hasher.make(secrets.token_urlsafe(40))
|
|
46
|
+
raise InvalidGrant("We can't find a user with that email address.")
|
|
47
|
+
if self._repo.recently_created(email, self._throttle_seconds):
|
|
48
|
+
raise ThrottleException("Please wait before requesting another reset link.")
|
|
49
|
+
token = secrets.token_urlsafe(40)
|
|
50
|
+
self._repo.create(email, self._hasher.make(token))
|
|
51
|
+
if self._notifier is not None:
|
|
52
|
+
self._notifier(email, token)
|
|
53
|
+
return token
|
|
54
|
+
|
|
55
|
+
def reset(self, email: str, token: str, new_password: str) -> bool:
|
|
56
|
+
record = self._repo.find(email)
|
|
57
|
+
if record is None:
|
|
58
|
+
raise InvalidGrant("This password reset token is invalid.")
|
|
59
|
+
if record.age() > self._expire_minutes * 60:
|
|
60
|
+
self._repo.delete(email)
|
|
61
|
+
raise InvalidGrant("This password reset token has expired.")
|
|
62
|
+
if not self._hasher.verify(token, record.hashed_token):
|
|
63
|
+
raise InvalidGrant("This password reset token is invalid.")
|
|
64
|
+
user = self._find_user(email)
|
|
65
|
+
if user is None:
|
|
66
|
+
raise InvalidGrant("We can't find a user with that email address.")
|
|
67
|
+
self._users.update_password(user, new_password)
|
|
68
|
+
self._repo.delete(email)
|
|
69
|
+
return True
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class PasswordResetToken:
|
|
9
|
+
"""A row in the ``password_reset_tokens`` table."""
|
|
10
|
+
|
|
11
|
+
email: str
|
|
12
|
+
hashed_token: str
|
|
13
|
+
created_at: float
|
|
14
|
+
|
|
15
|
+
def age(self) -> float:
|
|
16
|
+
return time.time() - self.created_at
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryPasswordResetRepository:
|
|
20
|
+
"""Stores at most one active reset token per identifier (email).
|
|
21
|
+
|
|
22
|
+
Mirrors Laravel's ``password_reset_tokens`` table where issuing a new token
|
|
23
|
+
replaces any previous one for the same user.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._tokens: dict[str, PasswordResetToken] = {}
|
|
28
|
+
|
|
29
|
+
def create(self, email: str, hashed_token: str) -> PasswordResetToken:
|
|
30
|
+
rec = PasswordResetToken(email=email, hashed_token=hashed_token, created_at=time.time())
|
|
31
|
+
self._tokens[email] = rec
|
|
32
|
+
return rec
|
|
33
|
+
|
|
34
|
+
def find(self, email: str) -> PasswordResetToken | None:
|
|
35
|
+
return self._tokens.get(email)
|
|
36
|
+
|
|
37
|
+
def delete(self, email: str) -> None:
|
|
38
|
+
self._tokens.pop(email, None)
|
|
39
|
+
|
|
40
|
+
def recently_created(self, email: str, throttle_seconds: int) -> bool:
|
|
41
|
+
rec = self._tokens.get(email)
|
|
42
|
+
if rec is None:
|
|
43
|
+
return False
|
|
44
|
+
return rec.age() < throttle_seconds
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from .exceptions import AuthError
|
|
9
|
+
from .manager import AuthManager
|
|
10
|
+
from .middleware.csrf import CsrfMiddleware
|
|
11
|
+
from .middleware.session import SessionMiddleware
|
|
12
|
+
from .routes import build_router
|
|
13
|
+
from .routes_spa import build_spa_router
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
|
|
17
|
+
headers = {}
|
|
18
|
+
if exc.status_code == 401:
|
|
19
|
+
headers["WWW-Authenticate"] = "Bearer"
|
|
20
|
+
return JSONResponse(status_code=exc.status_code, content=exc.to_dict(), headers=headers)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthProvider:
|
|
24
|
+
"""Registers the auth stack onto a FastAPI application.
|
|
25
|
+
|
|
26
|
+
Instantiated with an :class:`AuthConfig`, it builds the :class:`AuthManager`,
|
|
27
|
+
stores it on ``app.state.auth_manager`` (where the dependencies read it),
|
|
28
|
+
mounts the OAuth2 router, and installs the OAuth2 error handler.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: Any, prefix: str = "") -> None:
|
|
32
|
+
self.config = config
|
|
33
|
+
self.prefix = prefix
|
|
34
|
+
self.manager = AuthManager(config)
|
|
35
|
+
|
|
36
|
+
def register(self, app: FastAPI) -> None:
|
|
37
|
+
app.state.auth_manager = self.manager
|
|
38
|
+
app.include_router(build_router(prefix=self.prefix))
|
|
39
|
+
app.add_exception_handler(AuthError, _auth_error_handler)
|
|
40
|
+
if self.manager.has_session_guard():
|
|
41
|
+
session = self.manager.session_config
|
|
42
|
+
if self.manager.spa_enabled():
|
|
43
|
+
spa = self.manager.spa_config
|
|
44
|
+
app.include_router(build_spa_router(prefix=self.prefix))
|
|
45
|
+
# Added before SessionMiddleware so it ends up inside it and
|
|
46
|
+
# sees the loaded session on the request state.
|
|
47
|
+
app.add_middleware(
|
|
48
|
+
CsrfMiddleware,
|
|
49
|
+
cookie=spa["csrf_cookie"],
|
|
50
|
+
header=spa["csrf_header"],
|
|
51
|
+
exempt_paths=spa["csrf_exempt_paths"],
|
|
52
|
+
stateful_origins=spa["stateful_origins"],
|
|
53
|
+
ttl=session["ttl"],
|
|
54
|
+
same_site=session["same_site"],
|
|
55
|
+
secure=session["secure"],
|
|
56
|
+
domain=session["domain"],
|
|
57
|
+
path=session["path"],
|
|
58
|
+
)
|
|
59
|
+
app.add_middleware(
|
|
60
|
+
SessionMiddleware,
|
|
61
|
+
store=self.manager.session_store,
|
|
62
|
+
cookie=session["cookie"],
|
|
63
|
+
ttl=session["ttl"],
|
|
64
|
+
http_only=session["http_only"],
|
|
65
|
+
same_site=session["same_site"],
|
|
66
|
+
secure=session["secure"],
|
|
67
|
+
domain=session["domain"],
|
|
68
|
+
path=session["path"],
|
|
69
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
User = Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class UserProvider(Protocol):
|
|
10
|
+
"""Retrieves and verifies users from a backing store.
|
|
11
|
+
|
|
12
|
+
Kept deliberately small so any store (ORM, in-memory, external service) can
|
|
13
|
+
implement it. Credential *lookup* is separate from credential *verification*
|
|
14
|
+
so timing and hashing stay under the provider's control.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def retrieve_by_id(self, identifier: Any) -> User | None:
|
|
18
|
+
"""Return the user with this primary identifier, or ``None``."""
|
|
19
|
+
|
|
20
|
+
def retrieve_by_credentials(self, credentials: dict[str, Any]) -> User | None:
|
|
21
|
+
"""Find a user by identifying fields (e.g. email); does NOT check the password."""
|
|
22
|
+
|
|
23
|
+
def validate_credentials(self, user: User, credentials: dict[str, Any]) -> bool:
|
|
24
|
+
"""Return whether ``credentials`` authenticate ``user``."""
|
|
25
|
+
|
|
26
|
+
def dummy_verify(self) -> None:
|
|
27
|
+
"""Burn a credential verification against a throwaway hash.
|
|
28
|
+
|
|
29
|
+
Called when ``retrieve_by_credentials`` finds no user, so the
|
|
30
|
+
absent-user path takes as long as a real password check and login
|
|
31
|
+
timing does not enumerate accounts.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def get_identifier(self, user: User) -> Any:
|
|
35
|
+
"""Return the primary identifier stored in a token's ``sub`` claim."""
|
|
36
|
+
|
|
37
|
+
def update_password(self, user: User, plain: str) -> None:
|
|
38
|
+
"""Persist a new hashed password for ``user`` (used by password resets)."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..security.hashing import BcryptHasher, Hasher
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InMemoryUserProvider:
|
|
10
|
+
"""Dict-backed user provider for tests, demos, and prototypes.
|
|
11
|
+
|
|
12
|
+
Users are plain dicts keyed by an ``id`` field. Real deployments swap this
|
|
13
|
+
for an ORM-backed provider via the ``providers`` config, but the contract is
|
|
14
|
+
identical.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
hasher: Hasher | None = None,
|
|
20
|
+
id_field: str = "id",
|
|
21
|
+
username_field: str = "email",
|
|
22
|
+
password_field: str = "password",
|
|
23
|
+
) -> None:
|
|
24
|
+
self._hasher = hasher or BcryptHasher()
|
|
25
|
+
self._id_field = id_field
|
|
26
|
+
self._username_field = username_field
|
|
27
|
+
self._password_field = password_field
|
|
28
|
+
self._users: dict[Any, dict[str, Any]] = {}
|
|
29
|
+
# Precomputed hash for constant-time verification of absent users.
|
|
30
|
+
self._dummy_hash = self._hasher.make(secrets.token_urlsafe(16))
|
|
31
|
+
|
|
32
|
+
def add(self, user: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
self._users[user[self._id_field]] = user
|
|
34
|
+
return user
|
|
35
|
+
|
|
36
|
+
def create(self, *, password: str, **fields: Any) -> dict[str, Any]:
|
|
37
|
+
fields[self._password_field] = self._hasher.make(password)
|
|
38
|
+
return self.add(fields)
|
|
39
|
+
|
|
40
|
+
def retrieve_by_id(self, identifier: Any) -> dict[str, Any] | None:
|
|
41
|
+
return self._users.get(identifier)
|
|
42
|
+
|
|
43
|
+
def retrieve_by_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
|
44
|
+
username = credentials.get(self._username_field)
|
|
45
|
+
if username is None:
|
|
46
|
+
return None
|
|
47
|
+
for user in self._users.values():
|
|
48
|
+
if user.get(self._username_field) == username:
|
|
49
|
+
return user
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def validate_credentials(self, user: dict[str, Any], credentials: dict[str, Any]) -> bool:
|
|
53
|
+
if user is None:
|
|
54
|
+
return False
|
|
55
|
+
supplied = credentials.get(self._password_field)
|
|
56
|
+
if supplied is None:
|
|
57
|
+
return False
|
|
58
|
+
return self._hasher.verify(supplied, user.get(self._password_field, ""))
|
|
59
|
+
|
|
60
|
+
def dummy_verify(self) -> None:
|
|
61
|
+
"""Run a throwaway hash verification to equalise timing for absent users."""
|
|
62
|
+
self._hasher.verify("invalid", self._dummy_hash)
|
|
63
|
+
|
|
64
|
+
def get_identifier(self, user: dict[str, Any]) -> Any:
|
|
65
|
+
return user[self._id_field]
|
|
66
|
+
|
|
67
|
+
def update_password(self, user: dict[str, Any], plain: str) -> None:
|
|
68
|
+
stored = self._users[user[self._id_field]]
|
|
69
|
+
stored[self._password_field] = self._hasher.make(plain)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..security.hashing import BcryptHasher, Hasher
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _attr(obj: Any, name: str) -> Any:
|
|
10
|
+
if isinstance(obj, dict):
|
|
11
|
+
return obj.get(name)
|
|
12
|
+
return getattr(obj, name, None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelUserProvider:
|
|
16
|
+
"""User provider backed by an active-record-style model class.
|
|
17
|
+
|
|
18
|
+
Works with masoniteorm (``config providers['users']['driver'] = 'masoniteorm'``)
|
|
19
|
+
and any ORM whose model exposes ``find(id)`` and ``where(field, value).first()``.
|
|
20
|
+
The model isn't imported here, keeping the ORM an optional dependency.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
model: Any,
|
|
26
|
+
hasher: Hasher | None = None,
|
|
27
|
+
id_field: str = "id",
|
|
28
|
+
username_field: str = "email",
|
|
29
|
+
password_field: str = "password",
|
|
30
|
+
) -> None:
|
|
31
|
+
self._model = model
|
|
32
|
+
self._hasher = hasher or BcryptHasher()
|
|
33
|
+
self._id_field = id_field
|
|
34
|
+
self._username_field = username_field
|
|
35
|
+
self._password_field = password_field
|
|
36
|
+
self._dummy_hash = self._hasher.make(secrets.token_urlsafe(16))
|
|
37
|
+
|
|
38
|
+
def dummy_verify(self) -> None:
|
|
39
|
+
"""Run a throwaway hash verification to equalise timing for absent users."""
|
|
40
|
+
self._hasher.verify("invalid", self._dummy_hash)
|
|
41
|
+
|
|
42
|
+
def retrieve_by_id(self, identifier: Any) -> Any | None:
|
|
43
|
+
return self._model.find(identifier)
|
|
44
|
+
|
|
45
|
+
def retrieve_by_credentials(self, credentials: dict[str, Any]) -> Any | None:
|
|
46
|
+
username = credentials.get(self._username_field)
|
|
47
|
+
if username is None:
|
|
48
|
+
return None
|
|
49
|
+
return self._model.where(self._username_field, username).first()
|
|
50
|
+
|
|
51
|
+
def validate_credentials(self, user: Any, credentials: dict[str, Any]) -> bool:
|
|
52
|
+
if user is None:
|
|
53
|
+
return False
|
|
54
|
+
supplied = credentials.get(self._password_field)
|
|
55
|
+
if supplied is None:
|
|
56
|
+
return False
|
|
57
|
+
return self._hasher.verify(supplied, _attr(user, self._password_field) or "")
|
|
58
|
+
|
|
59
|
+
def get_identifier(self, user: Any) -> Any:
|
|
60
|
+
return _attr(user, self._id_field)
|
|
61
|
+
|
|
62
|
+
def update_password(self, user: Any, plain: str) -> None:
|
|
63
|
+
hashed = self._hasher.make(plain)
|
|
64
|
+
if isinstance(user, dict):
|
|
65
|
+
user[self._password_field] = hashed
|
|
66
|
+
else:
|
|
67
|
+
setattr(user, self._password_field, hashed)
|
|
68
|
+
save = getattr(user, "save", None)
|
|
69
|
+
if callable(save):
|
|
70
|
+
save()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CORS configuration for SPA cookie authentication.
|
|
2
|
+
|
|
3
|
+
Published into the application as ``config/cors.py`` by::
|
|
4
|
+
|
|
5
|
+
python artisan provider:publish -p auth
|
|
6
|
+
|
|
7
|
+
Cookie-authenticated cross-origin requests require ``allow_credentials=True``,
|
|
8
|
+
and browsers reject a wildcard ``*`` origin when credentials are enabled —
|
|
9
|
+
always list the exact first-party origins here, matching
|
|
10
|
+
``AuthConfig.spa["stateful_origins"]``.
|
|
11
|
+
|
|
12
|
+
Wire it into Starlette's CORS middleware::
|
|
13
|
+
|
|
14
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
15
|
+
from config import cors
|
|
16
|
+
|
|
17
|
+
app.add_middleware(
|
|
18
|
+
CORSMiddleware,
|
|
19
|
+
allow_origins=cors.ALLOW_ORIGINS,
|
|
20
|
+
allow_credentials=cors.ALLOW_CREDENTIALS,
|
|
21
|
+
allow_methods=cors.ALLOW_METHODS,
|
|
22
|
+
allow_headers=cors.ALLOW_HEADERS,
|
|
23
|
+
expose_headers=cors.EXPOSE_HEADERS,
|
|
24
|
+
max_age=cors.MAX_AGE,
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Exact first-party SPA origins (scheme + host + port). Never use "*" here:
|
|
29
|
+
# credentials would silently stop working in every browser.
|
|
30
|
+
ALLOW_ORIGINS = [
|
|
31
|
+
"http://localhost:5173",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Required so the browser sends the session cookie and accepts Set-Cookie.
|
|
35
|
+
ALLOW_CREDENTIALS = True
|
|
36
|
+
|
|
37
|
+
ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
38
|
+
|
|
39
|
+
# X-XSRF-TOKEN is the CSRF header the auth package's middleware verifies.
|
|
40
|
+
ALLOW_HEADERS = ["Authorization", "Content-Type", "X-XSRF-TOKEN", "X-Requested-With"]
|
|
41
|
+
|
|
42
|
+
EXPOSE_HEADERS: list[str] = []
|
|
43
|
+
|
|
44
|
+
MAX_AGE = 600
|
|
File without changes
|