fastapi-admin-kit 0.1.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_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""CSRF protection — double-submit cookie pattern with HMAC signing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException, Request, Response
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.responses import JSONResponse, RedirectResponse
|
|
13
|
+
|
|
14
|
+
CSRF_COOKIE_NAME = "admin_csrf_token"
|
|
15
|
+
CSRF_FORM_FIELD = "csrf_token"
|
|
16
|
+
CSRF_HEADER = "X-CSRF-Token"
|
|
17
|
+
CSRF_TOKEN_MAX_AGE = 3600 # 1 hour
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_secret_key(request: Request) -> str | None:
|
|
21
|
+
"""Resolve the CSRF signing key from app state.
|
|
22
|
+
|
|
23
|
+
Prefers an explicit ``app.state.admin_secret_key`` (the unified signing-key
|
|
24
|
+
source shared with sessions and JWT); falls back to the session backend's
|
|
25
|
+
public ``secret_key``. Returns ``None`` only when neither is configured.
|
|
26
|
+
"""
|
|
27
|
+
explicit = getattr(request.app.state, "admin_secret_key", None)
|
|
28
|
+
if explicit:
|
|
29
|
+
return explicit
|
|
30
|
+
session_backend = getattr(request.app.state, "admin_session_backend", None)
|
|
31
|
+
if session_backend is None:
|
|
32
|
+
return None
|
|
33
|
+
return getattr(session_backend, "secret_key", None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_csrf_token(secret_key: str) -> str:
|
|
37
|
+
"""Generate a signed CSRF token: ``timestamp.random_hex.signature``."""
|
|
38
|
+
random_bytes = os.urandom(16)
|
|
39
|
+
timestamp = str(int(time.time()))
|
|
40
|
+
payload = f"{timestamp}.{random_bytes.hex()}"
|
|
41
|
+
signature = hmac.new(
|
|
42
|
+
secret_key.encode(), payload.encode(), hashlib.sha256
|
|
43
|
+
).hexdigest()[:32]
|
|
44
|
+
return f"{payload}.{signature}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _verify_csrf_token(secret_key: str, token: str) -> bool:
|
|
48
|
+
"""Verify the HMAC signature and check token freshness."""
|
|
49
|
+
parts = token.rsplit(".", 2)
|
|
50
|
+
if len(parts) != 3:
|
|
51
|
+
return False
|
|
52
|
+
timestamp_str, random_hex, provided_sig = parts
|
|
53
|
+
payload = f"{timestamp_str}.{random_hex}"
|
|
54
|
+
expected_sig = hmac.new(
|
|
55
|
+
secret_key.encode(), payload.encode(), hashlib.sha256
|
|
56
|
+
).hexdigest()[:32]
|
|
57
|
+
if not hmac.compare_digest(provided_sig, expected_sig):
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
token_time = int(timestamp_str)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return False
|
|
63
|
+
if time.time() - token_time > CSRF_TOKEN_MAX_AGE:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_csrf_cookie(response: Response, secret_key: str) -> str:
|
|
69
|
+
"""Generate and set the CSRF cookie on a response. Returns the token."""
|
|
70
|
+
token = generate_csrf_token(secret_key)
|
|
71
|
+
response.set_cookie(
|
|
72
|
+
key=CSRF_COOKIE_NAME,
|
|
73
|
+
value=token,
|
|
74
|
+
max_age=CSRF_TOKEN_MAX_AGE,
|
|
75
|
+
path="/",
|
|
76
|
+
secure=False,
|
|
77
|
+
httponly=False,
|
|
78
|
+
samesite="strict",
|
|
79
|
+
)
|
|
80
|
+
return token
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_csrf_token(
|
|
84
|
+
request: Request, csrf_token: str | None = None
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Validate CSRF token from form body or header against the cookie.
|
|
87
|
+
|
|
88
|
+
Raises ``HTTPException(403)`` if the token is missing or invalid.
|
|
89
|
+
Fails *closed*: if no signing key is configured (the admin was not
|
|
90
|
+
set up properly), raises ``HTTPException(500)`` rather than silently
|
|
91
|
+
disabling CSRF protection — a missing key must never mean "protection off".
|
|
92
|
+
"""
|
|
93
|
+
secret_key = _get_secret_key(request)
|
|
94
|
+
if secret_key is None:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status_code=500,
|
|
97
|
+
detail="CSRF secret not configured — admin session backend not initialised.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Read token from argument, header, or request state (set by middleware)
|
|
101
|
+
form_token = csrf_token
|
|
102
|
+
if form_token is None:
|
|
103
|
+
form_token = request.headers.get(CSRF_HEADER)
|
|
104
|
+
if form_token is None:
|
|
105
|
+
form_token = getattr(request.state, "_csrf_token", None)
|
|
106
|
+
if not form_token:
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=403,
|
|
109
|
+
detail="CSRF token missing. Please refresh the page and try again.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Read the cookie token
|
|
113
|
+
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
|
114
|
+
if not cookie_token:
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=403,
|
|
117
|
+
detail="CSRF session cookie missing. Please refresh the page and try again.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Verify both tokens have valid HMAC signatures
|
|
121
|
+
if not _verify_csrf_token(secret_key, form_token):
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=403,
|
|
124
|
+
detail="Invalid CSRF token. Please refresh the page and try again.",
|
|
125
|
+
)
|
|
126
|
+
if not _verify_csrf_token(secret_key, cookie_token):
|
|
127
|
+
raise HTTPException(
|
|
128
|
+
status_code=403,
|
|
129
|
+
detail="Invalid CSRF cookie. Please refresh the page and try again.",
|
|
130
|
+
)
|
|
131
|
+
# Compare the inner payloads (timestamp + random)
|
|
132
|
+
form_parts = form_token.rsplit(".", 2)
|
|
133
|
+
cookie_parts = cookie_token.rsplit(".", 2)
|
|
134
|
+
if form_parts[0] != cookie_parts[0] or form_parts[1] != cookie_parts[1]:
|
|
135
|
+
raise HTTPException(
|
|
136
|
+
status_code=403,
|
|
137
|
+
detail="CSRF token mismatch. Please refresh the page and try again.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def require_csrf_token(request: Request) -> None:
|
|
142
|
+
"""FastAPI dependency that enforces CSRF validation on state-changing requests.
|
|
143
|
+
|
|
144
|
+
Reads the CSRF token from ``X-CSRF-Token`` header or from the form body
|
|
145
|
+
(extracted by :class:`CSRFMiddleware` and stored in ``request.state``).
|
|
146
|
+
"""
|
|
147
|
+
validate_csrf_token(request)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# CSRF Middleware — extracts form CSRF token + generates per-request token
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CSRFMiddleware(BaseHTTPMiddleware):
|
|
156
|
+
"""Middleware that:
|
|
157
|
+
1. Generates a CSRF token per request and stores it in ``request.state.csrf_token``
|
|
158
|
+
for templates to render as a hidden field or meta tag.
|
|
159
|
+
2. Sets the CSRF cookie on GET requests if not already present.
|
|
160
|
+
3. Extracts the CSRF token from form bodies on POST/PUT/PATCH/DELETE requests
|
|
161
|
+
and stores it in ``request.state._csrf_token`` for the dependency to validate.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
165
|
+
# Generate per-request CSRF token for templates
|
|
166
|
+
secret_key = _get_secret_key(request)
|
|
167
|
+
if secret_key is not None:
|
|
168
|
+
existing_cookie = request.cookies.get(CSRF_COOKIE_NAME)
|
|
169
|
+
if existing_cookie and _verify_csrf_token(secret_key, existing_cookie):
|
|
170
|
+
csrf_token = existing_cookie
|
|
171
|
+
else:
|
|
172
|
+
csrf_token = generate_csrf_token(secret_key)
|
|
173
|
+
request.state.csrf_token = csrf_token
|
|
174
|
+
else:
|
|
175
|
+
secret_key = ""
|
|
176
|
+
csrf_token = ""
|
|
177
|
+
request.state.csrf_token = ""
|
|
178
|
+
|
|
179
|
+
# On state-changing requests, extract CSRF token from form body
|
|
180
|
+
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
|
181
|
+
content_type = request.headers.get("content-type", "")
|
|
182
|
+
is_form = (
|
|
183
|
+
"application/x-www-form-urlencoded" in content_type
|
|
184
|
+
or "multipart/form-data" in content_type
|
|
185
|
+
)
|
|
186
|
+
if is_form:
|
|
187
|
+
try:
|
|
188
|
+
body = await request.body()
|
|
189
|
+
from urllib.parse import parse_qs
|
|
190
|
+
|
|
191
|
+
form_data = parse_qs(body.decode("utf-8", errors="replace"))
|
|
192
|
+
csrf_values = form_data.get(CSRF_FORM_FIELD)
|
|
193
|
+
if csrf_values:
|
|
194
|
+
request.state._csrf_token = csrf_values[0]
|
|
195
|
+
except Exception:
|
|
196
|
+
pass # Let the dependency handle missing token
|
|
197
|
+
|
|
198
|
+
response = await call_next(request)
|
|
199
|
+
|
|
200
|
+
# Set CSRF cookie on all responses so it's always present
|
|
201
|
+
if secret_key:
|
|
202
|
+
response.set_cookie(
|
|
203
|
+
key=CSRF_COOKIE_NAME,
|
|
204
|
+
value=csrf_token,
|
|
205
|
+
max_age=CSRF_TOKEN_MAX_AGE,
|
|
206
|
+
path="/",
|
|
207
|
+
secure=False,
|
|
208
|
+
httponly=False,
|
|
209
|
+
samesite="strict",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return response
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Auth Redirect Exception Handler — redirects HTML requests to login on 401
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def auth_redirect_handler(request: Request, exc: HTTPException) -> Response:
|
|
221
|
+
"""Exception handler that catches 401 HTTPExceptions and redirects
|
|
222
|
+
HTML requests to the login page instead of returning a JSON error.
|
|
223
|
+
"""
|
|
224
|
+
if exc.status_code == 401:
|
|
225
|
+
accept = request.headers.get("accept", "")
|
|
226
|
+
if "text/html" in accept:
|
|
227
|
+
login_url = "/admin/login"
|
|
228
|
+
current_path = request.url.path
|
|
229
|
+
if current_path != "/admin/login":
|
|
230
|
+
if request.url.query:
|
|
231
|
+
login_url += f"?next={current_path}%3F{request.url.query}"
|
|
232
|
+
else:
|
|
233
|
+
login_url += f"?next={current_path}"
|
|
234
|
+
return RedirectResponse(url=login_url, status_code=302)
|
|
235
|
+
from starlette.responses import JSONResponse
|
|
236
|
+
return JSONResponse(
|
|
237
|
+
status_code=401,
|
|
238
|
+
content={"detail": exc.detail or "Not authenticated"},
|
|
239
|
+
)
|
|
240
|
+
raise exc
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""FastAPI dependencies — session, current user, permission checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from fastapi_admin_kit.auth.csrf import require_csrf_token # noqa: F401
|
|
11
|
+
from fastapi_admin_kit.auth.protocol import AdminUserProtocol
|
|
12
|
+
from fastapi_admin_kit.auth.session import SignedCookieSessionBackend
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Session helpers
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_session_backend(request: Request) -> SignedCookieSessionBackend:
|
|
20
|
+
"""Resolve the session backend from app state (set during admin setup)."""
|
|
21
|
+
backend: SignedCookieSessionBackend | None = getattr(
|
|
22
|
+
request.app.state, "admin_session_backend", None
|
|
23
|
+
)
|
|
24
|
+
if backend is None:
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=500,
|
|
27
|
+
detail="Admin session backend not initialised.",
|
|
28
|
+
)
|
|
29
|
+
return backend
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _get_db_session(request: Request) -> AsyncSession:
|
|
33
|
+
"""Yield the async SQLAlchemy session from app.state."""
|
|
34
|
+
from fastapi_admin_kit.db import get_db_session
|
|
35
|
+
|
|
36
|
+
session: AsyncSession = get_db_session(request)
|
|
37
|
+
yield session
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Per-request dependencies
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_session(request: Request) -> dict[str, Any] | None:
|
|
46
|
+
"""Read and decode the admin session cookie from the current request."""
|
|
47
|
+
session_backend = _get_session_backend(request)
|
|
48
|
+
token = request.cookies.get(session_backend.cookie_name)
|
|
49
|
+
return session_backend.decode(token)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def get_current_admin_user(
|
|
53
|
+
request: Request,
|
|
54
|
+
session_payload: dict[str, Any] | None = Depends(get_session),
|
|
55
|
+
) -> AdminUserProtocol:
|
|
56
|
+
"""Resolve the logged-in admin user from the session cookie.
|
|
57
|
+
|
|
58
|
+
Raises ``401`` if the session is missing, invalid, the user is
|
|
59
|
+
no longer active, or the password was changed after the session was issued.
|
|
60
|
+
"""
|
|
61
|
+
if session_payload is None:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=401,
|
|
64
|
+
detail="Not authenticated. Please log in.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
user_id = session_payload.get("user_id")
|
|
68
|
+
if user_id is None:
|
|
69
|
+
raise HTTPException(status_code=401, detail="Invalid session payload.")
|
|
70
|
+
|
|
71
|
+
# request.state.admin_user is populated as a side effect.
|
|
72
|
+
from fastapi_admin_kit.auth.identity import resolve_user
|
|
73
|
+
|
|
74
|
+
user = await resolve_user(request, user_id)
|
|
75
|
+
if user is None:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=401, detail="User not found or inactive."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Check session invalidation: reject if password changed after session iat
|
|
81
|
+
password_changed_at = getattr(user, "password_changed_at", None)
|
|
82
|
+
session_iat = session_payload.get("iat")
|
|
83
|
+
if password_changed_at is not None and session_iat is not None:
|
|
84
|
+
from datetime import UTC, datetime
|
|
85
|
+
|
|
86
|
+
if isinstance(password_changed_at, datetime):
|
|
87
|
+
if password_changed_at.tzinfo is None:
|
|
88
|
+
password_changed_at = password_changed_at.replace(tzinfo=UTC)
|
|
89
|
+
if isinstance(session_iat, (int, float)):
|
|
90
|
+
session_time = datetime.fromtimestamp(session_iat, tz=UTC)
|
|
91
|
+
if password_changed_at > session_time:
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=401,
|
|
94
|
+
detail="Session invalidated. Password was changed. Please log in again.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return user
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def get_permission_checker(
|
|
101
|
+
request: Request,
|
|
102
|
+
user: AdminUserProtocol = Depends(get_current_admin_user),
|
|
103
|
+
session: AsyncSession = Depends(_get_db_session),
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Build a ``PermissionChecker`` for the current user.
|
|
106
|
+
|
|
107
|
+
The concrete ``PermissionChecker`` class is imported here to avoid
|
|
108
|
+
circular imports.
|
|
109
|
+
"""
|
|
110
|
+
from fastapi_admin_kit.auth.permissions import PermissionChecker
|
|
111
|
+
|
|
112
|
+
snapshot = getattr(request.state, "admin_user_snapshot", None)
|
|
113
|
+
return PermissionChecker(session=session, user=user, user_snapshot=snapshot)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def require_permission(table_name: str, action: str): # type: ignore[no-untyped-def]
|
|
117
|
+
"""Return a FastAPI dependency that enforces a permission check.
|
|
118
|
+
|
|
119
|
+
Usage::
|
|
120
|
+
|
|
121
|
+
@router.get("/")
|
|
122
|
+
async def list_view(_=Depends(require_permission("products", "view"))):
|
|
123
|
+
...
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
async def _check(
|
|
127
|
+
checker: Any = Depends(get_permission_checker),
|
|
128
|
+
) -> None:
|
|
129
|
+
if not await checker.has_permission(table_name, action):
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=403,
|
|
132
|
+
detail=f"You do not have permission to {action} {table_name}.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return _check
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def require_superuser(
|
|
139
|
+
user: AdminUserProtocol = Depends(get_current_admin_user),
|
|
140
|
+
) -> AdminUserProtocol:
|
|
141
|
+
"""FastAPI dependency that enforces superuser access.
|
|
142
|
+
|
|
143
|
+
The single source of the "must be superuser" rule, shared by the roles
|
|
144
|
+
and audit views (previously copy-pasted in each).
|
|
145
|
+
"""
|
|
146
|
+
if not getattr(user, "is_superuser", False):
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=403, detail="Superuser access required."
|
|
149
|
+
)
|
|
150
|
+
return user
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Request-authentication — the single place that resolves "the current user".
|
|
2
|
+
|
|
3
|
+
The admin framework historically had *three* parallel implementations of
|
|
4
|
+
"decode a credential, then SELECT the active user it belongs to":
|
|
5
|
+
|
|
6
|
+
1. :func:`fastapi_admin_kit.auth.dependencies.get_current_admin_user` (cookie)
|
|
7
|
+
2. :func:`fastapi_admin_kit.views.factory._resolve_permission_checker` (cookie,
|
|
8
|
+
with a process-global cache on ``app.state`` that leaked identity across
|
|
9
|
+
requests)
|
|
10
|
+
3. :func:`fastapi_admin_kit.api.crud._get_current_user` (bearer JWT, hardcoded to
|
|
11
|
+
the built-in ``AdminUser`` model and so bypassing the ``AuthBackend`` seam)
|
|
12
|
+
|
|
13
|
+
This module is the deep seam they all delegate to. The two credential
|
|
14
|
+
transports (signed cookie, bearer JWT) become thin internal adapters; the
|
|
15
|
+
"load the active user and remember it for this request" rule lives in
|
|
16
|
+
:func:`resolve_user` exactly once.
|
|
17
|
+
|
|
18
|
+
The only request-scoped side effect is writing ``request.state.admin_user``
|
|
19
|
+
once per request, which the audit middleware, sidebar, and templates read.
|
|
20
|
+
Nothing is ever cached on ``app.state``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from fastapi import Request
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
31
|
+
|
|
32
|
+
from fastapi_admin_kit.auth.protocol import AdminUserProtocol
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_session_backend(request: Request) -> Any:
|
|
36
|
+
"""Return the configured session backend, or ``None`` if not initialised."""
|
|
37
|
+
return getattr(request.app.state, "admin_session_backend", None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_auth_backend(request: Request) -> Any:
|
|
41
|
+
"""Return the configured auth backend, or ``None`` if not initialised."""
|
|
42
|
+
return getattr(request.app.state, "admin_auth_backend", None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_db_session(request: Request) -> AsyncSession | None:
|
|
46
|
+
"""Return the per-request DB session, falling back to app.state."""
|
|
47
|
+
from fastapi_admin_kit.db import get_db_session
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return get_db_session(request)
|
|
51
|
+
except AttributeError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def resolve_user(
|
|
56
|
+
request: Request, user_id: int | str | None
|
|
57
|
+
) -> AdminUserProtocol | None:
|
|
58
|
+
"""Resolve *user_id* to an active user and cache it on the request.
|
|
59
|
+
|
|
60
|
+
Idempotent for a given request: if ``request.state.admin_user`` is already
|
|
61
|
+
populated, it is returned without another DB hit. Returns ``None`` when the
|
|
62
|
+
user is absent or no longer active — callers decide whether that is an
|
|
63
|
+
error (views raise 401) or a soft miss (list/form views render unauthed).
|
|
64
|
+
|
|
65
|
+
Always honours the configured ``AuthBackend.get_user`` seam, so BYO user
|
|
66
|
+
models are supported on every transport (cookie *and* JWT), not just the
|
|
67
|
+
built-in one.
|
|
68
|
+
"""
|
|
69
|
+
cached = getattr(request.state, "admin_user", None)
|
|
70
|
+
if cached is not None:
|
|
71
|
+
if not hasattr(request.state, "admin_user_snapshot"):
|
|
72
|
+
try:
|
|
73
|
+
role_ids = list(getattr(cached, "role_ids", []))
|
|
74
|
+
except Exception:
|
|
75
|
+
role_ids = []
|
|
76
|
+
request.state.admin_user_snapshot = {
|
|
77
|
+
"id": getattr(cached, "id", None),
|
|
78
|
+
"email": getattr(cached, "email", None),
|
|
79
|
+
"is_superuser": bool(getattr(cached, "is_superuser", False)),
|
|
80
|
+
"role_ids": role_ids,
|
|
81
|
+
}
|
|
82
|
+
return cached
|
|
83
|
+
|
|
84
|
+
if user_id is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
auth_backend = _get_auth_backend(request)
|
|
88
|
+
session = _get_db_session(request)
|
|
89
|
+
if auth_backend is None or session is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
user = await auth_backend.get_user(user_id, session)
|
|
93
|
+
if user is None or not getattr(user, "is_active", False):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Per-request memoisation. This is request-scoped state, never app-scoped:
|
|
97
|
+
# two concurrent requests on the same Admin instance resolve independently.
|
|
98
|
+
request.state.admin_user = user
|
|
99
|
+
|
|
100
|
+
# Snapshot scalar fields into a plain dict so sync code can read them
|
|
101
|
+
# without touching the ORM object (which may be expired after a rollback).
|
|
102
|
+
# Use getattr with [] default — role_ids may not be loaded yet for BYO models.
|
|
103
|
+
try:
|
|
104
|
+
role_ids = list(getattr(user, "role_ids", []))
|
|
105
|
+
except Exception:
|
|
106
|
+
role_ids = []
|
|
107
|
+
request.state.admin_user_snapshot = {
|
|
108
|
+
"id": getattr(user, "id", None),
|
|
109
|
+
"email": getattr(user, "email", None),
|
|
110
|
+
"is_superuser": bool(getattr(user, "is_superuser", False)),
|
|
111
|
+
"role_ids": role_ids,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Inject user identity into the audit context so audit listeners can
|
|
115
|
+
# record who performed the action. The middleware already set IP and
|
|
116
|
+
# user-agent; this merges the user fields in.
|
|
117
|
+
from fastapi_admin_kit.audit.context import set_audit_context
|
|
118
|
+
|
|
119
|
+
set_audit_context({
|
|
120
|
+
"user_id": request.state.admin_user_snapshot["id"],
|
|
121
|
+
"user_email": request.state.admin_user_snapshot["email"],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return user
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _decode_cookie_payload(request: Request) -> dict[str, Any] | None:
|
|
128
|
+
"""Read and decode the signed admin session cookie. Returns ``None`` if absent/invalid."""
|
|
129
|
+
backend = _get_session_backend(request)
|
|
130
|
+
if backend is None:
|
|
131
|
+
return None
|
|
132
|
+
token = request.cookies.get(
|
|
133
|
+
getattr(backend, "cookie_name", "admin_session")
|
|
134
|
+
)
|
|
135
|
+
return backend.decode(token) if token else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def get_current_user_from_cookie(
|
|
139
|
+
request: Request,
|
|
140
|
+
) -> AdminUserProtocol | None:
|
|
141
|
+
"""Resolve the current user from the signed ``admin_session`` cookie.
|
|
142
|
+
|
|
143
|
+
Returns ``None`` when there is no valid session. Does not raise — callers
|
|
144
|
+
(dependencies, view factories) decide whether a missing user is a 401.
|
|
145
|
+
"""
|
|
146
|
+
payload = _decode_cookie_payload(request)
|
|
147
|
+
if payload is None:
|
|
148
|
+
return None
|
|
149
|
+
return await resolve_user(request, payload.get("user_id"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def get_current_user_from_bearer(
|
|
153
|
+
request: Request,
|
|
154
|
+
) -> AdminUserProtocol | None:
|
|
155
|
+
"""Resolve the current user from an ``Authorization: Bearer <jwt>`` header.
|
|
156
|
+
|
|
157
|
+
Returns ``None`` when the header is absent or the token is invalid/expired.
|
|
158
|
+
The DB lookup is delegated to :func:`resolve_user`, so the JWT API now
|
|
159
|
+
honours the ``AuthBackend.get_user`` seam just like the cookie path.
|
|
160
|
+
"""
|
|
161
|
+
# Imported lazily to avoid a circular import at module load time.
|
|
162
|
+
from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token
|
|
163
|
+
|
|
164
|
+
auth_header = request.headers.get("Authorization", "")
|
|
165
|
+
if not auth_header.startswith("Bearer "):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
token = auth_header[len("Bearer ") :]
|
|
169
|
+
secret_key = _get_secret_key(request)
|
|
170
|
+
payload = decode_access_token(token, secret_key)
|
|
171
|
+
if payload is None:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
sub = payload.get("sub")
|
|
175
|
+
if sub is None:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
user_id: int | str = int(sub) # type: ignore[assignment]
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
return await resolve_user(request, user_id)
|