swl 5.0.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.
- swl/.gitignore +1 -0
- swl/__init__.py +26 -0
- swl/_version.py +24 -0
- swl/actions.py +150 -0
- swl/actions_test.py +245 -0
- swl/auth_test.py +121 -0
- swl/breadcrumbs.py +52 -0
- swl/config.py +66 -0
- swl/dispatcher.py +46 -0
- swl/dispatcher_test.py +45 -0
- swl/frameworks.py +179 -0
- swl/frameworks_test.py +122 -0
- swl/passwd.py +186 -0
- swl/passwd_test.py +208 -0
- swl/resources/__init__.py +14 -0
- swl/resources/bootstrap-web-resources.json +22 -0
- swl/resources/bulma-web-resources.json +18 -0
- swl/resources/swl-web-resources.json +17 -0
- swl/resources/tabler-web-resources.json +22 -0
- swl/resources/web_resources.py +64 -0
- swl/resources/web_resources_test.py +81 -0
- swl/responses.py +212 -0
- swl/responses_test.py +258 -0
- swl/routes_endpoint.py +135 -0
- swl/routes_endpoint_test.py +123 -0
- swl/session/__init__.py +15 -0
- swl/session/sessionauthbackend.py +30 -0
- swl/session/sessionsecretkey.py +28 -0
- swl/session/sessionsecretkey_test.py +23 -0
- swl/shutdown.py +40 -0
- swl/sort/__init__.py +14 -0
- swl/sort/info.py +76 -0
- swl/sort/info_test.py +81 -0
- swl/static/swl/lang/de_DE.json +16 -0
- swl/static/swl/lang/en_US.json +16 -0
- swl/static/swl/lang/fr_FR.json +16 -0
- swl/static/swl/lang/nl_NL.json +16 -0
- swl/static/swl/lang.js +37 -0
- swl/static/swl/seecr-logo-zonder.png +0 -0
- swl/static/swl/swl-base.css +145 -0
- swl/static/swl/swl-run.js +21 -0
- swl/static/swl/swl.css +412 -0
- swl/static/swl/swl.js +535 -0
- swl/static/swl/theme.js +63 -0
- swl/static/swl/websocket.js +62 -0
- swl/swl.py +171 -0
- swl/swl_test.py +100 -0
- swl/templates/swl/account.j2 +22 -0
- swl/templates/swl/account_password.j2 +42 -0
- swl/templates/swl/alert.j2 +16 -0
- swl/templates/swl/base.j2 +59 -0
- swl/templates/swl/breadcrumbs.j2 +33 -0
- swl/templates/swl/card.j2 +34 -0
- swl/templates/swl/contextmenu.j2 +5 -0
- swl/templates/swl/dialog.j2 +29 -0
- swl/templates/swl/footer.j2 +25 -0
- swl/templates/swl/inputs.j2 +60 -0
- swl/templates/swl/kickstart.j2 +13 -0
- swl/templates/swl/modal-content.j2 +39 -0
- swl/templates/swl/modal.j2 +20 -0
- swl/templates/swl/placeholder.j2 +35 -0
- swl/templates/swl/toast.j2 +4 -0
- swl/templates/swl/users.j2 +56 -0
- swl/templates/swl/users_list.j2 +59 -0
- swl/test/__init__.py +12 -0
- swl/test/js-test/swl.test.js +509 -0
- swl/test/server.py +96 -0
- swl/test/views/__init__.py +16 -0
- swl/test/views/change.py +26 -0
- swl/test/views/misc.py +120 -0
- swl/test/views/modal.py +38 -0
- swl/test/views/rehydrate.py +54 -0
- swl/test_utils.py +117 -0
- swl/userbase.py +233 -0
- swl/userbase_test.py +213 -0
- swl/utils.py +36 -0
- swl/utils_test.py +36 -0
- swl/uvicorn_app.py +41 -0
- swl/views/__init__.py +16 -0
- swl/views/account.py +178 -0
- swl/views/account_test.py +289 -0
- swl-5.0.0.dist-info/METADATA +146 -0
- swl-5.0.0.dist-info/RECORD +86 -0
- swl-5.0.0.dist-info/WHEEL +5 -0
- swl-5.0.0.dist-info/licenses/LICENSE +21 -0
- swl-5.0.0.dist-info/top_level.txt +1 -0
swl/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
_version.py
|
swl/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024-2025 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
# ._version.py is generated by setuptools_scm
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .actions import Actions
|
|
19
|
+
from .config import configure_logging
|
|
20
|
+
from .responses import *
|
|
21
|
+
from .swl import SWL
|
|
22
|
+
from .uvicorn_app import uvicorn_main
|
|
23
|
+
|
|
24
|
+
VERSION = SWL.SWL_VERSION
|
|
25
|
+
static_dir = Path(__file__).parent / "static"
|
|
26
|
+
templates_dir = Path(__file__).parent / "templates"
|
swl/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '5.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (5, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g4ab6b414a'
|
swl/actions.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024-2026 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from hashlib import md5
|
|
16
|
+
|
|
17
|
+
from starlette._exception_handler import wrap_app_handling_exceptions
|
|
18
|
+
from starlette.exceptions import HTTPException
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
from starlette.responses import PlainTextResponse, RedirectResponse
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hash_name(name):
|
|
26
|
+
return md5(name.encode(encoding="utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Actions:
|
|
30
|
+
def __init__(self, path="/action", default_auth=True, key=None):
|
|
31
|
+
self._actions = {}
|
|
32
|
+
self._path = path
|
|
33
|
+
self._extra_kwargs = {}
|
|
34
|
+
self._default_auth = default_auth
|
|
35
|
+
self._key = key or hash_name
|
|
36
|
+
if not self._path.startswith("/"):
|
|
37
|
+
raise ValueError("path must start with /")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def actions_path(self):
|
|
41
|
+
return self._path + "/{action:str}"
|
|
42
|
+
|
|
43
|
+
def register_module(self, *modules):
|
|
44
|
+
for module in modules:
|
|
45
|
+
if hasattr(module, "__actions__"):
|
|
46
|
+
logger.info(
|
|
47
|
+
f"loading module {module.__name__}, "
|
|
48
|
+
+ f"methods: {len(module.__actions__)}"
|
|
49
|
+
)
|
|
50
|
+
for item in module.__actions__:
|
|
51
|
+
if not isinstance(item, dict):
|
|
52
|
+
item = dict(method=item, authenticated=self._default_auth)
|
|
53
|
+
self.register_method(**item)
|
|
54
|
+
|
|
55
|
+
def register_method(self, method, authenticated=None, is_authorized=None):
|
|
56
|
+
if authenticated is None:
|
|
57
|
+
authenticated = self._default_auth
|
|
58
|
+
is_allowed = is_authorized_fn(authenticated, is_authorized)
|
|
59
|
+
logger.debug(f" - {method.__name__} - {is_allowed.__doc__}")
|
|
60
|
+
|
|
61
|
+
key = self._key(method.__name__)
|
|
62
|
+
self._actions[key] = dict(func=method, is_allowed=is_allowed)
|
|
63
|
+
|
|
64
|
+
def register_kwarg(self, key, value):
|
|
65
|
+
self._extra_kwargs[key] = value
|
|
66
|
+
|
|
67
|
+
def register(self, method_name, path=None):
|
|
68
|
+
key = self._key(method_name)
|
|
69
|
+
if path is not None:
|
|
70
|
+
return path + f"/{key}"
|
|
71
|
+
if key not in self._actions:
|
|
72
|
+
raise RuntimeError(f"Method not found: {method_name}")
|
|
73
|
+
return self._path + f"/{key}"
|
|
74
|
+
|
|
75
|
+
def get(self, key):
|
|
76
|
+
return self._actions.get(key)
|
|
77
|
+
|
|
78
|
+
def wrap(self, method, authenticated=True, **kwargs):
|
|
79
|
+
return ActionMethodWrap(self, method, authenticated=authenticated, **kwargs)
|
|
80
|
+
|
|
81
|
+
async def handle(self, request):
|
|
82
|
+
if "action" in request.path_params:
|
|
83
|
+
key = request.path_params["action"]
|
|
84
|
+
if (action := self.get(key)) is None:
|
|
85
|
+
raise HTTPException(404)
|
|
86
|
+
logger.debug(f"handle {self._path}/{key[:8]}.. {action['func'].__name__}")
|
|
87
|
+
|
|
88
|
+
user = request.user if "user" in request.scope else None
|
|
89
|
+
if not await action["is_allowed"](user, request, **self._extra_kwargs):
|
|
90
|
+
# action["authenticated"] is True and not request.user.is_authenticated:
|
|
91
|
+
raise HTTPException(403)
|
|
92
|
+
|
|
93
|
+
result = await action["func"](request, **self._extra_kwargs)
|
|
94
|
+
return result
|
|
95
|
+
raise HTTPException(404)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_authorized_fn(authenticated, is_authorized):
|
|
99
|
+
doc = "authenticated=False"
|
|
100
|
+
if authenticated:
|
|
101
|
+
doc = "authenticated=True"
|
|
102
|
+
if is_authorized is not None:
|
|
103
|
+
doc += f", is_authorized={is_authorized.__name__}(...)"
|
|
104
|
+
|
|
105
|
+
async def is_allowed(user, request, **kwargs):
|
|
106
|
+
if not authenticated:
|
|
107
|
+
return True
|
|
108
|
+
if user is None or not user.is_authenticated:
|
|
109
|
+
return False
|
|
110
|
+
if is_authorized is not None:
|
|
111
|
+
return await is_authorized(request, **kwargs)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
is_allowed.__doc__ = doc
|
|
115
|
+
|
|
116
|
+
return is_allowed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ActionMethodWrap:
|
|
120
|
+
def __init__(
|
|
121
|
+
self, actions, method, authenticated=True, is_authorized=None, redirect=None
|
|
122
|
+
):
|
|
123
|
+
self._actions = actions
|
|
124
|
+
self._method = method
|
|
125
|
+
self._authenticated = authenticated
|
|
126
|
+
self._is_authorized = is_authorized
|
|
127
|
+
if is_authorized is not None and not authenticated:
|
|
128
|
+
raise ValueError("is_authorized can only be set when authenticated is True")
|
|
129
|
+
self.is_allowed = is_authorized_fn(authenticated, is_authorized)
|
|
130
|
+
self._redirect = redirect
|
|
131
|
+
|
|
132
|
+
async def __call__(self, scope, receive, send):
|
|
133
|
+
request = Request(scope, receive, send)
|
|
134
|
+
|
|
135
|
+
if not await self.is_allowed(
|
|
136
|
+
scope.get("user"), request, **self._actions._extra_kwargs
|
|
137
|
+
):
|
|
138
|
+
if self._redirect is None:
|
|
139
|
+
response = PlainTextResponse("Forbidden", status_code=403)
|
|
140
|
+
else:
|
|
141
|
+
request.session["next"] = request.url.path
|
|
142
|
+
response = RedirectResponse(self._redirect, status_code=303)
|
|
143
|
+
await response(scope, receive, send)
|
|
144
|
+
else:
|
|
145
|
+
|
|
146
|
+
async def app(scope, receive, send):
|
|
147
|
+
response = await self._method(request, **self._actions._extra_kwargs)
|
|
148
|
+
await response(scope, receive, send)
|
|
149
|
+
|
|
150
|
+
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
swl/actions_test.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024-2026 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.responses import PlainTextResponse
|
|
17
|
+
from starlette.routing import Route
|
|
18
|
+
from starlette.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
from swl.test_utils import environment, create_app, login_user, logout_user
|
|
21
|
+
|
|
22
|
+
from .actions import Actions
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def write_module(tmp_path):
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, tmp_path.as_posix())
|
|
30
|
+
|
|
31
|
+
def write_mod(name, text):
|
|
32
|
+
(tmp_path / name).write_text(text)
|
|
33
|
+
|
|
34
|
+
yield write_mod
|
|
35
|
+
sys.path.pop(0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_register_module_with_actions(tmp_path, write_module):
|
|
39
|
+
actions = Actions()
|
|
40
|
+
|
|
41
|
+
write_module("mod1.py", "def method1(x):\n return 42")
|
|
42
|
+
write_module("mod2.py", "def method2(x):\n return 42\n__actions__ = [method2]")
|
|
43
|
+
write_module(
|
|
44
|
+
"mod3.py",
|
|
45
|
+
"def method3(x):\n return 42"
|
|
46
|
+
+ "\n__actions__ = [dict(method=method3, authenticated=False)]",
|
|
47
|
+
)
|
|
48
|
+
write_module(
|
|
49
|
+
"mod4.py",
|
|
50
|
+
"def method4(x):\n return 42"
|
|
51
|
+
+ "\n__actions__ = [dict(method=method4, authenticated=True)]",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
import mod1
|
|
55
|
+
import mod2
|
|
56
|
+
import mod3
|
|
57
|
+
import mod4
|
|
58
|
+
|
|
59
|
+
actions.register_module(mod1)
|
|
60
|
+
assert len(actions._actions) == 0
|
|
61
|
+
actions.register_module(mod2)
|
|
62
|
+
assert len(actions._actions) == 1
|
|
63
|
+
actions.register_module(mod3)
|
|
64
|
+
assert len(actions._actions) == 2
|
|
65
|
+
actions.register_module(mod4)
|
|
66
|
+
assert len(actions._actions) == 3
|
|
67
|
+
|
|
68
|
+
auth = [
|
|
69
|
+
action
|
|
70
|
+
for action in actions._actions.values()
|
|
71
|
+
if action["is_allowed"].__doc__.startswith("authenticated=True")
|
|
72
|
+
]
|
|
73
|
+
assert len(auth) == 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_default_auth(write_module):
|
|
77
|
+
write_module("mod2.py", "def method2(x):\n return 42\n__actions__ = [method2]")
|
|
78
|
+
import mod2
|
|
79
|
+
|
|
80
|
+
auth_actions = Actions()
|
|
81
|
+
auth_actions.register_module(mod2)
|
|
82
|
+
assert list(auth_actions._actions.values())[0]["is_allowed"].__doc__.startswith(
|
|
83
|
+
"authenticated=True"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
noauth_actions = Actions(default_auth=False)
|
|
87
|
+
noauth_actions.register_module(mod2)
|
|
88
|
+
assert list(noauth_actions._actions.values())[0]["is_allowed"].__doc__.startswith(
|
|
89
|
+
"authenticated=False"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_default_auth_with_dict(write_module):
|
|
94
|
+
write_module(
|
|
95
|
+
"mod2.py", "def method2(x):\n return 42\n__actions__ = [dict(method=method2)]"
|
|
96
|
+
)
|
|
97
|
+
import mod2
|
|
98
|
+
|
|
99
|
+
auth_actions = Actions()
|
|
100
|
+
auth_actions.register_module(mod2)
|
|
101
|
+
assert list(auth_actions._actions.values())[0]["is_allowed"].__doc__.startswith(
|
|
102
|
+
"authenticated=True"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
noauth_actions = Actions(default_auth=False)
|
|
106
|
+
noauth_actions.register_module(mod2)
|
|
107
|
+
assert list(noauth_actions._actions.values())[0]["is_allowed"].__doc__.startswith(
|
|
108
|
+
"authenticated=False"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_register_with_different_path(write_module):
|
|
113
|
+
write_module(
|
|
114
|
+
"mod2.py", "def method2(x):\n return 42\n__actions__ = [dict(method=method2)]"
|
|
115
|
+
)
|
|
116
|
+
import mod2
|
|
117
|
+
|
|
118
|
+
actions = Actions(path="/my/action", key=str.upper)
|
|
119
|
+
actions.register_module(mod2)
|
|
120
|
+
|
|
121
|
+
assert actions.register("method2") == "/my/action/METHOD2"
|
|
122
|
+
with pytest.raises(RuntimeError):
|
|
123
|
+
actions.register("unknown")
|
|
124
|
+
assert actions.register("unknown", path="/other/action") == "/other/action/UNKNOWN"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_is_authorized(environment):
|
|
128
|
+
client, swl, userbase = environment
|
|
129
|
+
|
|
130
|
+
async def some_action(request, **kwargs):
|
|
131
|
+
return PlainTextResponse("Some Action")
|
|
132
|
+
|
|
133
|
+
async def userbase_has_user2(request, userbase, **kwargs):
|
|
134
|
+
return (userbase.get("user2")) is not None
|
|
135
|
+
|
|
136
|
+
swl.actions.register_method(some_action, is_authorized=userbase_has_user2)
|
|
137
|
+
|
|
138
|
+
response = client.post(swl.actions.register("some_action"))
|
|
139
|
+
assert response.status_code == 403
|
|
140
|
+
|
|
141
|
+
userbase.create("user2", "user2_password", admin=False)
|
|
142
|
+
response = client.post(swl.actions.register("some_action"))
|
|
143
|
+
assert response.status_code == 200
|
|
144
|
+
assert response.text == "Some Action"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def test_method_wrap(environment):
|
|
148
|
+
_, swl, userbase = environment
|
|
149
|
+
|
|
150
|
+
async def kwargs_page(request, **kwargs):
|
|
151
|
+
kwarg_keys = sorted(kwargs.keys())
|
|
152
|
+
return PlainTextResponse(f"Kwargs: {kwarg_keys}")
|
|
153
|
+
|
|
154
|
+
app = await create_app(
|
|
155
|
+
swl,
|
|
156
|
+
userbase,
|
|
157
|
+
routes=[
|
|
158
|
+
Route("/wrapped", endpoint=swl.endpoint(kwargs_page)),
|
|
159
|
+
Route("/unwrapped", endpoint=kwargs_page),
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
client = TestClient(app)
|
|
163
|
+
login_user(client, "user", "user_password")
|
|
164
|
+
|
|
165
|
+
response = client.get("/unwrapped")
|
|
166
|
+
assert response.text == "Kwargs: []"
|
|
167
|
+
|
|
168
|
+
response = client.get("/wrapped")
|
|
169
|
+
assert response.text == "Kwargs: ['swl', 'userbase']"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def test_wrap_authenticated(environment):
|
|
173
|
+
_, swl, userbase = environment
|
|
174
|
+
|
|
175
|
+
async def auth_page(request, **kwargs):
|
|
176
|
+
return PlainTextResponse("Authenticated")
|
|
177
|
+
|
|
178
|
+
async def is_admin(request, **kwargs):
|
|
179
|
+
return request.user.admin
|
|
180
|
+
|
|
181
|
+
async def login_page(request, **kwargs):
|
|
182
|
+
return PlainTextResponse(request.session.get("next", "No next"))
|
|
183
|
+
|
|
184
|
+
app = await create_app(
|
|
185
|
+
swl,
|
|
186
|
+
userbase,
|
|
187
|
+
routes=[
|
|
188
|
+
Route(
|
|
189
|
+
"/auth",
|
|
190
|
+
endpoint=swl.endpoint(
|
|
191
|
+
auth_page, authenticated=True, redirect="/login/here"
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
Route(
|
|
195
|
+
"/noauth",
|
|
196
|
+
endpoint=swl.endpoint(auth_page, authenticated=False),
|
|
197
|
+
),
|
|
198
|
+
Route(
|
|
199
|
+
"/login/here",
|
|
200
|
+
endpoint=swl.endpoint(login_page, authenticated=False),
|
|
201
|
+
),
|
|
202
|
+
Route(
|
|
203
|
+
"/admin_only",
|
|
204
|
+
endpoint=swl.endpoint(
|
|
205
|
+
auth_page,
|
|
206
|
+
authenticated=True,
|
|
207
|
+
is_authorized=is_admin,
|
|
208
|
+
redirect="/login/here",
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
client = TestClient(app)
|
|
214
|
+
|
|
215
|
+
response = client.get("/auth", follow_redirects=False)
|
|
216
|
+
assert response.status_code == 303
|
|
217
|
+
assert response.headers["location"] == "/login/here"
|
|
218
|
+
response = client.get("/login/here", follow_redirects=False)
|
|
219
|
+
assert response.status_code == 200
|
|
220
|
+
assert response.text == "/auth"
|
|
221
|
+
|
|
222
|
+
login_user(client, "user", "user_password")
|
|
223
|
+
response = client.get("/auth")
|
|
224
|
+
assert response.status_code == 200
|
|
225
|
+
assert response.text == "Authenticated"
|
|
226
|
+
|
|
227
|
+
response = client.get("/admin_only", follow_redirects=False)
|
|
228
|
+
assert response.status_code == 303
|
|
229
|
+
response = client.get(response.headers["location"], follow_redirects=False)
|
|
230
|
+
assert response.status_code == 200
|
|
231
|
+
assert response.text == "/admin_only"
|
|
232
|
+
|
|
233
|
+
login_user(client, "admin", "admin_password")
|
|
234
|
+
response = client.get("/admin_only")
|
|
235
|
+
assert response.status_code == 200
|
|
236
|
+
assert response.text == "Authenticated"
|
|
237
|
+
|
|
238
|
+
response = client.get("/noauth")
|
|
239
|
+
assert response.status_code == 200
|
|
240
|
+
assert response.text == "Authenticated"
|
|
241
|
+
|
|
242
|
+
logout_user(client)
|
|
243
|
+
response = client.get("/noauth")
|
|
244
|
+
assert response.status_code == 200
|
|
245
|
+
assert response.text == "Authenticated"
|
swl/auth_test.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from starlette.applications import Starlette
|
|
17
|
+
from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser
|
|
18
|
+
from starlette.middleware import Middleware
|
|
19
|
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
20
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
21
|
+
from starlette.responses import PlainTextResponse
|
|
22
|
+
from starlette.routing import Route
|
|
23
|
+
from starlette.testclient import TestClient
|
|
24
|
+
|
|
25
|
+
from swl.swl import SWL
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestUser(BaseUser):
|
|
29
|
+
@property
|
|
30
|
+
def display_name(self):
|
|
31
|
+
return "TestUser"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_authenticated(self):
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestAuthBackend(AuthenticationBackend):
|
|
39
|
+
async def authenticate(self, conn):
|
|
40
|
+
if "user" in conn.session:
|
|
41
|
+
return AuthCredentials(["authenticated"]), TestUser()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def authenticated_method(request, swl):
|
|
45
|
+
return swl.success()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def unauthenticated_method(request, swl):
|
|
49
|
+
return swl.success()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def page(request, swl, **kwargs):
|
|
53
|
+
return PlainTextResponse(f"Authenticated: {request.user.is_authenticated}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def login(request, swl, **kwargs):
|
|
57
|
+
request.session["user"] = True
|
|
58
|
+
return PlainTextResponse("OK")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def logout(request, swl, **kwargs):
|
|
62
|
+
request.session.clear()
|
|
63
|
+
return PlainTextResponse("OK")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_auth(tmp_path):
|
|
67
|
+
|
|
68
|
+
swl = SWL(config={})
|
|
69
|
+
swl.actions.register_method(authenticated_method)
|
|
70
|
+
swl.actions.register_method(unauthenticated_method, authenticated=False)
|
|
71
|
+
|
|
72
|
+
app = Starlette(
|
|
73
|
+
routes=[
|
|
74
|
+
swl.action_route,
|
|
75
|
+
Route("/secure", endpoint=swl.endpoint(page)),
|
|
76
|
+
Route(
|
|
77
|
+
"/unsecure",
|
|
78
|
+
endpoint=swl.endpoint(page, authenticated=False, redirect="/login"),
|
|
79
|
+
),
|
|
80
|
+
Route("/login", endpoint=swl.endpoint(login, authenticated=False)),
|
|
81
|
+
Route("/logout", endpoint=swl.endpoint(logout, authenticated=False)),
|
|
82
|
+
],
|
|
83
|
+
middleware=[
|
|
84
|
+
Middleware(SessionMiddleware, secret_key=str(uuid.uuid1())),
|
|
85
|
+
Middleware(
|
|
86
|
+
AuthenticationMiddleware,
|
|
87
|
+
backend=TestAuthBackend(),
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
client = TestClient(app)
|
|
93
|
+
response = client.post("/secure")
|
|
94
|
+
assert response.status_code == 403
|
|
95
|
+
|
|
96
|
+
response = client.post("/unsecure")
|
|
97
|
+
assert response.status_code == 200
|
|
98
|
+
assert response.text == "Authenticated: False"
|
|
99
|
+
|
|
100
|
+
response = client.post(swl.actions.register("authenticated_method"))
|
|
101
|
+
assert response.status_code == 403
|
|
102
|
+
|
|
103
|
+
response = client.post(swl.actions.register("unauthenticated_method"))
|
|
104
|
+
assert response.status_code == 204 # empty SuccessResponse
|
|
105
|
+
|
|
106
|
+
response = client.get("/login")
|
|
107
|
+
assert response.status_code == 200
|
|
108
|
+
|
|
109
|
+
response = client.post("/secure")
|
|
110
|
+
assert response.status_code == 200
|
|
111
|
+
assert response.text == "Authenticated: True"
|
|
112
|
+
|
|
113
|
+
response = client.post("/unsecure")
|
|
114
|
+
assert response.status_code == 200
|
|
115
|
+
assert response.text == "Authenticated: True"
|
|
116
|
+
|
|
117
|
+
response = client.post(swl.actions.register("authenticated_method"))
|
|
118
|
+
assert response.status_code == 204 # empty SuccessResponse
|
|
119
|
+
|
|
120
|
+
response = client.post(swl.actions.register("unauthenticated_method"))
|
|
121
|
+
assert response.status_code == 204 # empty SuccessResponse
|
swl/breadcrumbs.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Crumb:
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, dct):
|
|
18
|
+
return cls(**dct)
|
|
19
|
+
|
|
20
|
+
def __init__(self, label, action, arguments):
|
|
21
|
+
self.label = label
|
|
22
|
+
self.action = action
|
|
23
|
+
self.arguments = arguments
|
|
24
|
+
|
|
25
|
+
def __eq__(self, other):
|
|
26
|
+
return (
|
|
27
|
+
self.label == other.label
|
|
28
|
+
and self.action == other.action
|
|
29
|
+
and self.arguments == other.arguments
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def as_dict(self):
|
|
33
|
+
return dict(label=self.label, action=self.action, arguments=self.arguments)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Breadcrumbs:
|
|
37
|
+
def __init__(self, breadcrumbs):
|
|
38
|
+
self._crumbs = [Crumb.from_dict(crumb) for crumb in (breadcrumbs or [])]
|
|
39
|
+
|
|
40
|
+
def add(self, label, action, arguments=None):
|
|
41
|
+
new_crumb = Crumb(label=label, action=action, arguments=arguments or {})
|
|
42
|
+
|
|
43
|
+
if new_crumb in self._crumbs:
|
|
44
|
+
self._crumbs = self._crumbs[: self._crumbs.index(new_crumb) + 1]
|
|
45
|
+
else:
|
|
46
|
+
self._crumbs.append(new_crumb)
|
|
47
|
+
|
|
48
|
+
def __iter__(self):
|
|
49
|
+
return iter(self._crumbs)
|
|
50
|
+
|
|
51
|
+
def as_dict(self):
|
|
52
|
+
return [crumb.as_dict() for crumb in self._crumbs]
|
swl/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
## begin license ##
|
|
2
|
+
#
|
|
3
|
+
# "SWL"(Seecr Web Lib) makes developing webbased apps easier
|
|
4
|
+
#
|
|
5
|
+
# Copyright (C) 2024-2025 Seecr (Seek You Too B.V.) https://seecr.nl
|
|
6
|
+
#
|
|
7
|
+
# This file is part of "Seecr Web Lib"
|
|
8
|
+
#
|
|
9
|
+
# "Seecr Web Lib" is licensed under the MIT License; see the LICENSE file.
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
#
|
|
12
|
+
## end license ##
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from copy import copy
|
|
16
|
+
|
|
17
|
+
color_end = "\033[0m"
|
|
18
|
+
color_red = "\033[1;31m"
|
|
19
|
+
color_yellow = "\033[1;33m"
|
|
20
|
+
|
|
21
|
+
colors = {
|
|
22
|
+
"WARNING": color_yellow,
|
|
23
|
+
"ERROR": color_red,
|
|
24
|
+
"CRITICAL": color_red,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def colorize(levelname):
|
|
29
|
+
if levelname in colors:
|
|
30
|
+
return colors[levelname] + levelname + color_end
|
|
31
|
+
return levelname
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DefaultFormatter(logging.Formatter):
|
|
35
|
+
"""
|
|
36
|
+
A custom log formatter class.
|
|
37
|
+
|
|
38
|
+
It does the following:
|
|
39
|
+
* Outputs the LOG_LEVEL in uppercase, right-aligned to 8 characters
|
|
40
|
+
|
|
41
|
+
Idea based on uvicorn.logging.DefaultFormatter
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def formatMessage(self, record: logging.LogRecord) -> str:
|
|
45
|
+
recordcopy = copy(record)
|
|
46
|
+
levelname = recordcopy.levelname
|
|
47
|
+
seperator = " " * (8 - len(recordcopy.levelname))
|
|
48
|
+
recordcopy.__dict__["levelprefix"] = colorize(levelname) + ":" + seperator
|
|
49
|
+
return super().formatMessage(recordcopy)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def configure_logging(loglevel):
|
|
53
|
+
|
|
54
|
+
root = logging.getLogger()
|
|
55
|
+
sh = logging.StreamHandler()
|
|
56
|
+
df = DefaultFormatter("%(levelprefix)s %(message)s")
|
|
57
|
+
sh.setFormatter(df)
|
|
58
|
+
root.addHandler(sh)
|
|
59
|
+
root.setLevel(loglevel)
|
|
60
|
+
|
|
61
|
+
# set levels logging
|
|
62
|
+
logging.getLogger("uvicorn").setLevel("INFO")
|
|
63
|
+
logging.getLogger("python_multipart").setLevel("INFO")
|
|
64
|
+
logging.getLogger("sqlalchemy.engine").setLevel("WARNING")
|
|
65
|
+
logging.getLogger("aiosqlite").setLevel("WARNING")
|
|
66
|
+
logging.getLogger("databases").setLevel("WARNING")
|