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,249 @@
1
+ # MFA tool for cherrypy
2
+ # Copyright (C) 2022-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
+ import datetime
17
+ import logging
18
+ import secrets
19
+ import string
20
+ from typing import Optional
21
+
22
+ import cherrypy
23
+
24
+ from cherrypy_foundation.sessions import session_lock
25
+
26
+ from ..passwd import check_password, hash_password
27
+
28
+ MFA_CODE = '_auth_mfa_code'
29
+ MFA_CODE_TIME = '_auth_mfa_code_time'
30
+ MFA_CODE_ATTEMPT = '_auth_mfa_code_attempt'
31
+ MFA_REDIRECT_URL = '_auth_mfa_redirect_url'
32
+ MFA_TRUSTED_IP_LIST = '_auth_mfa_trusted_ip_list'
33
+ MFA_USER_KEY = '_auth_mfa_user_key'
34
+ MFA_VERIFICATION_TIME = '_auth_mfa_time'
35
+
36
+ MFA_DEFAULT_CODE_TIMEOUT = 10 # minutes
37
+ MFA_DEFAULT_LENGTH = 8
38
+ MFA_DEFAULT_MAX_ATTEMPT = 3
39
+ MFA_DEFAULT_MAX_TRUSTED_IPS = 5
40
+ MFA_DEFAULT_TRUST_DURATION = 43200 # 30 days
41
+
42
+
43
+ class CheckAuthMfa(cherrypy.Tool):
44
+ def __init__(self, priority: int = 75):
45
+ super().__init__(point='before_handler', callable=self.run, priority=priority)
46
+
47
+ # ---- Config helpers ----
48
+
49
+ @staticmethod
50
+ def _code_length() -> int:
51
+ """Return the configured code length."""
52
+ length = cherrypy.request.config.get('tools.auth_mfa.code_length', MFA_DEFAULT_LENGTH)
53
+ return max(1, int(length))
54
+
55
+ @staticmethod
56
+ def _code_timeout_minutes() -> int:
57
+ # Lifetime for a one-time MFA code
58
+ return int(cherrypy.request.config.get('tools.auth_mfa.code_timeout', MFA_DEFAULT_CODE_TIMEOUT))
59
+
60
+ @staticmethod
61
+ def _max_attempts() -> int:
62
+ return int(cherrypy.request.config.get('tools.auth_mfa.max_attempts', MFA_DEFAULT_MAX_ATTEMPT))
63
+
64
+ @staticmethod
65
+ def _max_trusted_ips() -> int:
66
+ return int(cherrypy.request.config.get('tools.auth_mfa.max_trusted_ips', MFA_DEFAULT_MAX_TRUSTED_IPS))
67
+
68
+ @staticmethod
69
+ def _trust_duration_minutes() -> int:
70
+ # How long a device/IP remains “MFA-verified”
71
+ return int(cherrypy.request.config.get('tools.auth_mfa.trust_duration', MFA_DEFAULT_TRUST_DURATION))
72
+
73
+ # ---- Core operations ----
74
+
75
+ def generate_code(self) -> str:
76
+ """
77
+ Generate a random numeric code, store its hash and metadata in the session,
78
+ and return the cleartext so the caller can deliver it out-of-band.
79
+ """
80
+ if not hasattr(cherrypy.serving, 'session'):
81
+ raise cherrypy.HTTPError(500, 'MFA requires sessions')
82
+
83
+ if not cherrypy.request.login:
84
+ raise cherrypy.HTTPError(401, 'MFA requires an authenticated username')
85
+
86
+ length = self._code_length()
87
+ code = ''.join(secrets.choice(string.digits) for _ in range(length))
88
+ with session_lock() as session:
89
+ session[MFA_USER_KEY] = cherrypy.request.login
90
+ session[MFA_CODE] = hash_password(code)
91
+ session[MFA_CODE_TIME] = session.now()
92
+ session[MFA_CODE_ATTEMPT] = 0
93
+ return code
94
+
95
+ def _is_verified(self) -> bool:
96
+ """Return True if current user/IP is within the MFA trust window."""
97
+ if not cherrypy.request.login or not hasattr(cherrypy.serving, 'session'):
98
+ return False
99
+
100
+ # Check if our username match current login user.
101
+ with session_lock() as session:
102
+ if session.get(MFA_USER_KEY) != cherrypy.request.login:
103
+ return False
104
+
105
+ # Check if the current IP is trusted
106
+ ip_list = session.get(MFA_TRUSTED_IP_LIST) or []
107
+ if cherrypy.request.remote.ip not in ip_list:
108
+ return False
109
+
110
+ # Check if user ever pass the verification.
111
+ verified_at: Optional[datetime.datetime] = session.get(MFA_VERIFICATION_TIME)
112
+ if not verified_at:
113
+ return False
114
+
115
+ # Check trusted duration time
116
+ trust_minutes = self._trust_duration_minutes()
117
+ if (verified_at + datetime.timedelta(minutes=trust_minutes)) <= session.now():
118
+ return False
119
+
120
+ return True
121
+
122
+ def is_code_expired(self) -> bool:
123
+ """
124
+ Return True if the current user's MFA code is absent, expired, or attempts exceeded.
125
+ """
126
+ # Check session existence first to avoid AttributeError
127
+ if not hasattr(cherrypy.serving, 'session'):
128
+ return True
129
+
130
+ with session_lock() as session:
131
+ if session.get(MFA_USER_KEY) != cherrypy.request.login:
132
+ return True
133
+
134
+ # Check if code is defined.
135
+ hash_ = session.get(MFA_CODE)
136
+ if not hash_:
137
+ return True
138
+
139
+ # Check issued time.
140
+ issued_at: Optional[datetime.datetime] = session.get(MFA_CODE_TIME)
141
+ if not issued_at or (
142
+ (issued_at + datetime.timedelta(minutes=self._code_timeout_minutes())) < session.now()
143
+ ):
144
+ return True
145
+
146
+ # Check number of attempt.
147
+ attempts = int(session.get(MFA_CODE_ATTEMPT, 0))
148
+ if attempts >= self._max_attempts():
149
+ return True
150
+
151
+ return False
152
+
153
+ # ---- Tool entrypoint ----
154
+
155
+ def run(self, mfa_url: str = '/mfa/', mfa_enabled=True, **_):
156
+ """
157
+ Gate requests for users who have MFA enabled but are not yet verified.
158
+ mfa_enabled can be a bool or a callable returning bool.
159
+ """
160
+ enabled = mfa_enabled() if callable(mfa_enabled) else bool(mfa_enabled)
161
+
162
+ # Normalize request path
163
+ request = cherrypy.serving.request
164
+ req_path = request.path_info or '/'
165
+ mfa_path = mfa_url if mfa_url.startswith('/') else '/' + mfa_url
166
+
167
+ # If the current request is the MFA page itself:
168
+ if req_path.rstrip('/') == mfa_path.rstrip('/'):
169
+ # If disabled or already verified, send user back to the original URL (or home)
170
+ if not enabled or self._is_verified():
171
+ raise cherrypy.tools.auth.redirect_to_original_url()
172
+ return # Allow the MFA page handler to render
173
+
174
+ # If MFA is globally disabled for this user/realm, allow through
175
+ if not enabled:
176
+ return
177
+
178
+ # Final gate: redirect to MFA if not verified
179
+ if not self._is_verified():
180
+ cherrypy.tools.auth.save_original_url()
181
+ # Use a relative, safe redirect
182
+ raise cherrypy.HTTPRedirect(mfa_path)
183
+
184
+ # ---- Verification API for the MFA page handler ----
185
+
186
+ def verify_code(self, code: str, persistent: bool = False) -> bool:
187
+ """
188
+ Verify the supplied one-time code. On success:
189
+ - mark session persistent if requested
190
+ - stamp verification time
191
+ - add client IP to trusted list (dedup, cap)
192
+ - clear the code, rotate session id
193
+ """
194
+ if not hasattr(cherrypy.serving, 'session'):
195
+ return False
196
+
197
+ with session_lock() as session:
198
+ stored_hash = session.get(MFA_CODE)
199
+ is_expired = self.is_code_expired()
200
+ if self.is_code_expired():
201
+ return False
202
+
203
+ # Always perform the hash check regardless of expiration
204
+ # to prevent timing attacks
205
+ code_valid = False
206
+ if stored_hash:
207
+ code_valid = check_password(code, stored_hash)
208
+
209
+ # Check all conditions after hash verification
210
+ if is_expired or not code_valid:
211
+ if not is_expired: # Only increment if not expired
212
+ session[MFA_CODE_ATTEMPT] = attempts = int(session.get(MFA_CODE_ATTEMPT, 0)) + 1
213
+ cherrypy.log(
214
+ f'verification failed user={cherrypy.request.login} ip={cherrypy.request.remote.ip} attempts={attempts}',
215
+ context='MFA',
216
+ severity=logging.WARNING,
217
+ )
218
+ return False
219
+
220
+ # Success: trust this device/IP for the configured duration
221
+ session[MFA_VERIFICATION_TIME] = session.now()
222
+ ip_list = list({*(session.get(MFA_TRUSTED_IP_LIST) or []), cherrypy.request.remote.ip})
223
+ # Cap the list to avoid unbounded growth (keep most recent N)
224
+ max_ips = self._max_trusted_ips()
225
+ if len(ip_list) > max_ips:
226
+ ip_list = ip_list[-max_ips:]
227
+ session[MFA_TRUSTED_IP_LIST] = ip_list
228
+
229
+ # Clear the one-time code
230
+ session[MFA_CODE] = None
231
+ session[MFA_CODE_TIME] = None
232
+ session[MFA_CODE_ATTEMPT] = 0
233
+
234
+ # Honor “remember this device” option by making the session persistent
235
+ if hasattr(cherrypy.tools, 'sessions_timeout'):
236
+ try:
237
+ cherrypy.tools.sessions_timeout.set_persistent(bool(persistent))
238
+ except Exception:
239
+ pass
240
+
241
+ # Rotate session id to prevent fixation
242
+ session.regenerate()
243
+ cherrypy.log(
244
+ f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
245
+ )
246
+ return True
247
+
248
+
249
+ cherrypy.tools.auth_mfa = CheckAuthMfa()