pyweber 1.5.0.dev3__tar.gz → 1.5.2__tar.gz
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.
- {pyweber-1.5.0.dev3/pyweber.egg-info → pyweber-1.5.2}/PKG-INFO +1 -1
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyproject.toml +1 -1
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/__init__.py +36 -0
- pyweber-1.5.2/pyweber/auth/__init__.py +44 -0
- pyweber-1.5.2/pyweber/auth/decorators.py +201 -0
- pyweber-1.5.2/pyweber/auth/passwords.py +52 -0
- pyweber-1.5.2/pyweber/auth/rbac.py +184 -0
- pyweber-1.5.2/pyweber/auth/session.py +146 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/components/form.py +4 -2
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/context.py +15 -1
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/cookies.py +18 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/element.py +8 -4
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/field_storage.py +13 -4
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/openapi.py +1 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/request.py +38 -4
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/pyweber/pyweber.py +24 -4
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/config.toml +1 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/security.py +43 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2/pyweber.egg-info}/PKG-INFO +1 -1
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber.egg-info/SOURCES.txt +5 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/LICENSE +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/MANIFEST.in +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/NOTICE +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/README.md +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/admin/index.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/admin/src/script.js +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/admin/src/style.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/cli/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/cli/commands.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/components/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/components/general.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/components/input.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/config/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/config/config.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/http.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/reload.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/selector.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/session.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/connection/websocket.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/element.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/events.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/html_parser.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/template.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/core/window.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/create_app.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/error_pages.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/field.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/file.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/file_stream.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/handoff.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/headers.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/middleware.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/rate_limit.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/response.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/routes.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/run.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/security.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/stream_stats.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/strem_stats.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/task_manager.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/template_diff.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/models/ws_message.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/pyweber/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/.gitignore +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/css.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/docs.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/apple-touch-icon.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/favicon-16x16.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/favicon-32x32.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/favicon.ico +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/pyweber.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/favicon/site.webmanifest +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/handlers.js +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/html.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/html401.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/html404.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/html500.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/js.js +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/loading.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/main.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/pyweber.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/static/update.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/testing/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/testing/client.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/async_utils.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/deprecation.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/exceptions.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/icons.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/loads.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/mime.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/types.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber/utils/utils.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber.egg-info/dependency_links.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber.egg-info/entry_points.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber.egg-info/requires.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/pyweber.egg-info/top_level.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/setup.cfg +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_config.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_context.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_cookies.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_public_api.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_response.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.5.2}/tests/test_template_diff.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyweber"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.2"
|
|
8
8
|
description = "A lightweight Python framework for building and managing web applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "DevPythonMZ", email = "pypi.dev@gmail.com" }]
|
|
@@ -76,10 +76,29 @@ from .utils.security import (
|
|
|
76
76
|
sign_value,
|
|
77
77
|
unsign_value,
|
|
78
78
|
generate_csrf_token,
|
|
79
|
+
get_csrf_token,
|
|
79
80
|
)
|
|
80
81
|
from .utils.icons import Icons
|
|
81
82
|
from .utils.deprecation import warn_deprecated
|
|
82
83
|
from .testing import TestClient
|
|
84
|
+
from .auth import (
|
|
85
|
+
login_required,
|
|
86
|
+
login_user,
|
|
87
|
+
logout_user,
|
|
88
|
+
current_user,
|
|
89
|
+
get_user_id,
|
|
90
|
+
hash_password,
|
|
91
|
+
check_password,
|
|
92
|
+
permission_required,
|
|
93
|
+
role_required,
|
|
94
|
+
register_roles,
|
|
95
|
+
clear_roles,
|
|
96
|
+
define_role,
|
|
97
|
+
has_role,
|
|
98
|
+
has_all_roles,
|
|
99
|
+
has_permission,
|
|
100
|
+
user_permissions,
|
|
101
|
+
)
|
|
83
102
|
|
|
84
103
|
from .utils.types import (
|
|
85
104
|
ContentTypes,
|
|
@@ -200,6 +219,7 @@ __all__ = [
|
|
|
200
219
|
'sign_value',
|
|
201
220
|
'unsign_value',
|
|
202
221
|
'generate_csrf_token',
|
|
222
|
+
'get_csrf_token',
|
|
203
223
|
'ContentTypes',
|
|
204
224
|
'EventType',
|
|
205
225
|
'HTMLTag',
|
|
@@ -244,6 +264,22 @@ __all__ = [
|
|
|
244
264
|
'SecurityEnforcer',
|
|
245
265
|
'SecurityError',
|
|
246
266
|
'ForbiddenError',
|
|
267
|
+
'login_required',
|
|
268
|
+
'login_user',
|
|
269
|
+
'logout_user',
|
|
270
|
+
'current_user',
|
|
271
|
+
'get_user_id',
|
|
272
|
+
'hash_password',
|
|
273
|
+
'check_password',
|
|
274
|
+
'permission_required',
|
|
275
|
+
'role_required',
|
|
276
|
+
'register_roles',
|
|
277
|
+
'clear_roles',
|
|
278
|
+
'define_role',
|
|
279
|
+
'has_role',
|
|
280
|
+
'has_all_roles',
|
|
281
|
+
'has_permission',
|
|
282
|
+
'user_permissions',
|
|
247
283
|
'Icon',
|
|
248
284
|
'Style',
|
|
249
285
|
'Script',
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Lightweight session login helpers (Flask-Login style, no ORM) + RBAC."""
|
|
2
|
+
|
|
3
|
+
from pyweber.auth.decorators import login_required, permission_required, role_required
|
|
4
|
+
from pyweber.auth.passwords import check_password, hash_password
|
|
5
|
+
from pyweber.auth.rbac import (
|
|
6
|
+
RoleRegistry,
|
|
7
|
+
clear_roles,
|
|
8
|
+
define_role,
|
|
9
|
+
get_role_registry,
|
|
10
|
+
has_all_roles,
|
|
11
|
+
has_permission,
|
|
12
|
+
has_role,
|
|
13
|
+
register_roles,
|
|
14
|
+
user_permissions,
|
|
15
|
+
)
|
|
16
|
+
from pyweber.auth.session import (
|
|
17
|
+
USER_COOKIE_NAME,
|
|
18
|
+
current_user,
|
|
19
|
+
get_user_id,
|
|
20
|
+
login_user,
|
|
21
|
+
logout_user,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
'USER_COOKIE_NAME',
|
|
26
|
+
'RoleRegistry',
|
|
27
|
+
'check_password',
|
|
28
|
+
'clear_roles',
|
|
29
|
+
'current_user',
|
|
30
|
+
'define_role',
|
|
31
|
+
'get_role_registry',
|
|
32
|
+
'get_user_id',
|
|
33
|
+
'has_all_roles',
|
|
34
|
+
'has_permission',
|
|
35
|
+
'has_role',
|
|
36
|
+
'hash_password',
|
|
37
|
+
'login_required',
|
|
38
|
+
'login_user',
|
|
39
|
+
'logout_user',
|
|
40
|
+
'permission_required',
|
|
41
|
+
'register_roles',
|
|
42
|
+
'role_required',
|
|
43
|
+
'user_permissions',
|
|
44
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""``@login_required`` / ``@permission_required`` / ``@role_required``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from pyweber.auth.rbac import user_has_permissions, user_has_roles
|
|
10
|
+
from pyweber.auth.session import current_user
|
|
11
|
+
from pyweber.models.context import get_current_request
|
|
12
|
+
from pyweber.models.response import Response
|
|
13
|
+
from pyweber.utils.types import ContentTypes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _wants_json(request) -> bool:
|
|
17
|
+
if request is None:
|
|
18
|
+
return False
|
|
19
|
+
accept = (request.headers.get('accept') or '').lower()
|
|
20
|
+
if 'application/json' in accept:
|
|
21
|
+
if 'text/html' not in accept:
|
|
22
|
+
return True
|
|
23
|
+
return accept.find('application/json') <= accept.find('text/html')
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unauthorized(*, redirect: str | None, request) -> Response:
|
|
28
|
+
if redirect and not _wants_json(request):
|
|
29
|
+
return Response(
|
|
30
|
+
content=b'',
|
|
31
|
+
status=302,
|
|
32
|
+
request=request,
|
|
33
|
+
route=redirect,
|
|
34
|
+
headers={'Location': redirect},
|
|
35
|
+
content_type=ContentTypes.txt,
|
|
36
|
+
)
|
|
37
|
+
return Response.json({'detail': 'Unauthorized'}, status=401, request=request)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _forbidden(*, request) -> Response:
|
|
41
|
+
return Response.json({'detail': 'Forbidden'}, status=403, request=request)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _authorize(
|
|
45
|
+
user: dict[str, Any] | None,
|
|
46
|
+
*,
|
|
47
|
+
roles: list[str] | None,
|
|
48
|
+
roles_all: list[str] | None,
|
|
49
|
+
permissions: list[str] | None,
|
|
50
|
+
permissions_all: list[str] | None,
|
|
51
|
+
) -> str | None:
|
|
52
|
+
"""Return ``'unauthorized'``, ``'forbidden'``, or ``None`` if allowed."""
|
|
53
|
+
if user is None:
|
|
54
|
+
return 'unauthorized'
|
|
55
|
+
if not user_has_roles(user, roles, roles_all=roles_all):
|
|
56
|
+
return 'forbidden'
|
|
57
|
+
if not user_has_permissions(user, permissions, permissions_all=permissions_all):
|
|
58
|
+
return 'forbidden'
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _wrap_handler(
|
|
63
|
+
func: Callable,
|
|
64
|
+
*,
|
|
65
|
+
redirect: str | None,
|
|
66
|
+
roles: list[str] | None,
|
|
67
|
+
roles_all: list[str] | None,
|
|
68
|
+
permissions: list[str] | None,
|
|
69
|
+
permissions_all: list[str] | None,
|
|
70
|
+
):
|
|
71
|
+
if inspect.iscoroutinefunction(func):
|
|
72
|
+
@wraps(func)
|
|
73
|
+
async def async_wrapper(*args, **kwargs):
|
|
74
|
+
request = get_current_request()
|
|
75
|
+
decision = _authorize(
|
|
76
|
+
current_user(),
|
|
77
|
+
roles=roles,
|
|
78
|
+
roles_all=roles_all,
|
|
79
|
+
permissions=permissions,
|
|
80
|
+
permissions_all=permissions_all,
|
|
81
|
+
)
|
|
82
|
+
if decision == 'unauthorized':
|
|
83
|
+
return _unauthorized(redirect=redirect, request=request)
|
|
84
|
+
if decision == 'forbidden':
|
|
85
|
+
return _forbidden(request=request)
|
|
86
|
+
return await func(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
return async_wrapper
|
|
89
|
+
|
|
90
|
+
@wraps(func)
|
|
91
|
+
def sync_wrapper(*args, **kwargs):
|
|
92
|
+
request = get_current_request()
|
|
93
|
+
decision = _authorize(
|
|
94
|
+
current_user(),
|
|
95
|
+
roles=roles,
|
|
96
|
+
roles_all=roles_all,
|
|
97
|
+
permissions=permissions,
|
|
98
|
+
permissions_all=permissions_all,
|
|
99
|
+
)
|
|
100
|
+
if decision == 'unauthorized':
|
|
101
|
+
return _unauthorized(redirect=redirect, request=request)
|
|
102
|
+
if decision == 'forbidden':
|
|
103
|
+
return _forbidden(request=request)
|
|
104
|
+
return func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
return sync_wrapper
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def login_required(
|
|
110
|
+
fn: Callable | None = None,
|
|
111
|
+
*,
|
|
112
|
+
redirect: str | None = None,
|
|
113
|
+
roles: list[str] | None = None,
|
|
114
|
+
roles_all: list[str] | None = None,
|
|
115
|
+
permissions: list[str] | None = None,
|
|
116
|
+
permissions_all: list[str] | None = None,
|
|
117
|
+
):
|
|
118
|
+
"""Require a logged-in user (cookie) or ``request.auth`` from OpenAPI schemes.
|
|
119
|
+
|
|
120
|
+
RBAC (optional)::
|
|
121
|
+
|
|
122
|
+
@login_required(roles=['admin', 'moderator']) # any role
|
|
123
|
+
@login_required(roles_all=['staff', 'verified']) # all roles
|
|
124
|
+
@login_required(permissions=['posts:write']) # via register_roles()
|
|
125
|
+
@login_required(permissions_all=['posts:read', 'posts:write'])
|
|
126
|
+
|
|
127
|
+
Usage::
|
|
128
|
+
|
|
129
|
+
@app.route('/dash')
|
|
130
|
+
@login_required(redirect='/login')
|
|
131
|
+
def dash():
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
@login_required # bare form
|
|
135
|
+
def api_me():
|
|
136
|
+
...
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def decorator(func: Callable):
|
|
140
|
+
return _wrap_handler(
|
|
141
|
+
func,
|
|
142
|
+
redirect=redirect,
|
|
143
|
+
roles=roles,
|
|
144
|
+
roles_all=roles_all,
|
|
145
|
+
permissions=permissions,
|
|
146
|
+
permissions_all=permissions_all,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if fn is not None and callable(fn):
|
|
150
|
+
return decorator(fn)
|
|
151
|
+
return decorator
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def permission_required(
|
|
155
|
+
*permissions: str,
|
|
156
|
+
redirect: str | None = None,
|
|
157
|
+
require_all: bool = False,
|
|
158
|
+
):
|
|
159
|
+
"""Shorthand for ``@login_required(permissions=…)`` / ``permissions_all=``.
|
|
160
|
+
|
|
161
|
+
::
|
|
162
|
+
|
|
163
|
+
@permission_required('posts:write')
|
|
164
|
+
def create(): ...
|
|
165
|
+
|
|
166
|
+
@permission_required('a', 'b', require_all=True)
|
|
167
|
+
def both(): ...
|
|
168
|
+
"""
|
|
169
|
+
if not permissions:
|
|
170
|
+
raise TypeError('permission_required() needs at least one permission')
|
|
171
|
+
perms = list(permissions)
|
|
172
|
+
return login_required(
|
|
173
|
+
redirect=redirect,
|
|
174
|
+
permissions=None if require_all else perms,
|
|
175
|
+
permissions_all=perms if require_all else None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def role_required(
|
|
180
|
+
*roles: str,
|
|
181
|
+
redirect: str | None = None,
|
|
182
|
+
require_all: bool = False,
|
|
183
|
+
):
|
|
184
|
+
"""Shorthand for ``@login_required(roles=…)`` / ``roles_all=``.
|
|
185
|
+
|
|
186
|
+
::
|
|
187
|
+
|
|
188
|
+
@role_required('admin')
|
|
189
|
+
def admin_panel(): ...
|
|
190
|
+
|
|
191
|
+
@role_required('staff', 'verified', require_all=True)
|
|
192
|
+
def staff_only(): ...
|
|
193
|
+
"""
|
|
194
|
+
if not roles:
|
|
195
|
+
raise TypeError('role_required() needs at least one role')
|
|
196
|
+
rs = list(roles)
|
|
197
|
+
return login_required(
|
|
198
|
+
redirect=redirect,
|
|
199
|
+
roles=None if require_all else rs,
|
|
200
|
+
roles_all=rs if require_all else None,
|
|
201
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Password hashing helpers (stdlib PBKDF2-HMAC-SHA256)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
DEFAULT_ITERATIONS = 260_000
|
|
10
|
+
_SCHEME = 'pbkdf2_sha256'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hash_password(password: str, *, iterations: int = DEFAULT_ITERATIONS) -> str:
|
|
14
|
+
"""Hash a password for storage.
|
|
15
|
+
|
|
16
|
+
Format: ``pbkdf2_sha256$<iterations>$<salt_hex>$<digest_hex>``
|
|
17
|
+
"""
|
|
18
|
+
if not isinstance(password, str) or password == '':
|
|
19
|
+
raise ValueError('password must be a non-empty string')
|
|
20
|
+
salt = secrets.token_hex(16)
|
|
21
|
+
digest = hashlib.pbkdf2_hmac(
|
|
22
|
+
'sha256',
|
|
23
|
+
password.encode('utf-8'),
|
|
24
|
+
bytes.fromhex(salt),
|
|
25
|
+
iterations,
|
|
26
|
+
).hex()
|
|
27
|
+
return f'{_SCHEME}${iterations}${salt}${digest}'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_password(password: str, encoded: str) -> bool:
|
|
31
|
+
"""Constant-time verify of ``password`` against ``hash_password`` output."""
|
|
32
|
+
if not password or not encoded or not isinstance(encoded, str):
|
|
33
|
+
return False
|
|
34
|
+
try:
|
|
35
|
+
scheme, iter_s, salt, digest = encoded.split('$', 3)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
if scheme != _SCHEME:
|
|
39
|
+
return False
|
|
40
|
+
try:
|
|
41
|
+
iterations = int(iter_s)
|
|
42
|
+
salt_b = bytes.fromhex(salt)
|
|
43
|
+
expected = bytes.fromhex(digest)
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return False
|
|
46
|
+
actual = hashlib.pbkdf2_hmac(
|
|
47
|
+
'sha256',
|
|
48
|
+
password.encode('utf-8'),
|
|
49
|
+
salt_b,
|
|
50
|
+
iterations,
|
|
51
|
+
)
|
|
52
|
+
return hmac.compare_digest(actual, expected)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Lightweight role → permission registry (no ORM).
|
|
2
|
+
|
|
3
|
+
Define roles once, then gate routes with ``permissions=`` / helpers::
|
|
4
|
+
|
|
5
|
+
from pyweber.auth import register_roles, has_permission, login_required
|
|
6
|
+
|
|
7
|
+
register_roles({
|
|
8
|
+
'admin': ['*'],
|
|
9
|
+
'editor': ['posts:read', 'posts:write'],
|
|
10
|
+
'viewer': ['posts:read'],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
@app.route('/posts', methods=['POST'])
|
|
14
|
+
@login_required(permissions=['posts:write'])
|
|
15
|
+
def create_post():
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
from pyweber.auth.session import current_user
|
|
24
|
+
|
|
25
|
+
# Wildcard: full access, or ``resource:*`` for a namespace.
|
|
26
|
+
ALL_PERMISSIONS = '*'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RoleRegistry:
|
|
30
|
+
"""Map role names to permission strings."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._roles: dict[str, set[str]] = {}
|
|
34
|
+
|
|
35
|
+
def clear(self) -> None:
|
|
36
|
+
self._roles.clear()
|
|
37
|
+
|
|
38
|
+
def role(self, name: str, permissions: Iterable[str] | None = None) -> None:
|
|
39
|
+
self._roles[str(name)] = {str(p) for p in (permissions or [])}
|
|
40
|
+
|
|
41
|
+
def register(self, mapping: dict[str, Iterable[str]]) -> None:
|
|
42
|
+
for name, perms in mapping.items():
|
|
43
|
+
self.role(name, perms)
|
|
44
|
+
|
|
45
|
+
def get(self, name: str) -> set[str]:
|
|
46
|
+
return set(self._roles.get(str(name), set()))
|
|
47
|
+
|
|
48
|
+
def permissions_for(self, roles: Iterable[str] | None) -> set[str]:
|
|
49
|
+
out: set[str] = set()
|
|
50
|
+
for role in roles or []:
|
|
51
|
+
out |= self.get(role)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
def has_permission(self, roles: Iterable[str] | None, permission: str) -> bool:
|
|
55
|
+
return permission_matches(self.permissions_for(roles), permission)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def permission_matches(granted: Iterable[str], needed: str) -> bool:
|
|
59
|
+
"""Return True if ``needed`` is covered by ``granted`` (supports ``*`` / ``ns:*``)."""
|
|
60
|
+
need = str(needed)
|
|
61
|
+
have = {str(p) for p in granted}
|
|
62
|
+
if ALL_PERMISSIONS in have or need in have:
|
|
63
|
+
return True
|
|
64
|
+
if ':' in need:
|
|
65
|
+
ns = need.split(':', 1)[0]
|
|
66
|
+
if f'{ns}:*' in have:
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def permissions_match_any(granted: Iterable[str], needed: Iterable[str]) -> bool:
|
|
72
|
+
return any(permission_matches(granted, p) for p in needed)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def permissions_match_all(granted: Iterable[str], needed: Iterable[str]) -> bool:
|
|
76
|
+
return all(permission_matches(granted, p) for p in needed)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_registry = RoleRegistry()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_role_registry() -> RoleRegistry:
|
|
83
|
+
return _registry
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_roles(mapping: dict[str, Iterable[str]]) -> None:
|
|
87
|
+
"""Register or replace role → permission mappings (process-wide)."""
|
|
88
|
+
_registry.register(mapping)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def define_role(name: str, permissions: Iterable[str] | None = None) -> None:
|
|
92
|
+
_registry.role(name, permissions)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clear_roles() -> None:
|
|
96
|
+
"""Clear the process-wide role registry (useful in tests)."""
|
|
97
|
+
_registry.clear()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _user_role_names(user: dict[str, Any] | None = None) -> list[str]:
|
|
101
|
+
u = user if user is not None else current_user()
|
|
102
|
+
if not u:
|
|
103
|
+
return []
|
|
104
|
+
return [str(r) for r in (u.get('roles') or [])]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _direct_permissions(user: dict[str, Any] | None = None) -> set[str]:
|
|
108
|
+
"""Extra permissions stored on the session (``data['permissions']``)."""
|
|
109
|
+
u = user if user is not None else current_user()
|
|
110
|
+
if not u:
|
|
111
|
+
return set()
|
|
112
|
+
data = u.get('data') or {}
|
|
113
|
+
raw = data.get('permissions') or []
|
|
114
|
+
if isinstance(raw, str):
|
|
115
|
+
return {raw}
|
|
116
|
+
return {str(p) for p in raw}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def user_permissions(user: dict[str, Any] | None = None) -> set[str]:
|
|
120
|
+
"""Expanded permissions for the current (or given) user."""
|
|
121
|
+
u = user if user is not None else current_user()
|
|
122
|
+
if not u:
|
|
123
|
+
return set()
|
|
124
|
+
return _registry.permissions_for(_user_role_names(u)) | _direct_permissions(u)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def has_role(*roles: str, require_all: bool = False, user: dict[str, Any] | None = None) -> bool:
|
|
128
|
+
"""True if the user has any (default) or all of the given roles."""
|
|
129
|
+
u = user if user is not None else current_user()
|
|
130
|
+
if not u or not roles:
|
|
131
|
+
return False
|
|
132
|
+
have = {str(r) for r in (u.get('roles') or [])}
|
|
133
|
+
need = {str(r) for r in roles}
|
|
134
|
+
if require_all:
|
|
135
|
+
return need <= have
|
|
136
|
+
return bool(have & need)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def has_all_roles(*roles: str, user: dict[str, Any] | None = None) -> bool:
|
|
140
|
+
return has_role(*roles, require_all=True, user=user)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def has_permission(
|
|
144
|
+
*permissions: str,
|
|
145
|
+
require_all: bool = False,
|
|
146
|
+
user: dict[str, Any] | None = None,
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""True if the user has any (default) or all of the given permissions."""
|
|
149
|
+
if not permissions:
|
|
150
|
+
return False
|
|
151
|
+
granted = user_permissions(user)
|
|
152
|
+
if require_all:
|
|
153
|
+
return permissions_match_all(granted, permissions)
|
|
154
|
+
return permissions_match_any(granted, permissions)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def user_has_roles(
|
|
158
|
+
user: dict[str, Any],
|
|
159
|
+
roles: list[str] | None = None,
|
|
160
|
+
*,
|
|
161
|
+
roles_all: list[str] | None = None,
|
|
162
|
+
) -> bool:
|
|
163
|
+
if roles_all:
|
|
164
|
+
if not has_role(*roles_all, require_all=True, user=user):
|
|
165
|
+
return False
|
|
166
|
+
if roles:
|
|
167
|
+
if not has_role(*roles, require_all=False, user=user):
|
|
168
|
+
return False
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def user_has_permissions(
|
|
173
|
+
user: dict[str, Any],
|
|
174
|
+
permissions: list[str] | None = None,
|
|
175
|
+
*,
|
|
176
|
+
permissions_all: list[str] | None = None,
|
|
177
|
+
) -> bool:
|
|
178
|
+
if permissions_all:
|
|
179
|
+
if not has_permission(*permissions_all, require_all=True, user=user):
|
|
180
|
+
return False
|
|
181
|
+
if permissions:
|
|
182
|
+
if not has_permission(*permissions, require_all=False, user=user):
|
|
183
|
+
return False
|
|
184
|
+
return True
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Signed login-user cookie (not the reactive WS session id)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pyweber.models.context import get_cookie_manager, get_current_request
|
|
10
|
+
from pyweber.utils.security import https_enabled, sign_value, unsign_value
|
|
11
|
+
|
|
12
|
+
USER_COOKIE_NAME = 'pyweber_user'
|
|
13
|
+
DEFAULT_MAX_AGE = 60 * 60 * 24 * 14 # 14 days
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _encode_payload(payload: dict[str, Any]) -> str:
|
|
17
|
+
raw = json.dumps(payload, separators=(',', ':'), sort_keys=True)
|
|
18
|
+
return sign_value(raw)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _decode_payload(signed: str | None) -> dict[str, Any] | None:
|
|
22
|
+
if not signed:
|
|
23
|
+
return None
|
|
24
|
+
raw = unsign_value(signed)
|
|
25
|
+
if not raw:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(raw)
|
|
29
|
+
except (TypeError, json.JSONDecodeError):
|
|
30
|
+
return None
|
|
31
|
+
if not isinstance(data, dict) or 'id' not in data:
|
|
32
|
+
return None
|
|
33
|
+
exp = data.get('exp')
|
|
34
|
+
if exp is not None:
|
|
35
|
+
try:
|
|
36
|
+
if float(exp) < time.time():
|
|
37
|
+
return None
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return None
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _from_request_auth() -> dict[str, Any] | None:
|
|
44
|
+
request = get_current_request()
|
|
45
|
+
auth = getattr(request, 'auth', None) if request else None
|
|
46
|
+
if auth is None:
|
|
47
|
+
return None
|
|
48
|
+
user = getattr(auth, 'user', None)
|
|
49
|
+
if isinstance(user, dict) and 'id' in user:
|
|
50
|
+
roles = user.get('roles') or getattr(auth, 'scopes', None) or []
|
|
51
|
+
return {
|
|
52
|
+
'id': user['id'],
|
|
53
|
+
'roles': list(roles) if roles else [],
|
|
54
|
+
'data': {k: v for k, v in user.items() if k not in ('id', 'roles')},
|
|
55
|
+
'scheme': getattr(auth, 'scheme', None),
|
|
56
|
+
}
|
|
57
|
+
if user is not None:
|
|
58
|
+
return {
|
|
59
|
+
'id': user,
|
|
60
|
+
'roles': list(getattr(auth, 'scopes', None) or []),
|
|
61
|
+
'data': {},
|
|
62
|
+
'scheme': getattr(auth, 'scheme', None),
|
|
63
|
+
}
|
|
64
|
+
# credentials-only success (e.g. bearer token string)
|
|
65
|
+
creds = getattr(auth, 'credentials', None)
|
|
66
|
+
if creds is not None:
|
|
67
|
+
return {
|
|
68
|
+
'id': creds if not isinstance(creds, dict) else creds.get('id', creds),
|
|
69
|
+
'roles': list(getattr(auth, 'scopes', None) or []),
|
|
70
|
+
'data': creds if isinstance(creds, dict) else {},
|
|
71
|
+
'scheme': getattr(auth, 'scheme', None),
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
'id': getattr(auth, 'scheme', 'authenticated'),
|
|
75
|
+
'roles': list(getattr(auth, 'scopes', None) or []),
|
|
76
|
+
'data': {},
|
|
77
|
+
'scheme': getattr(auth, 'scheme', None),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def current_user() -> dict[str, Any] | None:
|
|
82
|
+
"""Return the logged-in user dict, or map ``request.auth``, or None."""
|
|
83
|
+
request = get_current_request()
|
|
84
|
+
if request is not None:
|
|
85
|
+
signed = request.cookies.get(USER_COOKIE_NAME)
|
|
86
|
+
payload = _decode_payload(signed)
|
|
87
|
+
if payload is not None:
|
|
88
|
+
return {
|
|
89
|
+
'id': payload['id'],
|
|
90
|
+
'roles': list(payload.get('roles') or []),
|
|
91
|
+
'data': dict(payload.get('data') or {}),
|
|
92
|
+
}
|
|
93
|
+
return _from_request_auth()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_user_id() -> Any | None:
|
|
97
|
+
user = current_user()
|
|
98
|
+
return None if user is None else user.get('id')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def login_user(
|
|
102
|
+
user_id: Any,
|
|
103
|
+
*,
|
|
104
|
+
roles: list[str] | None = None,
|
|
105
|
+
data: dict[str, Any] | None = None,
|
|
106
|
+
max_age: int = DEFAULT_MAX_AGE,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Mark the current request's principal as logged in (signed cookie)."""
|
|
109
|
+
cm = get_cookie_manager()
|
|
110
|
+
if cm is None:
|
|
111
|
+
raise RuntimeError('login_user() must be called inside an HTTP request handler')
|
|
112
|
+
|
|
113
|
+
now = time.time()
|
|
114
|
+
payload = {
|
|
115
|
+
'id': user_id,
|
|
116
|
+
'roles': list(roles or []),
|
|
117
|
+
'data': dict(data or {}),
|
|
118
|
+
'exp': now + max_age if max_age and max_age > 0 else None,
|
|
119
|
+
}
|
|
120
|
+
signed = _encode_payload(payload)
|
|
121
|
+
cm.set_cookie(
|
|
122
|
+
cookie_name=USER_COOKIE_NAME,
|
|
123
|
+
cookie_value=signed,
|
|
124
|
+
httponly=True,
|
|
125
|
+
secure=https_enabled(),
|
|
126
|
+
samesite='Strict',
|
|
127
|
+
max_age=max_age if max_age and max_age > 0 else None,
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
'id': user_id,
|
|
131
|
+
'roles': list(roles or []),
|
|
132
|
+
'data': dict(data or {}),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def logout_user() -> None:
|
|
137
|
+
"""Clear the login cookie on the current response."""
|
|
138
|
+
cm = get_cookie_manager()
|
|
139
|
+
if cm is None:
|
|
140
|
+
raise RuntimeError('logout_user() must be called inside an HTTP request handler')
|
|
141
|
+
cm.delete_cookie(
|
|
142
|
+
cookie_name=USER_COOKIE_NAME,
|
|
143
|
+
httponly=True,
|
|
144
|
+
secure=https_enabled(),
|
|
145
|
+
samesite='Strict',
|
|
146
|
+
)
|