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.
Files changed (136) hide show
  1. cherrypy_foundation/__init__.py +0 -0
  2. cherrypy_foundation/components/ColorModes.jinja +70 -0
  3. cherrypy_foundation/components/Datatable.css +47 -0
  4. cherrypy_foundation/components/Datatable.jinja +63 -0
  5. cherrypy_foundation/components/Datatable.js +358 -0
  6. cherrypy_foundation/components/Field.css +10 -0
  7. cherrypy_foundation/components/Field.jinja +66 -0
  8. cherrypy_foundation/components/Field.js +56 -0
  9. cherrypy_foundation/components/Fields.jinja +4 -0
  10. cherrypy_foundation/components/Flash.jinja +13 -0
  11. cherrypy_foundation/components/Icon.jinja +3 -0
  12. cherrypy_foundation/components/LocaleSelection.jinja +13 -0
  13. cherrypy_foundation/components/LocaleSelection.js +26 -0
  14. cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
  15. cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
  16. cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
  17. cherrypy_foundation/components/Typeahead.css +55 -0
  18. cherrypy_foundation/components/Typeahead.jinja +106 -0
  19. cherrypy_foundation/components/Typeahead.js +8 -0
  20. cherrypy_foundation/components/__init__.py +51 -0
  21. cherrypy_foundation/components/tests/__init__.py +0 -0
  22. cherrypy_foundation/components/tests/test_static.py +90 -0
  23. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  24. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  25. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  26. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  27. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
  28. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
  29. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
  30. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
  31. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
  32. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
  33. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
  34. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
  35. cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
  36. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
  37. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
  38. cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
  39. cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
  40. cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
  41. cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
  42. cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
  43. cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
  44. cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
  45. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
  46. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
  47. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
  48. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
  49. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
  50. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
  51. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
  52. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
  53. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
  54. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
  55. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
  56. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
  57. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
  58. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
  59. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
  60. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
  61. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
  62. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
  63. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
  64. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
  65. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
  66. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
  67. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
  68. cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
  69. cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
  70. cherrypy_foundation/components/vendor/multi/README.md +109 -0
  71. cherrypy_foundation/components/vendor/multi/multi.css +95 -0
  72. cherrypy_foundation/components/vendor/multi/multi.js +328 -0
  73. cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
  74. cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
  75. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
  76. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
  77. cherrypy_foundation/error_page.py +94 -0
  78. cherrypy_foundation/flash.py +50 -0
  79. cherrypy_foundation/form.py +119 -0
  80. cherrypy_foundation/logging.py +103 -0
  81. cherrypy_foundation/passwd.py +65 -0
  82. cherrypy_foundation/plugins/__init__.py +0 -0
  83. cherrypy_foundation/plugins/db.py +286 -0
  84. cherrypy_foundation/plugins/ldap.py +257 -0
  85. cherrypy_foundation/plugins/restapi.py +74 -0
  86. cherrypy_foundation/plugins/scheduler.py +287 -0
  87. cherrypy_foundation/plugins/smtp.py +223 -0
  88. cherrypy_foundation/plugins/tests/__init__.py +0 -0
  89. cherrypy_foundation/plugins/tests/test_db.py +118 -0
  90. cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
  91. cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
  92. cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
  93. cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
  94. cherrypy_foundation/sessions.py +93 -0
  95. cherrypy_foundation/tests/__init__.py +72 -0
  96. cherrypy_foundation/tests/templates/test_flash.html +9 -0
  97. cherrypy_foundation/tests/templates/test_form.html +16 -0
  98. cherrypy_foundation/tests/templates/test_url.html +15 -0
  99. cherrypy_foundation/tests/test_error_page.py +78 -0
  100. cherrypy_foundation/tests/test_flash.py +61 -0
  101. cherrypy_foundation/tests/test_form.py +148 -0
  102. cherrypy_foundation/tests/test_logging.py +78 -0
  103. cherrypy_foundation/tests/test_passwd.py +51 -0
  104. cherrypy_foundation/tests/test_sessions.py +89 -0
  105. cherrypy_foundation/tests/test_url.py +161 -0
  106. cherrypy_foundation/tools/__init__.py +0 -0
  107. cherrypy_foundation/tools/auth.py +263 -0
  108. cherrypy_foundation/tools/auth_mfa.py +249 -0
  109. cherrypy_foundation/tools/i18n.py +529 -0
  110. cherrypy_foundation/tools/jinja2.py +158 -0
  111. cherrypy_foundation/tools/ratelimit.py +265 -0
  112. cherrypy_foundation/tools/secure_headers.py +119 -0
  113. cherrypy_foundation/tools/sessions_timeout.py +167 -0
  114. cherrypy_foundation/tools/tests/__init__.py +0 -0
  115. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  116. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  117. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
  118. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
  119. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
  120. cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
  121. cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
  122. cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
  123. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
  124. cherrypy_foundation/tools/tests/test_auth.py +110 -0
  125. cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
  126. cherrypy_foundation/tools/tests/test_i18n.py +247 -0
  127. cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
  128. cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
  129. cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
  130. cherrypy_foundation/url.py +66 -0
  131. cherrypy_foundation/widgets.py +48 -0
  132. cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
  133. cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
  134. cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
  135. cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
  136. 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&amp;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&amp;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&amp;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&amp;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&amp;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()