cherrypy-foundation 1.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.
- cherrypy_foundation/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# CherryPy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import cherrypy
|
|
21
|
+
from cherrypy.test import helper
|
|
22
|
+
|
|
23
|
+
from ..logging import setup_logging
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Root:
|
|
27
|
+
|
|
28
|
+
@cherrypy.expose
|
|
29
|
+
def index(self):
|
|
30
|
+
return "OK"
|
|
31
|
+
|
|
32
|
+
@cherrypy.expose
|
|
33
|
+
def error(self):
|
|
34
|
+
cherrypy.log('error messages to be logged')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LoggingTest(helper.CPWebCase):
|
|
38
|
+
interactive = False
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def setup_server(cls):
|
|
42
|
+
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-logging')
|
|
43
|
+
cls.log_access_file = f'{cls.tempdir.name}/access.log'
|
|
44
|
+
cls.log_file = f'{cls.tempdir.name}/error.log'
|
|
45
|
+
setup_logging(log_file=cls.log_file, log_access_file=cls.log_access_file, level='DEBUG')
|
|
46
|
+
cherrypy.tree.mount(Root(), '/')
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def teardown_class(cls):
|
|
50
|
+
# Delete temp folder
|
|
51
|
+
cls.tempdir.cleanup()
|
|
52
|
+
# Stop server
|
|
53
|
+
super().teardown_class()
|
|
54
|
+
# Reset logging to default.
|
|
55
|
+
# Re-enable screen logging
|
|
56
|
+
cherrypy.config.update({'log.screen': True, 'log.error_file': '', 'log.access_file': ''})
|
|
57
|
+
# Reset internal logger references
|
|
58
|
+
cherrypy.log.error_file = None
|
|
59
|
+
cherrypy.log.access_file = None
|
|
60
|
+
|
|
61
|
+
def test_logging_access(self):
|
|
62
|
+
mtime = Path(self.log_access_file).stat().st_mtime
|
|
63
|
+
# When page get queried
|
|
64
|
+
self.getPage('/')
|
|
65
|
+
# Then access files get updated
|
|
66
|
+
mtime2 = Path(self.log_access_file).stat().st_mtime
|
|
67
|
+
self.assertNotEqual(mtime, mtime2)
|
|
68
|
+
|
|
69
|
+
def test_logging_error(self):
|
|
70
|
+
data = Path(self.log_file).read_text()
|
|
71
|
+
self.assertNotIn('error messages to be logged', data)
|
|
72
|
+
# When page get queried
|
|
73
|
+
self.getPage('/error')
|
|
74
|
+
self.assertStatus(200)
|
|
75
|
+
# Then access files get updated
|
|
76
|
+
data2 = Path(self.log_file).read_text()
|
|
77
|
+
self.assertNotEqual(data, data2)
|
|
78
|
+
self.assertIn('error messages to be logged', data2)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
'''
|
|
17
|
+
Created on Apr. 10, 2020
|
|
18
|
+
|
|
19
|
+
@author: Patrik Dufresne
|
|
20
|
+
'''
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
import unittest
|
|
24
|
+
|
|
25
|
+
from ..passwd import check_password, hash_password
|
|
26
|
+
|
|
27
|
+
HAS_ARGON2 = importlib.util.find_spec("argon2") is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Test(unittest.TestCase):
|
|
31
|
+
|
|
32
|
+
@unittest.skipUnless(HAS_ARGON2, "argon2 not installed")
|
|
33
|
+
def test_check_password_argon(self):
|
|
34
|
+
self.assertTrue(hash_password('admin12').startswith('$argon2'))
|
|
35
|
+
self.assertTrue(
|
|
36
|
+
check_password('admin123', '$argon2id$v=19$m=102400,t=2,p=8$/mDhOg8wyZeMTUjcbIC7mg$3pxRSfYgUXmKEKNtasP1Og')
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@unittest.skipIf(HAS_ARGON2, "argon2 is installed")
|
|
40
|
+
def test_check_password_hashlib(self):
|
|
41
|
+
# Given argon is not installed
|
|
42
|
+
# When generating hash password
|
|
43
|
+
hash = hash_password('admin12')
|
|
44
|
+
# Then SSHA hash is generated
|
|
45
|
+
self.assertTrue(hash.startswith('{SSHA}'))
|
|
46
|
+
|
|
47
|
+
def test_check_password(self):
|
|
48
|
+
self.assertTrue(check_password('admin123', '{SSHA}/LAr7zGT/Rv/CEsbrEndyh27h+4fLb9h'))
|
|
49
|
+
self.assertFalse(check_password('admin12', '{SSHA}/LAr7zGT/Rv/CEsbrEndyh27h+4fLb9h'))
|
|
50
|
+
self.assertTrue(check_password('admin12', hash_password('admin12')))
|
|
51
|
+
self.assertTrue(check_password('admin123', hash_password('admin123')))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
|
|
20
|
+
import cherrypy
|
|
21
|
+
from cherrypy.test import helper
|
|
22
|
+
|
|
23
|
+
from cherrypy_foundation.sessions import FileSession, session_lock
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cherrypy.tools.sessions(on=True, locking='explicit', storage_class=FileSession)
|
|
27
|
+
class Root:
|
|
28
|
+
|
|
29
|
+
@cherrypy.expose
|
|
30
|
+
def index(self, value='OK'):
|
|
31
|
+
if value:
|
|
32
|
+
with session_lock() as s:
|
|
33
|
+
s['value'] = value
|
|
34
|
+
return s['value']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FileSessionTest(helper.CPWebCase):
|
|
38
|
+
interactive = False
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def setup_class(cls):
|
|
42
|
+
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-file-session-test')
|
|
43
|
+
cls.storage_path = cls.tempdir.name
|
|
44
|
+
super().setup_class()
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def teardown_class(cls):
|
|
48
|
+
cls.tempdir.cleanup()
|
|
49
|
+
super().teardown_class()
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def setup_server(cls):
|
|
53
|
+
cherrypy.config.update(
|
|
54
|
+
{
|
|
55
|
+
'tools.sessions.storage_path': cls.storage_path,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
cherrypy.tree.mount(Root(), '/')
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def _session_id(self):
|
|
62
|
+
"""Return session id from cookie."""
|
|
63
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
64
|
+
for unused, value in self.cookies:
|
|
65
|
+
for part in value.split(';'):
|
|
66
|
+
key, unused, value = part.partition('=')
|
|
67
|
+
if key == 'session_id':
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def test_get_page(self):
|
|
71
|
+
# Given a page with session enabled
|
|
72
|
+
# When the page get queried
|
|
73
|
+
self.getPage("/")
|
|
74
|
+
# Then a session is created with a id
|
|
75
|
+
self.assertStatus(200)
|
|
76
|
+
self.assertTrue(self._session_id)
|
|
77
|
+
# Then this session is created on disk.
|
|
78
|
+
s = FileSession(id=self._session_id, storage_path=self.storage_path)
|
|
79
|
+
self.assertTrue(s._exists())
|
|
80
|
+
# When session timeout and get clean-up
|
|
81
|
+
s.acquire_lock()
|
|
82
|
+
s.load()
|
|
83
|
+
s.timeout = 0
|
|
84
|
+
s.save()
|
|
85
|
+
s.clean_up()
|
|
86
|
+
# Then session get deleted
|
|
87
|
+
self.assertFalse(s._exists())
|
|
88
|
+
# Lock file also get deleted.
|
|
89
|
+
self.assertFalse(os.path.exists(s._get_file_path() + FileSession.LOCK_SUFFIX))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import cherrypy
|
|
18
|
+
from cherrypy.test import helper
|
|
19
|
+
|
|
20
|
+
import cherrypy_foundation.tools.jinja2 # noqa
|
|
21
|
+
|
|
22
|
+
from ..url import url_for
|
|
23
|
+
|
|
24
|
+
env = cherrypy.tools.jinja2.create_env(
|
|
25
|
+
package_name=__package__,
|
|
26
|
+
globals={
|
|
27
|
+
'url_for': url_for,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SubPage:
|
|
33
|
+
|
|
34
|
+
@cherrypy.expose
|
|
35
|
+
@cherrypy.tools.jinja2(template='test_url.html')
|
|
36
|
+
def index(self, **kwargs):
|
|
37
|
+
_relative = cherrypy.request.headers.get('-Relative')
|
|
38
|
+
return {'kwargs': {'_relative': _relative or None}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cherrypy.tools.proxy(base='https://www.example.com')
|
|
42
|
+
class ProxiedPage:
|
|
43
|
+
|
|
44
|
+
@cherrypy.expose
|
|
45
|
+
@cherrypy.tools.jinja2(template='test_url.html')
|
|
46
|
+
def index(self, **kwargs):
|
|
47
|
+
_relative = cherrypy.request.headers.get('-Relative')
|
|
48
|
+
return {'kwargs': {'_relative': _relative or None}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cherrypy.tools.jinja2(env=env)
|
|
52
|
+
class Root:
|
|
53
|
+
sub_page = SubPage()
|
|
54
|
+
proxied = ProxiedPage()
|
|
55
|
+
|
|
56
|
+
@cherrypy.expose
|
|
57
|
+
@cherrypy.tools.jinja2(template='test_url.html')
|
|
58
|
+
def index(self, **kwargs):
|
|
59
|
+
_relative = cherrypy.request.headers.get('-Relative')
|
|
60
|
+
return {'kwargs': {'_relative': _relative or None}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UrlTest(helper.CPWebCase):
|
|
64
|
+
default_lang = None
|
|
65
|
+
interactive = False
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def setup_server(cls):
|
|
69
|
+
cherrypy.tree.mount(Root(), '/')
|
|
70
|
+
|
|
71
|
+
def test_url_for(self):
|
|
72
|
+
self.assertEqual(url_for("foo", "bar"), 'http://127.0.0.1:54583/foo/bar')
|
|
73
|
+
self.assertEqual(url_for("foo", "bar", _relative='server'), '/foo/bar')
|
|
74
|
+
# Outside a request, relative url doesn't make alot of sens.
|
|
75
|
+
self.assertEqual(url_for("foo", "bar", _relative=1), '127.0.0.1:54583/foo/bar')
|
|
76
|
+
self.assertEqual(url_for("foo", "bar", _base='http://test.com'), 'http://test.com/foo/bar')
|
|
77
|
+
|
|
78
|
+
def test_get_page(self):
|
|
79
|
+
# Given a form
|
|
80
|
+
# When querying the page that include this form
|
|
81
|
+
self.getPage("/")
|
|
82
|
+
self.assertStatus(200)
|
|
83
|
+
# Then each field is render properly.
|
|
84
|
+
# 1. Check title
|
|
85
|
+
self.assertInBody('test-url')
|
|
86
|
+
# 2. Check user field
|
|
87
|
+
self.assertInBody(f'Empty: http://{self.HOST}:{self.PORT}/')
|
|
88
|
+
self.assertInBody(f'Dot: http://{self.HOST}:{self.PORT}/')
|
|
89
|
+
self.assertInBody(f'Dot page: http://{self.HOST}:{self.PORT}/my-page')
|
|
90
|
+
self.assertInBody(f'Slash: http://{self.HOST}:{self.PORT}/')
|
|
91
|
+
self.assertInBody(f'Page: http://{self.HOST}:{self.PORT}/my-page')
|
|
92
|
+
self.assertInBody(f'Slash page: http://{self.HOST}:{self.PORT}/my-page')
|
|
93
|
+
self.assertInBody(f'Query: http://{self.HOST}:{self.PORT}/my-page?bar=test+with+space&foo=1')
|
|
94
|
+
|
|
95
|
+
def test_get_page_relative_true(self):
|
|
96
|
+
# Given a form
|
|
97
|
+
# When querying the page that include this form
|
|
98
|
+
self.getPage("/", headers=[('_relative', '1')])
|
|
99
|
+
self.assertStatus(200)
|
|
100
|
+
# Then each field is render properly.
|
|
101
|
+
# 1. Check title
|
|
102
|
+
self.assertInBody('test-url')
|
|
103
|
+
# 2. Check user field
|
|
104
|
+
self.assertInBody('Empty: <br/>')
|
|
105
|
+
self.assertInBody('Dot: <br/>')
|
|
106
|
+
self.assertInBody('Dot page: my-page<br/>')
|
|
107
|
+
self.assertInBody('Slash: <br/>')
|
|
108
|
+
self.assertInBody('Page: my-page<br/>')
|
|
109
|
+
self.assertInBody('Slash page: my-page<br/>')
|
|
110
|
+
self.assertInBody('Query: my-page?bar=test+with+space&foo=1<br/>')
|
|
111
|
+
|
|
112
|
+
def test_get_page_relative_server(self):
|
|
113
|
+
# Given a form
|
|
114
|
+
# When querying the page that include this form
|
|
115
|
+
self.getPage("/", headers=[('_relative', 'server')])
|
|
116
|
+
self.assertStatus(200)
|
|
117
|
+
# Then each field is render properly.
|
|
118
|
+
# 1. Check title
|
|
119
|
+
self.assertInBody('test-url')
|
|
120
|
+
# 2. Check user field
|
|
121
|
+
self.assertInBody('Empty: /<br/>')
|
|
122
|
+
self.assertInBody('Dot: /<br/>')
|
|
123
|
+
self.assertInBody('Dot page: /my-page<br/>')
|
|
124
|
+
self.assertInBody('Slash: /<br/>')
|
|
125
|
+
self.assertInBody('Page: /my-page<br/>')
|
|
126
|
+
self.assertInBody('Slash page: /my-page<br/>')
|
|
127
|
+
self.assertInBody('Query: /my-page?bar=test+with+space&foo=1<br/>')
|
|
128
|
+
|
|
129
|
+
def test_get_page_proxied(self):
|
|
130
|
+
# Given a form
|
|
131
|
+
# When querying the page that include this form
|
|
132
|
+
self.getPage("/proxied/")
|
|
133
|
+
self.assertStatus(200)
|
|
134
|
+
# Then each field is render properly.
|
|
135
|
+
# 1. Check title
|
|
136
|
+
self.assertInBody('test-url')
|
|
137
|
+
# 2. Check user field
|
|
138
|
+
self.assertInBody('Empty: https://www.example.com/')
|
|
139
|
+
self.assertInBody('Dot: https://www.example.com/proxied/')
|
|
140
|
+
self.assertInBody('Dot page: https://www.example.com/proxied/my-page<br/>')
|
|
141
|
+
self.assertInBody('Slash: https://www.example.com/')
|
|
142
|
+
self.assertInBody('Page: https://www.example.com/my-page')
|
|
143
|
+
self.assertInBody('Slash page: https://www.example.com/my-page')
|
|
144
|
+
self.assertInBody('Query: https://www.example.com/my-page?bar=test+with+space&foo=1')
|
|
145
|
+
|
|
146
|
+
def test_get_sub_page(self):
|
|
147
|
+
# Given a form
|
|
148
|
+
# When querying the page that include this form
|
|
149
|
+
self.getPage("/sub-page/")
|
|
150
|
+
self.assertStatus(200)
|
|
151
|
+
# Then each field is render properly.
|
|
152
|
+
# 1. Check title
|
|
153
|
+
self.assertInBody('test-url')
|
|
154
|
+
# 2. Check user field
|
|
155
|
+
self.assertInBody(f'Empty: http://{self.HOST}:{self.PORT}/sub-page/')
|
|
156
|
+
self.assertInBody(f'Dot: http://{self.HOST}:{self.PORT}/sub-page/')
|
|
157
|
+
self.assertInBody(f'Dot page: http://{self.HOST}:{self.PORT}/sub-page/my-page')
|
|
158
|
+
self.assertInBody(f'Slash: http://{self.HOST}:{self.PORT}/')
|
|
159
|
+
self.assertInBody(f'Page: http://{self.HOST}:{self.PORT}/my-page')
|
|
160
|
+
self.assertInBody(f'Slash page: http://{self.HOST}:{self.PORT}/my-page')
|
|
161
|
+
self.assertInBody(f'Query: http://{self.HOST}:{self.PORT}/my-page?bar=test+with+space&foo=1')
|
|
File without changes
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Authentication tools for cherrypy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import logging
|
|
19
|
+
import urllib.parse
|
|
20
|
+
|
|
21
|
+
import cherrypy
|
|
22
|
+
|
|
23
|
+
from cherrypy_foundation.sessions import session_lock
|
|
24
|
+
|
|
25
|
+
AUTH_LAST_PASSWORD_AT = '_auth_last_password_at'
|
|
26
|
+
AUTH_METHOD = '_auth_method'
|
|
27
|
+
AUTH_ORIGINAL_URL = '_auth_original_url'
|
|
28
|
+
|
|
29
|
+
AUTH_DEFAULT_REDIRECT = "/login/"
|
|
30
|
+
AUTH_DEFAULT_SESSION_KEY = "_auth_session_key"
|
|
31
|
+
AUTH_DEFAULT_REAUTH_TIMEOUT = 60 # minutes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthManager(cherrypy.Tool):
|
|
35
|
+
"""
|
|
36
|
+
CherryPy tool handling authentication.
|
|
37
|
+
|
|
38
|
+
Required config:
|
|
39
|
+
- tools.auth.user_lookup_func(login, user_info) -> (user_key, userobj)
|
|
40
|
+
- tools.auth.user_from_key_func(user_key) -> userobj | None
|
|
41
|
+
- tools.auth.checkpassword: callable or list of callables (login, password) ->
|
|
42
|
+
False | (login, user_info) | str(login)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
super().__init__(point='before_handler', callable=self._restore_from_session, priority=70)
|
|
47
|
+
|
|
48
|
+
def _setup(self):
|
|
49
|
+
cherrypy.Tool._setup(self)
|
|
50
|
+
# Attach additional hooks as different priority to update preferred lang with more accurate preferences.
|
|
51
|
+
conf = self._merged_args()
|
|
52
|
+
conf.pop('priority', None)
|
|
53
|
+
cherrypy.serving.request.hooks.attach('before_handler', self._forbidden_or_redirect, priority=74, **conf)
|
|
54
|
+
|
|
55
|
+
# ---- Config helpers ----
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _reauth_timeout_minutes() -> int:
|
|
59
|
+
# How long until we force a full username/password re-login regardless of session timeout.
|
|
60
|
+
return int(cherrypy.request.config.get('tools.auth.reauth_timeout', AUTH_DEFAULT_REAUTH_TIMEOUT))
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _session_user_key() -> int:
|
|
64
|
+
return cherrypy.request.config.get('tools.auth.session_user_key', AUTH_DEFAULT_SESSION_KEY)
|
|
65
|
+
|
|
66
|
+
# ---- Tool entrypoints ----
|
|
67
|
+
|
|
68
|
+
def _restore_from_session(self, **kwargs):
|
|
69
|
+
"""
|
|
70
|
+
Early hook: attempt to restore 'authenticated' state from session.
|
|
71
|
+
"""
|
|
72
|
+
# Verify if session is enabled, if not the user is not authenticated.
|
|
73
|
+
if not hasattr(cherrypy.serving, 'session'):
|
|
74
|
+
return
|
|
75
|
+
# Check if a user_key is stored in session.
|
|
76
|
+
with session_lock() as session:
|
|
77
|
+
user_key = session.get(self._session_user_key())
|
|
78
|
+
if not user_key:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
last = session.get(AUTH_LAST_PASSWORD_AT)
|
|
82
|
+
if last is None:
|
|
83
|
+
return # never had a password login in this session
|
|
84
|
+
timeout = self._reauth_timeout_minutes()
|
|
85
|
+
if (last + datetime.timedelta(minutes=timeout)) < session.now():
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Mark request as authenticated by key; user object will be resolved later.
|
|
89
|
+
cherrypy.serving.request.login = user_key
|
|
90
|
+
|
|
91
|
+
def _forbidden_or_redirect(self, user_from_key_func, redirect=AUTH_DEFAULT_REDIRECT, **kwargs):
|
|
92
|
+
"""
|
|
93
|
+
If authenticated via session, resolve user object; else redirect/403.
|
|
94
|
+
"""
|
|
95
|
+
# Allow access to the login page itself
|
|
96
|
+
if cherrypy.serving.request.path_info == redirect:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
user_key = getattr(cherrypy.serving.request, 'login', False)
|
|
100
|
+
if user_key:
|
|
101
|
+
try:
|
|
102
|
+
currentuser = user_from_key_func(user_key)
|
|
103
|
+
except Exception:
|
|
104
|
+
cherrypy.log(
|
|
105
|
+
f'unexpected error searching for user_key={user_key}',
|
|
106
|
+
context='AUTH',
|
|
107
|
+
severity=logging.ERROR,
|
|
108
|
+
traceback=True,
|
|
109
|
+
)
|
|
110
|
+
currentuser = None
|
|
111
|
+
|
|
112
|
+
if currentuser:
|
|
113
|
+
cherrypy.serving.request.currentuser = currentuser
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Not authenticated or user not found.
|
|
117
|
+
if redirect:
|
|
118
|
+
self.save_original_url()
|
|
119
|
+
raise cherrypy.HTTPRedirect(redirect)
|
|
120
|
+
raise cherrypy.HTTPError(403)
|
|
121
|
+
|
|
122
|
+
# ---- Login flows ----
|
|
123
|
+
|
|
124
|
+
def login_with_credentials(self, login, password):
|
|
125
|
+
"""
|
|
126
|
+
Validate credentials with configured checkers; on success, call login_with_result.
|
|
127
|
+
"""
|
|
128
|
+
if not login or not password:
|
|
129
|
+
cherrypy.log('authentication failed reason=empty_credentials', context='AUTH', severity=logging.WARNING)
|
|
130
|
+
return None
|
|
131
|
+
# Validate credentials using checkpassword function(s).
|
|
132
|
+
conf = self._merged_args()
|
|
133
|
+
checkpassword = conf.get('checkpassword')
|
|
134
|
+
if not isinstance(checkpassword, (list, tuple)):
|
|
135
|
+
checkpassword = [checkpassword]
|
|
136
|
+
for func in checkpassword:
|
|
137
|
+
try:
|
|
138
|
+
valid = func(login, password)
|
|
139
|
+
if not valid:
|
|
140
|
+
continue
|
|
141
|
+
# Support various return value: tuple, string with username, boolean value
|
|
142
|
+
if isinstance(valid, (list, tuple)) and len(valid) >= 2:
|
|
143
|
+
login, user_info = valid
|
|
144
|
+
elif isinstance(valid, str):
|
|
145
|
+
login, user_info = valid, None
|
|
146
|
+
else:
|
|
147
|
+
login, user_info = login, None
|
|
148
|
+
# If authentication is successful, initiate login process.
|
|
149
|
+
return self.login_with_result(login=login, user_info=user_info)
|
|
150
|
+
except Exception:
|
|
151
|
+
cherrypy.log(
|
|
152
|
+
f'unexpected error checking password login={login} checkpassword={func.__qualname__} - continue with next function',
|
|
153
|
+
context='AUTH',
|
|
154
|
+
severity=logging.ERROR,
|
|
155
|
+
traceback=True,
|
|
156
|
+
)
|
|
157
|
+
# If we reach here, authentication failed
|
|
158
|
+
if hasattr(cherrypy.serving, 'session'):
|
|
159
|
+
with session_lock() as session:
|
|
160
|
+
session.regenerate() # Prevent session analysis
|
|
161
|
+
|
|
162
|
+
remote_ip = cherrypy.serving.request.remote.ip
|
|
163
|
+
cherrypy.log(
|
|
164
|
+
f'authentication failed login={login} ip={remote_ip} reason=wrong_credentials',
|
|
165
|
+
context='AUTH',
|
|
166
|
+
severity=logging.WARNING,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def login_with_result(self, login=None, user_info=None, auth_method='password'):
|
|
172
|
+
"""
|
|
173
|
+
Called after credentials were validated or via SSO.
|
|
174
|
+
Resolves user via user_lookup_func and establishes the session.
|
|
175
|
+
"""
|
|
176
|
+
conf = self._merged_args()
|
|
177
|
+
user_lookup_func = conf.get('user_lookup_func')
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
user_key, userobj = user_lookup_func(login=login, user_info=user_info or {})
|
|
181
|
+
except Exception:
|
|
182
|
+
cherrypy.log(
|
|
183
|
+
f"unexpected error searching user login={login} user_info={user_info}",
|
|
184
|
+
context='AUTH',
|
|
185
|
+
severity=logging.ERROR,
|
|
186
|
+
traceback=True,
|
|
187
|
+
)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if not userobj:
|
|
191
|
+
cherrypy.log(
|
|
192
|
+
f"authentication failed login={login} reason=not_found", context='AUTH', severity=logging.WARNING
|
|
193
|
+
)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# Notify plugins about user login
|
|
197
|
+
cherrypy.engine.publish('user_login', userobj)
|
|
198
|
+
|
|
199
|
+
# Store in session
|
|
200
|
+
if hasattr(cherrypy.serving, 'session'):
|
|
201
|
+
with session_lock() as session:
|
|
202
|
+
session_user_key = self._session_user_key()
|
|
203
|
+
session[session_user_key] = user_key
|
|
204
|
+
session[AUTH_METHOD] = auth_method
|
|
205
|
+
session[AUTH_LAST_PASSWORD_AT] = session.now()
|
|
206
|
+
# Generate a new session id
|
|
207
|
+
session.regenerate()
|
|
208
|
+
|
|
209
|
+
# When authenticated, store user_key in request.
|
|
210
|
+
cherrypy.serving.request.login = user_key
|
|
211
|
+
return userobj
|
|
212
|
+
|
|
213
|
+
# ---- Session helpers ----
|
|
214
|
+
|
|
215
|
+
def clear_session(self):
|
|
216
|
+
"""
|
|
217
|
+
Clear session data and generate a new session id.
|
|
218
|
+
"""
|
|
219
|
+
with session_lock() as session:
|
|
220
|
+
session.clear()
|
|
221
|
+
session.regenerate()
|
|
222
|
+
|
|
223
|
+
def get_original_url(self):
|
|
224
|
+
"""
|
|
225
|
+
Return the original URL browsed by the user before authentication.
|
|
226
|
+
"""
|
|
227
|
+
if not hasattr(cherrypy.serving, 'session'):
|
|
228
|
+
return None
|
|
229
|
+
with session_lock() as session:
|
|
230
|
+
return session.get(AUTH_ORIGINAL_URL)
|
|
231
|
+
|
|
232
|
+
def get_user_key(self):
|
|
233
|
+
"""Return the last username."""
|
|
234
|
+
if not hasattr(cherrypy.serving, 'session'):
|
|
235
|
+
return False
|
|
236
|
+
session_user_key = self._session_user_key()
|
|
237
|
+
with session_lock() as session:
|
|
238
|
+
return session.get(session_user_key)
|
|
239
|
+
|
|
240
|
+
def redirect_to_original_url(self):
|
|
241
|
+
# Redirect user to original URL
|
|
242
|
+
redirect_url = self.get_original_url() or '/'
|
|
243
|
+
return cherrypy.HTTPRedirect(redirect_url)
|
|
244
|
+
|
|
245
|
+
def save_original_url(self):
|
|
246
|
+
"""
|
|
247
|
+
Save the current URL to user's session.
|
|
248
|
+
"""
|
|
249
|
+
# Skip this step if session is not enabled
|
|
250
|
+
if not hasattr(cherrypy.serving, 'session'):
|
|
251
|
+
return
|
|
252
|
+
# Extract URL including query-string.
|
|
253
|
+
request = cherrypy.serving.request
|
|
254
|
+
uri_encoding = getattr(request, 'uri_encoding', 'utf-8')
|
|
255
|
+
original_url = urllib.parse.quote(request.path_info, encoding=uri_encoding)
|
|
256
|
+
query_string = request.query_string
|
|
257
|
+
|
|
258
|
+
# Store value in session
|
|
259
|
+
with session_lock() as session:
|
|
260
|
+
session[AUTH_ORIGINAL_URL] = cherrypy.url(original_url, qs=query_string, base='')
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
cherrypy.tools.auth = AuthManager()
|