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.
- cherrypy_foundation/components/ColorModes.jinja +4 -5
- cherrypy_foundation/components/Datatable.jinja +2 -2
- cherrypy_foundation/components/Datatable.js +2 -2
- cherrypy_foundation/components/Field.jinja +2 -4
- cherrypy_foundation/components/Fields.jinja +2 -0
- cherrypy_foundation/components/Typeahead.css +1 -6
- cherrypy_foundation/components/Typeahead.jinja +2 -2
- cherrypy_foundation/components/__init__.py +2 -2
- cherrypy_foundation/components/tests/test_static.py +1 -1
- cherrypy_foundation/error_page.py +3 -3
- cherrypy_foundation/flash.py +15 -17
- cherrypy_foundation/form.py +2 -2
- cherrypy_foundation/logging.py +2 -2
- cherrypy_foundation/passwd.py +2 -2
- cherrypy_foundation/plugins/db.py +1 -1
- cherrypy_foundation/plugins/ldap.py +25 -27
- cherrypy_foundation/plugins/restapi.py +1 -1
- cherrypy_foundation/plugins/scheduler.py +3 -14
- cherrypy_foundation/plugins/smtp.py +2 -8
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_ldap.py +3 -76
- cherrypy_foundation/plugins/tests/test_scheduler.py +1 -1
- cherrypy_foundation/plugins/tests/test_smtp.py +1 -31
- cherrypy_foundation/tests/__init__.py +0 -72
- cherrypy_foundation/tests/templates/test_form.html +1 -7
- cherrypy_foundation/tests/test_error_page.py +1 -7
- cherrypy_foundation/tests/test_form.py +11 -40
- cherrypy_foundation/tests/test_passwd.py +2 -2
- cherrypy_foundation/tools/auth.py +27 -31
- cherrypy_foundation/tools/auth_mfa.py +83 -87
- cherrypy_foundation/tools/errors.py +27 -0
- cherrypy_foundation/tools/i18n.py +151 -235
- cherrypy_foundation/tools/jinja2.py +2 -15
- cherrypy_foundation/tools/ratelimit.py +18 -32
- cherrypy_foundation/tools/secure_headers.py +1 -1
- cherrypy_foundation/tools/sessions_timeout.py +21 -23
- cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/{de → en}/LC_MESSAGES/messages.po +2 -2
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +1 -2
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +2 -3
- cherrypy_foundation/tools/tests/test_auth.py +3 -20
- cherrypy_foundation/tools/tests/test_auth_mfa.py +4 -6
- cherrypy_foundation/tools/tests/test_i18n.py +6 -81
- cherrypy_foundation/tools/tests/test_jinja2.py +5 -35
- cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
- cherrypy_foundation/url.py +25 -25
- cherrypy_foundation/widgets.py +2 -2
- cherrypy_foundation-1.0.0a2.dist-info/METADATA +42 -0
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +53 -64
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +1 -1
- cherrypy_foundation/components/Flash.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.js +0 -26
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +0 -107
- cherrypy_foundation/sessions.py +0 -93
- cherrypy_foundation/tests/templates/test_flash.html +0 -9
- cherrypy_foundation/tests/templates/test_url.html +0 -15
- cherrypy_foundation/tests/test_flash.py +0 -61
- cherrypy_foundation/tests/test_logging.py +0 -78
- cherrypy_foundation/tests/test_sessions.py +0 -89
- cherrypy_foundation/tests/test_url.py +0 -161
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +0 -22
- cherrypy_foundation/tools/tests/test_secure_headers.py +0 -200
- cherrypy_foundation-1.0.0.dist-info/METADATA +0 -71
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
- {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-
|
|
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
|
-
|
|
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)
|
|
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
|
-
#
|
|
2
|
-
# Copyright (C)
|
|
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
|
|
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("/
|
|
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
|
|
126
|
-
self.assertInBody('<div attr="BAR"')
|
|
127
|
-
self.
|
|
128
|
-
'<input class="
|
|
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
|
-
#
|
|
2
|
-
# Copyright (C)
|
|
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)
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
session = cherrypy.serving.session
|
|
75
|
+
user_key = session.get(self._session_user_key())
|
|
76
|
+
if not user_key:
|
|
77
|
+
return
|
|
80
78
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
session = cherrypy.serving.session
|
|
100
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
101
|
+
return False
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
session = cherrypy.serving.session
|
|
129
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
130
|
+
return True
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
# Check if code is defined.
|
|
133
|
+
hash_ = session.get(MFA_CODE)
|
|
134
|
+
if not hash_:
|
|
135
|
+
return True
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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)
|