cherrypy-foundation 1.0.0__py3-none-any.whl → 1.0.0a2__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 (68) hide show
  1. cherrypy_foundation/components/ColorModes.jinja +4 -5
  2. cherrypy_foundation/components/Datatable.jinja +2 -2
  3. cherrypy_foundation/components/Datatable.js +2 -2
  4. cherrypy_foundation/components/Field.jinja +2 -4
  5. cherrypy_foundation/components/Fields.jinja +2 -0
  6. cherrypy_foundation/components/Typeahead.css +1 -6
  7. cherrypy_foundation/components/Typeahead.jinja +2 -2
  8. cherrypy_foundation/components/__init__.py +2 -2
  9. cherrypy_foundation/components/tests/test_static.py +1 -1
  10. cherrypy_foundation/error_page.py +3 -3
  11. cherrypy_foundation/flash.py +15 -17
  12. cherrypy_foundation/form.py +2 -2
  13. cherrypy_foundation/logging.py +2 -2
  14. cherrypy_foundation/passwd.py +2 -2
  15. cherrypy_foundation/plugins/db.py +1 -1
  16. cherrypy_foundation/plugins/ldap.py +25 -27
  17. cherrypy_foundation/plugins/restapi.py +1 -1
  18. cherrypy_foundation/plugins/scheduler.py +3 -14
  19. cherrypy_foundation/plugins/smtp.py +2 -8
  20. cherrypy_foundation/plugins/tests/test_db.py +2 -2
  21. cherrypy_foundation/plugins/tests/test_ldap.py +3 -76
  22. cherrypy_foundation/plugins/tests/test_scheduler.py +1 -1
  23. cherrypy_foundation/plugins/tests/test_smtp.py +1 -31
  24. cherrypy_foundation/tests/__init__.py +0 -72
  25. cherrypy_foundation/tests/templates/test_form.html +1 -7
  26. cherrypy_foundation/tests/test_error_page.py +1 -7
  27. cherrypy_foundation/tests/test_form.py +11 -40
  28. cherrypy_foundation/tests/test_passwd.py +2 -2
  29. cherrypy_foundation/tools/auth.py +27 -31
  30. cherrypy_foundation/tools/auth_mfa.py +83 -87
  31. cherrypy_foundation/tools/errors.py +27 -0
  32. cherrypy_foundation/tools/i18n.py +151 -235
  33. cherrypy_foundation/tools/jinja2.py +2 -15
  34. cherrypy_foundation/tools/ratelimit.py +18 -32
  35. cherrypy_foundation/tools/secure_headers.py +1 -1
  36. cherrypy_foundation/tools/sessions_timeout.py +21 -23
  37. cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
  38. cherrypy_foundation/tools/tests/locales/{de → en}/LC_MESSAGES/messages.po +2 -2
  39. cherrypy_foundation/tools/tests/templates/test_jinja2.html +1 -2
  40. cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
  41. cherrypy_foundation/tools/tests/templates/test_jinjax.html +2 -3
  42. cherrypy_foundation/tools/tests/test_auth.py +3 -20
  43. cherrypy_foundation/tools/tests/test_auth_mfa.py +4 -6
  44. cherrypy_foundation/tools/tests/test_i18n.py +6 -81
  45. cherrypy_foundation/tools/tests/test_jinja2.py +5 -35
  46. cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
  47. cherrypy_foundation/url.py +25 -25
  48. cherrypy_foundation/widgets.py +2 -2
  49. cherrypy_foundation-1.0.0a2.dist-info/METADATA +42 -0
  50. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +53 -64
  51. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +1 -1
  52. cherrypy_foundation/components/Flash.jinja +0 -13
  53. cherrypy_foundation/components/LocaleSelection.jinja +0 -13
  54. cherrypy_foundation/components/LocaleSelection.js +0 -26
  55. cherrypy_foundation/plugins/tests/test_scheduler_db.py +0 -107
  56. cherrypy_foundation/sessions.py +0 -93
  57. cherrypy_foundation/tests/templates/test_flash.html +0 -9
  58. cherrypy_foundation/tests/templates/test_url.html +0 -15
  59. cherrypy_foundation/tests/test_flash.py +0 -61
  60. cherrypy_foundation/tests/test_logging.py +0 -78
  61. cherrypy_foundation/tests/test_sessions.py +0 -89
  62. cherrypy_foundation/tests/test_url.py +0 -161
  63. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  64. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +0 -22
  65. cherrypy_foundation/tools/tests/test_secure_headers.py +0 -200
  66. cherrypy_foundation-1.0.0.dist-info/METADATA +0 -71
  67. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
  68. {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  # SMTP Plugins for cherrypy
2
- # Copyright (C) 2022-2026 IKUS Software
2
+ # Copyright (C) 2022-2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -18,7 +18,6 @@ from unittest import mock, skipUnless
18
18
 
19
19
  import cherrypy
20
20
  from cherrypy.test import helper
21
- from parameterized import parameterized
22
21
 
23
22
  from .. import smtp # noqa
24
23
 
@@ -109,32 +108,3 @@ Here is the link [1] you wanted.
109
108
  self.assertEqual(
110
109
  'test2@test.com, TEST3 <test3@test.com>', smtp._formataddr(['test2@test.com', ('TEST3', 'test3@test.com')])
111
110
  )
112
-
113
- @parameterized.expand(
114
- [
115
- (None, None, None),
116
- (None, 'test2@test.com', 'test2@test.com'),
117
- ('test2@test.com', None, 'test2@test.com'),
118
- ('test1@test.com', 'test2@test.com', 'test1@test.com, test2@test.com'),
119
- ]
120
- )
121
- def test_with_bcc(self, smtp_bcc, bcc, expected_bcc):
122
- # Given a valid smtp server
123
- with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
124
- # Given bcc is defined at plugin level.
125
- cherrypy.smtp.bcc = smtp_bcc
126
- # When publishing a send_mail with Bcc
127
- cherrypy.engine.publish(
128
- 'send_mail',
129
- to=('A name', 'target@test.com'),
130
- subject='subjet',
131
- message='body',
132
- bcc=bcc,
133
- )
134
- # Then message is sent
135
- smtplib.SMTP.assert_called_once_with('__default__', 25)
136
- smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
137
- msg = smtplib.SMTP.return_value.send_message.call_args.args[0]
138
- smtplib.SMTP.return_value.quit.assert_called_once_with()
139
- # Message include our expected Bcc
140
- self.assertEqual(expected_bcc, msg['Bcc'])
@@ -1,72 +0,0 @@
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
- import unittest
20
- from contextlib import contextmanager
21
-
22
- from selenium import webdriver
23
-
24
-
25
- class SeleniumUnitTest:
26
-
27
- @property
28
- def _session_id(self):
29
- if hasattr(self, 'cookies') and self.cookies:
30
- for unused, value in self.cookies:
31
- for part in value.split(';'):
32
- key, unused, value = part.partition('=')
33
- if key == 'session_id':
34
- return value
35
-
36
- @contextmanager
37
- def selenium(self, headless=True, implicitly_wait=3):
38
- """
39
- Decorator to load selenium for a test.
40
- """
41
- # Skip selenium test is display is not available.
42
- if not os.environ.get('DISPLAY', False):
43
- raise unittest.SkipTest("selenium require a display")
44
- # Start selenium driver
45
- options = webdriver.ChromeOptions()
46
- if headless:
47
- options.add_argument('--headless')
48
- options.add_argument('--disable-gpu')
49
- options.add_argument('--window-size=1280,800')
50
- options.add_argument('--no-sandbox')
51
- options.add_argument('--disable-dev-shm-usage')
52
- options.add_argument('--lang=en-US')
53
- driver = webdriver.Chrome(options=options)
54
- try:
55
- # If logged in, reuse the same session id.
56
- if self._session_id:
57
- driver.get(f'{self.baseurl}/login/')
58
- driver.add_cookie({"name": "session_id", "value": self.session_id})
59
- # Configure download folder
60
- download = os.path.join(os.path.expanduser('~'), 'Downloads')
61
- os.makedirs(download, exist_ok=True)
62
- self._selenium_download_dir = tempfile.mkdtemp(dir=download, prefix='selenium-download-')
63
- driver.execute_cdp_cmd(
64
- 'Page.setDownloadBehavior', {'behavior': 'allow', 'downloadPath': self._selenium_download_dir}
65
- )
66
- # Set default wait.
67
- driver.implicitly_wait(implicitly_wait)
68
- yield driver
69
- finally:
70
- # Code to release resource, e.g.:
71
- driver.close()
72
- driver = None
@@ -1,14 +1,8 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
1
+ <html>
3
2
  <head>
4
3
  <title>test-form</title>
5
4
  </head>
6
5
  <body>
7
- {% if form.error_message %}
8
- <p>
9
- {{ form.error_message }}
10
- </p>
11
- {% endif %}
12
6
  <form method="post" action="/">
13
7
  <Fields form={{ form }} />
14
8
  </form>
@@ -1,5 +1,5 @@
1
1
  # CherryPy
2
- # Copyright (C) 2026 IKUS Software
2
+ # Copyright (C) 2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -29,10 +29,6 @@ class Root:
29
29
 
30
30
  @cherrypy.expose
31
31
  def not_found(self):
32
- raise cherrypy.NotFound()
33
-
34
- @cherrypy.expose
35
- def not_found_custom(self):
36
32
  raise cherrypy.HTTPError(404, message='My error message')
37
33
 
38
34
  @cherrypy.expose
@@ -64,8 +60,6 @@ class ErrorPageTest(helper.CPWebCase):
64
60
 
65
61
  @parameterized.expand(
66
62
  [
67
- ('/not_found', '<p>Nothing matches the given URI</p>'),
68
- ('/not_found_custom', '<p>My error message</p>'),
69
63
  ('/html_error', '<p>My error message</p>'),
70
64
  ('/json_error', '{"message": "json error message", "status": "400 Bad Request"}'),
71
65
  ('/text_error', 'text error message'),
@@ -1,5 +1,5 @@
1
- # Cherrypy-foundation
2
- # Copyright (C) 2026 IKUS Software
1
+ # CherryPy Foundation
2
+ # Copyright (C) 2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -15,11 +15,9 @@
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
  import importlib
17
17
  from unittest import skipUnless
18
- from urllib.parse import urlencode
19
18
 
20
19
  import cherrypy
21
20
  from cherrypy.test import helper
22
- from parameterized import parameterized
23
21
  from wtforms.fields import BooleanField, PasswordField, StringField, SubmitField
24
22
  from wtforms.validators import InputRequired, Length
25
23
 
@@ -31,6 +29,9 @@ HAS_JINJAX = importlib.util.find_spec("jinjax") is not None
31
29
 
32
30
  env = cherrypy.tools.jinja2.create_env(
33
31
  package_name=__package__,
32
+ globals={
33
+ 'const1': 'STATIC VALUE',
34
+ },
34
35
  )
35
36
 
36
37
 
@@ -69,24 +70,13 @@ class LoginForm(CherryForm):
69
70
  )
70
71
 
71
72
 
72
- @cherrypy.tools.sessions()
73
73
  @cherrypy.tools.jinja2(env=env)
74
74
  class Root:
75
75
 
76
- @cherrypy.expose
77
- def index(self, **kwargs):
78
- if 'login' not in cherrypy.session:
79
- raise cherrypy.HTTPRedirect('/login')
80
- return 'OK'
81
-
82
76
  @cherrypy.expose
83
77
  @cherrypy.tools.jinja2(template='test_form.html')
84
- def login(self, **kwargs):
78
+ def index(self):
85
79
  form = LoginForm()
86
- if form.validate_on_submit():
87
- # login user with cherrypy.tools.auth
88
- cherrypy.session['login'] = True
89
- raise cherrypy.HTTPRedirect('/')
90
80
  return {'form': form}
91
81
 
92
82
 
@@ -102,7 +92,7 @@ class FormTest(helper.CPWebCase):
102
92
  def test_get_form(self):
103
93
  # Given a form
104
94
  # When querying the page that include this form
105
- self.getPage("/login")
95
+ self.getPage("/")
106
96
  self.assertStatus(200)
107
97
  # Then each field is render properly.
108
98
  # 1. Check title
@@ -122,27 +112,8 @@ class FormTest(helper.CPWebCase):
122
112
  '<input class="form-check-input" container-class="col-sm-6" id="persistent" label-attr="FOO" name="persistent" type="checkbox" value="y">'
123
113
  )
124
114
  self.assertInBody('<label attr="FOO" class="form-check-label" for="persistent">Remember me</label>')
125
- # 5. check submit button (regex matches because class could have different order with jinjax<=0.57)
126
- self.assertInBody('<div attr="BAR"')
127
- self.assertMatchesBody(
128
- '<input class="(btn-primary ?|float-end ?|btn ?){3}" container-attr="BAR" container-class="col-sm-6" id="submit" name="submit" type="submit" value="Login">'
129
- )
130
-
131
- @parameterized.expand(
132
- [
133
- ('myuser', 'mypassword', 0, 303, False),
134
- ('myuser', '', 0, 200, 'Password: This field is required.'),
135
- ('', 'mypassword', 0, 200, 'User: This field is required.'),
136
- ]
137
- )
138
- def test_post_form(self, login, password, persistent, expect_status, expect_error):
139
- # Given a page with a form.
140
- # When data is sent to the form.
141
- self.getPage(
142
- "/login", method='POST', body=urlencode({'login': login, 'password': password, 'persistent': persistent})
115
+ # 5. check submit button
116
+ self.assertInBody('<div attr="BAR" class="mb-2 form-field col-sm-6">')
117
+ self.assertInBody(
118
+ '<input class="btn-primary float-end btn" container-attr="BAR" container-class="col-sm-6" id="submit" name="submit" type="submit" value="Login">'
143
119
  )
144
- # Then page return a status
145
- self.assertStatus(expect_status)
146
- # Then page may return an error
147
- if expect_error:
148
- self.assertInBody(expect_error)
@@ -1,5 +1,5 @@
1
- # Cherrypy-foundation
2
- # Copyright (C) 2026 IKUS Software
1
+ # CherryPy Foundation
2
+ # Copyright (C) 2025 IKUS Software inc.
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -1,5 +1,5 @@
1
1
  # Authentication tools for cherrypy
2
- # Copyright (C) 2026 IKUS Software
2
+ # Copyright (C) 2025 IKUS Software
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -20,8 +20,6 @@ import urllib.parse
20
20
 
21
21
  import cherrypy
22
22
 
23
- from cherrypy_foundation.sessions import session_lock
24
-
25
23
  AUTH_LAST_PASSWORD_AT = '_auth_last_password_at'
26
24
  AUTH_METHOD = '_auth_method'
27
25
  AUTH_ORIGINAL_URL = '_auth_original_url'
@@ -73,17 +71,17 @@ class AuthManager(cherrypy.Tool):
73
71
  if not hasattr(cherrypy.serving, 'session'):
74
72
  return
75
73
  # 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
74
+ session = cherrypy.serving.session
75
+ user_key = session.get(self._session_user_key())
76
+ if not user_key:
77
+ return
80
78
 
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
79
+ last = session.get(AUTH_LAST_PASSWORD_AT)
80
+ if last is None:
81
+ return # never had a password login in this session
82
+ timeout = self._reauth_timeout_minutes()
83
+ if (last + datetime.timedelta(minutes=timeout)) < session.now():
84
+ return
87
85
 
88
86
  # Mark request as authenticated by key; user object will be resolved later.
89
87
  cherrypy.serving.request.login = user_key
@@ -156,8 +154,7 @@ class AuthManager(cherrypy.Tool):
156
154
  )
157
155
  # If we reach here, authentication failed
158
156
  if hasattr(cherrypy.serving, 'session'):
159
- with session_lock() as session:
160
- session.regenerate() # Prevent session analysis
157
+ cherrypy.serving.session.regenerate() # Prevent session analysis
161
158
 
162
159
  remote_ip = cherrypy.serving.request.remote.ip
163
160
  cherrypy.log(
@@ -198,13 +195,13 @@ class AuthManager(cherrypy.Tool):
198
195
 
199
196
  # Store in session
200
197
  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()
198
+ session = cherrypy.serving.session
199
+ session_user_key = self._session_user_key()
200
+ session[session_user_key] = user_key
201
+ session[AUTH_METHOD] = auth_method
202
+ session[AUTH_LAST_PASSWORD_AT] = session.now()
203
+ # Generate a new session id
204
+ session.regenerate()
208
205
 
209
206
  # When authenticated, store user_key in request.
210
207
  cherrypy.serving.request.login = user_key
@@ -216,9 +213,9 @@ class AuthManager(cherrypy.Tool):
216
213
  """
217
214
  Clear session data and generate a new session id.
218
215
  """
219
- with session_lock() as session:
220
- session.clear()
221
- session.regenerate()
216
+ session = cherrypy.serving.session
217
+ session.clear()
218
+ session.regenerate()
222
219
 
223
220
  def get_original_url(self):
224
221
  """
@@ -226,16 +223,15 @@ class AuthManager(cherrypy.Tool):
226
223
  """
227
224
  if not hasattr(cherrypy.serving, 'session'):
228
225
  return None
229
- with session_lock() as session:
230
- return session.get(AUTH_ORIGINAL_URL)
226
+ return cherrypy.serving.session.get(AUTH_ORIGINAL_URL)
231
227
 
232
228
  def get_user_key(self):
233
229
  """Return the last username."""
234
230
  if not hasattr(cherrypy.serving, 'session'):
235
231
  return False
232
+ session = cherrypy.serving.session
236
233
  session_user_key = self._session_user_key()
237
- with session_lock() as session:
238
- return session.get(session_user_key)
234
+ return session.get(session_user_key)
239
235
 
240
236
  def redirect_to_original_url(self):
241
237
  # Redirect user to original URL
@@ -256,8 +252,8 @@ class AuthManager(cherrypy.Tool):
256
252
  query_string = request.query_string
257
253
 
258
254
  # Store value in session
259
- with session_lock() as session:
260
- session[AUTH_ORIGINAL_URL] = cherrypy.url(original_url, qs=query_string, base='')
255
+ session = cherrypy.serving.session
256
+ session[AUTH_ORIGINAL_URL] = cherrypy.url(original_url, qs=query_string, base='')
261
257
 
262
258
 
263
259
  cherrypy.tools.auth = AuthManager()
@@ -1,5 +1,5 @@
1
1
  # MFA tool for cherrypy
2
- # Copyright (C) 2022-2026 IKUS Software
2
+ # Copyright (C) 2022-2025 Patrik Dufresne
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
5
5
  # it under the terms of the GNU General Public License as published by
@@ -21,8 +21,6 @@ from typing import Optional
21
21
 
22
22
  import cherrypy
23
23
 
24
- from cherrypy_foundation.sessions import session_lock
25
-
26
24
  from ..passwd import check_password, hash_password
27
25
 
28
26
  MFA_CODE = '_auth_mfa_code'
@@ -85,11 +83,11 @@ class CheckAuthMfa(cherrypy.Tool):
85
83
 
86
84
  length = self._code_length()
87
85
  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
86
+ session = cherrypy.serving.session
87
+ session[MFA_USER_KEY] = cherrypy.request.login
88
+ session[MFA_CODE] = hash_password(code)
89
+ session[MFA_CODE_TIME] = session.now()
90
+ session[MFA_CODE_ATTEMPT] = 0
93
91
  return code
94
92
 
95
93
  def _is_verified(self) -> bool:
@@ -98,24 +96,24 @@ class CheckAuthMfa(cherrypy.Tool):
98
96
  return False
99
97
 
100
98
  # 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
99
+ session = cherrypy.serving.session
100
+ if session.get(MFA_USER_KEY) != cherrypy.request.login:
101
+ return False
104
102
 
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
103
+ # Check if the current IP is trusted
104
+ ip_list = session.get(MFA_TRUSTED_IP_LIST) or []
105
+ if cherrypy.request.remote.ip not in ip_list:
106
+ return False
109
107
 
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
108
+ # Check if user ever pass the verification.
109
+ verified_at: Optional[datetime.datetime] = session.get(MFA_VERIFICATION_TIME)
110
+ if not verified_at:
111
+ return False
114
112
 
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
113
+ # Check trusted duration time
114
+ trust_minutes = self._trust_duration_minutes()
115
+ if (verified_at + datetime.timedelta(minutes=trust_minutes)) <= session.now():
116
+ return False
119
117
 
120
118
  return True
121
119
 
@@ -127,26 +125,24 @@ class CheckAuthMfa(cherrypy.Tool):
127
125
  if not hasattr(cherrypy.serving, 'session'):
128
126
  return True
129
127
 
130
- with session_lock() as session:
131
- if session.get(MFA_USER_KEY) != cherrypy.request.login:
132
- return True
128
+ session = cherrypy.serving.session
129
+ if session.get(MFA_USER_KEY) != cherrypy.request.login:
130
+ return True
133
131
 
134
- # Check if code is defined.
135
- hash_ = session.get(MFA_CODE)
136
- if not hash_:
137
- return True
132
+ # Check if code is defined.
133
+ hash_ = session.get(MFA_CODE)
134
+ if not hash_:
135
+ return True
138
136
 
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
137
+ # Check issued time.
138
+ issued_at: Optional[datetime.datetime] = session.get(MFA_CODE_TIME)
139
+ if not issued_at or ((issued_at + datetime.timedelta(minutes=self._code_timeout_minutes())) < session.now()):
140
+ return True
145
141
 
146
- # Check number of attempt.
147
- attempts = int(session.get(MFA_CODE_ATTEMPT, 0))
148
- if attempts >= self._max_attempts():
149
- return True
142
+ # Check number of attempt.
143
+ attempts = int(session.get(MFA_CODE_ATTEMPT, 0))
144
+ if attempts >= self._max_attempts():
145
+ return True
150
146
 
151
147
  return False
152
148
 
@@ -194,55 +190,55 @@ class CheckAuthMfa(cherrypy.Tool):
194
190
  if not hasattr(cherrypy.serving, 'session'):
195
191
  return False
196
192
 
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()
193
+ session = cherrypy.serving.session
194
+ stored_hash = session.get(MFA_CODE)
195
+ is_expired = self.is_code_expired()
196
+ if self.is_code_expired():
197
+ return False
198
+
199
+ # Always perform the hash check regardless of expiration
200
+ # to prevent timing attacks
201
+ code_valid = False
202
+ if stored_hash:
203
+ code_valid = check_password(code, stored_hash)
204
+
205
+ # Check all conditions after hash verification
206
+ if is_expired or not code_valid:
207
+ if not is_expired: # Only increment if not expired
208
+ session[MFA_CODE_ATTEMPT] = attempts = int(session.get(MFA_CODE_ATTEMPT, 0)) + 1
243
209
  cherrypy.log(
244
- f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
210
+ f'verification failed user={cherrypy.request.login} ip={cherrypy.request.remote.ip} attempts={attempts}',
211
+ context='MFA',
212
+ severity=logging.WARNING,
245
213
  )
214
+ return False
215
+
216
+ # Success: trust this device/IP for the configured duration
217
+ session[MFA_VERIFICATION_TIME] = session.now()
218
+ ip_list = list({*(session.get(MFA_TRUSTED_IP_LIST) or []), cherrypy.request.remote.ip})
219
+ # Cap the list to avoid unbounded growth (keep most recent N)
220
+ max_ips = self._max_trusted_ips()
221
+ if len(ip_list) > max_ips:
222
+ ip_list = ip_list[-max_ips:]
223
+ session[MFA_TRUSTED_IP_LIST] = ip_list
224
+
225
+ # Clear the one-time code
226
+ session[MFA_CODE] = None
227
+ session[MFA_CODE_TIME] = None
228
+ session[MFA_CODE_ATTEMPT] = 0
229
+
230
+ # Honor “remember this device” option by making the session persistent
231
+ if hasattr(cherrypy.tools, 'sessions_timeout'):
232
+ try:
233
+ cherrypy.tools.sessions_timeout.set_persistent(bool(persistent))
234
+ except Exception:
235
+ pass
236
+
237
+ # Rotate session id to prevent fixation
238
+ session.regenerate()
239
+ cherrypy.log(
240
+ f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
241
+ )
246
242
  return True
247
243
 
248
244
 
@@ -0,0 +1,27 @@
1
+ # Cherrypy Foundation
2
+ # Copyright (C) 2025 IKUS Software inc.
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 sys
17
+
18
+ import cherrypy
19
+
20
+
21
+ def handle_exception(error_table):
22
+ t = sys.exc_info()[0]
23
+ code = error_table.get(t, 500)
24
+ cherrypy.serving.request.error_response = cherrypy.HTTPError(code).set_response
25
+
26
+
27
+ cherrypy.tools.errors = cherrypy.Tool('before_error_response', handle_exception, priority=90)