cherrypy-foundation 1.0.0a1__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/Field.jinja +14 -6
- cherrypy_foundation/components/__init__.py +1 -1
- cherrypy_foundation/error_page.py +18 -15
- cherrypy_foundation/plugins/db.py +34 -8
- cherrypy_foundation/plugins/ldap.py +28 -22
- cherrypy_foundation/plugins/scheduler.py +197 -84
- cherrypy_foundation/plugins/smtp.py +71 -45
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
- cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
- cherrypy_foundation/tests/templates/test_form.html +10 -0
- cherrypy_foundation/tests/test_form.py +119 -0
- cherrypy_foundation/tools/auth.py +28 -11
- cherrypy_foundation/tools/auth_mfa.py +10 -13
- cherrypy_foundation/tools/errors.py +1 -1
- cherrypy_foundation/tools/i18n.py +15 -6
- cherrypy_foundation/tools/jinja2.py +15 -12
- cherrypy_foundation/tools/ratelimit.py +5 -9
- cherrypy_foundation/tools/secure_headers.py +0 -4
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
- cherrypy_foundation/tools/tests/test_auth.py +1 -1
- cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/METADATA +1 -1
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +31 -23
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/top_level.txt +0 -0
|
@@ -100,6 +100,26 @@ def _formataddr(value):
|
|
|
100
100
|
return ', '.join(map(_formataddr, value))
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _send_message(server, encryption, username, password, msg):
|
|
104
|
+
"""
|
|
105
|
+
Send message using SMTP.
|
|
106
|
+
"""
|
|
107
|
+
host, unused, port = server.partition(':')
|
|
108
|
+
if encryption == 'ssl':
|
|
109
|
+
conn = smtplib.SMTP_SSL(host, port or 465)
|
|
110
|
+
else:
|
|
111
|
+
conn = smtplib.SMTP(host, port or 25)
|
|
112
|
+
try:
|
|
113
|
+
if encryption == 'starttls':
|
|
114
|
+
conn.starttls()
|
|
115
|
+
# Authenticate if required.
|
|
116
|
+
if username:
|
|
117
|
+
conn.login(username, password)
|
|
118
|
+
conn.send_message(msg)
|
|
119
|
+
finally:
|
|
120
|
+
conn.quit()
|
|
121
|
+
|
|
122
|
+
|
|
103
123
|
class SmtpPlugin(SimplePlugin):
|
|
104
124
|
|
|
105
125
|
server = None
|
|
@@ -108,6 +128,32 @@ class SmtpPlugin(SimplePlugin):
|
|
|
108
128
|
encryption = None
|
|
109
129
|
email_from = None
|
|
110
130
|
|
|
131
|
+
def _create_msg(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
|
|
132
|
+
assert subject
|
|
133
|
+
assert message
|
|
134
|
+
assert to or bcc
|
|
135
|
+
|
|
136
|
+
# Record the MIME types of both parts - text/plain and text/html.
|
|
137
|
+
msg = MIMEMultipart('alternative')
|
|
138
|
+
msg['Subject'] = str(subject)
|
|
139
|
+
msg['From'] = _formataddr(self.email_from)
|
|
140
|
+
if to:
|
|
141
|
+
msg['To'] = _formataddr(to)
|
|
142
|
+
if cc:
|
|
143
|
+
msg['Cc'] = _formataddr(cc)
|
|
144
|
+
if bcc:
|
|
145
|
+
msg['Bcc'] = _formataddr(bcc)
|
|
146
|
+
if reply_to:
|
|
147
|
+
msg['Reply-To'] = _formataddr(reply_to)
|
|
148
|
+
msg['Message-ID'] = email.utils.make_msgid()
|
|
149
|
+
if headers:
|
|
150
|
+
for key, value in headers.items():
|
|
151
|
+
msg[key] = value
|
|
152
|
+
text = _html2plaintext(message)
|
|
153
|
+
msg.attach(MIMEText(text, 'plain', 'utf8'))
|
|
154
|
+
msg.attach(MIMEText(message, 'html', 'utf8'))
|
|
155
|
+
return msg
|
|
156
|
+
|
|
111
157
|
def start(self):
|
|
112
158
|
self.bus.log('Start SMTP plugin')
|
|
113
159
|
self.bus.subscribe("send_mail", self.send_mail)
|
|
@@ -118,70 +164,50 @@ class SmtpPlugin(SimplePlugin):
|
|
|
118
164
|
self.bus.unsubscribe("send_mail", self.send_mail)
|
|
119
165
|
self.bus.unsubscribe("queue_mail", self.queue_mail)
|
|
120
166
|
|
|
167
|
+
def graceful(self):
|
|
168
|
+
"""Reload of subscribers."""
|
|
169
|
+
self.stop()
|
|
170
|
+
self.start()
|
|
171
|
+
|
|
121
172
|
def queue_mail(self, *args, **kwargs):
|
|
122
173
|
"""
|
|
123
174
|
Queue mail to be sent.
|
|
124
175
|
"""
|
|
125
176
|
# Skip sending email if smtp server is not configured.
|
|
126
177
|
if not self.server:
|
|
127
|
-
|
|
178
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
128
179
|
return
|
|
129
180
|
if not self.email_from:
|
|
130
|
-
|
|
181
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
131
182
|
return
|
|
132
|
-
self.
|
|
133
|
-
|
|
134
|
-
|
|
183
|
+
msg = self._create_msg(*args, **kwargs)
|
|
184
|
+
self.bus.publish(
|
|
185
|
+
'scheduler:add_job_now',
|
|
186
|
+
_send_message,
|
|
187
|
+
server=self.server,
|
|
188
|
+
encryption=self.encryption,
|
|
189
|
+
username=self.username,
|
|
190
|
+
password=self.password,
|
|
191
|
+
msg=msg,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def send_mail(self, *args, **kwargs):
|
|
135
195
|
"""
|
|
136
196
|
Reusable method to be called to send email to the user user.
|
|
137
197
|
`user` user object where to send the email.
|
|
138
198
|
"""
|
|
139
|
-
assert subject
|
|
140
|
-
assert message
|
|
141
|
-
assert to or bcc
|
|
142
|
-
|
|
143
199
|
# Skip sending email if smtp server is not configured.
|
|
144
200
|
if not self.server:
|
|
145
|
-
|
|
201
|
+
cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
|
|
146
202
|
return
|
|
147
203
|
if not self.email_from:
|
|
148
|
-
|
|
204
|
+
cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
|
|
149
205
|
return
|
|
150
206
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if to:
|
|
156
|
-
msg['To'] = _formataddr(to)
|
|
157
|
-
if cc:
|
|
158
|
-
msg['Cc'] = _formataddr(cc)
|
|
159
|
-
if bcc:
|
|
160
|
-
msg['Bcc'] = _formataddr(bcc)
|
|
161
|
-
if reply_to:
|
|
162
|
-
msg['Reply-To'] = _formataddr(reply_to)
|
|
163
|
-
msg['Message-ID'] = email.utils.make_msgid()
|
|
164
|
-
if headers:
|
|
165
|
-
for key, value in headers.items():
|
|
166
|
-
msg[key] = value
|
|
167
|
-
text = _html2plaintext(message)
|
|
168
|
-
msg.attach(MIMEText(text, 'plain', 'utf8'))
|
|
169
|
-
msg.attach(MIMEText(message, 'html', 'utf8'))
|
|
170
|
-
|
|
171
|
-
host, unused, port = self.server.partition(':')
|
|
172
|
-
if self.encryption == 'ssl':
|
|
173
|
-
conn = smtplib.SMTP_SSL(host, port or 465)
|
|
174
|
-
else:
|
|
175
|
-
conn = smtplib.SMTP(host, port or 25)
|
|
176
|
-
try:
|
|
177
|
-
if self.encryption == 'starttls':
|
|
178
|
-
conn.starttls()
|
|
179
|
-
# Authenticate if required.
|
|
180
|
-
if self.username:
|
|
181
|
-
conn.login(self.username, self.password)
|
|
182
|
-
conn.send_message(msg)
|
|
183
|
-
finally:
|
|
184
|
-
conn.quit()
|
|
207
|
+
msg = self._create_msg(*args, **kwargs)
|
|
208
|
+
_send_message(
|
|
209
|
+
server=self.server, encryption=self.encryption, username=self.username, password=self.password, msg=msg
|
|
210
|
+
)
|
|
185
211
|
|
|
186
212
|
|
|
187
213
|
# Register SMTP plugin
|
|
@@ -93,7 +93,7 @@ else:
|
|
|
93
93
|
# Given an empty database
|
|
94
94
|
self.assertEqual(0, User.query.count())
|
|
95
95
|
# When calling "/add/" to create a user
|
|
96
|
-
self.getPage('/add', method='POST', body=urlencode(
|
|
96
|
+
self.getPage('/add', method='POST', body=urlencode({'username': 'myuser'}))
|
|
97
97
|
self.assertStatus(200)
|
|
98
98
|
self.assertInBody('OK')
|
|
99
99
|
# Then user get added to database
|
|
@@ -103,7 +103,7 @@ else:
|
|
|
103
103
|
# Given a database with a users
|
|
104
104
|
User(username='user1').add().commit()
|
|
105
105
|
# When trying to add another user
|
|
106
|
-
self.getPage('/add', method='POST', body=urlencode(
|
|
106
|
+
self.getPage('/add', method='POST', body=urlencode({'username': 'user1'}))
|
|
107
107
|
# Then an error get raised
|
|
108
108
|
self.assertStatus(200)
|
|
109
109
|
self.assertInBody('user_username_unique_ix')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Scheduler plugins 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
|
|
@@ -14,79 +14,87 @@
|
|
|
14
14
|
# You should have received a copy of the GNU General Public License
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@author: Patrik Dufresne <patrik@ikus-soft.com>
|
|
21
|
-
"""
|
|
22
|
-
import importlib.util
|
|
23
|
-
import unittest
|
|
24
|
-
from time import sleep
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from threading import Event
|
|
25
19
|
|
|
26
20
|
import cherrypy
|
|
27
21
|
from cherrypy.test import helper
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
from .. import scheduler # noqa
|
|
24
|
+
|
|
25
|
+
done = Event()
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
|
|
28
|
+
def a_task(*args, **kwargs):
|
|
29
|
+
done.set()
|
|
33
30
|
|
|
34
31
|
|
|
35
|
-
@unittest.skipUnless(HAS_APSCHEDULER, "apscheduler not installed")
|
|
36
32
|
class SchedulerPluginTest(helper.CPWebCase):
|
|
37
33
|
def setUp(self) -> None:
|
|
38
|
-
|
|
34
|
+
done.clear()
|
|
35
|
+
cherrypy.scheduler.remove_all_jobs()
|
|
39
36
|
return super().setUp()
|
|
40
37
|
|
|
38
|
+
def tearDown(self):
|
|
39
|
+
return super().tearDown()
|
|
40
|
+
|
|
41
41
|
@classmethod
|
|
42
42
|
def setup_server(cls):
|
|
43
43
|
pass
|
|
44
44
|
|
|
45
|
-
def
|
|
46
|
-
# Given a
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
def test_add_job(self):
|
|
46
|
+
# Given a scheduled job
|
|
47
|
+
scheduled = cherrypy.engine.publish(
|
|
48
|
+
'scheduler:add_job',
|
|
49
|
+
a_task,
|
|
50
|
+
name='custom_name',
|
|
51
|
+
args=(1, 2, 3),
|
|
52
|
+
kwargs={'foo': 'bar'},
|
|
53
|
+
next_run_time=datetime.now(timezone.utc),
|
|
54
|
+
misfire_grace_time=None,
|
|
55
|
+
)
|
|
56
|
+
self.assertTrue(scheduled)
|
|
57
|
+
self.assertEqual('custom_name', scheduled[0].name)
|
|
58
|
+
# When waiting for all jobs
|
|
59
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
60
|
+
# Then the job is done
|
|
61
|
+
self.assertTrue(done.is_set())
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
def test_add_job_daily(self):
|
|
64
|
+
# Given a scheduler with a specific number of jobs
|
|
65
|
+
count = len(cherrypy.scheduler.get_jobs())
|
|
66
|
+
# When scheduling a daily job.
|
|
67
|
+
scheduled = cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
|
|
68
|
+
# Then the job is scheduled
|
|
54
69
|
self.assertTrue(scheduled)
|
|
55
|
-
self.assertEqual(
|
|
70
|
+
self.assertEqual('a_task', scheduled[0].name)
|
|
71
|
+
# Then the number of jobs increase.
|
|
72
|
+
self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
|
|
56
73
|
|
|
57
|
-
def
|
|
74
|
+
def test_add_job_now(self):
|
|
58
75
|
# Given a task
|
|
59
|
-
|
|
60
|
-
def a_task(*args, **kwargs):
|
|
61
|
-
self.called = True
|
|
62
|
-
|
|
63
76
|
# When scheduling that task
|
|
64
|
-
scheduled = cherrypy.engine.publish('
|
|
77
|
+
scheduled = cherrypy.engine.publish('scheduler:add_job_now', a_task, 1, 2, 3, foo=1, bar=2)
|
|
65
78
|
self.assertTrue(scheduled)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
sleep(1)
|
|
79
|
+
# When waiting for all tasks
|
|
80
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
69
81
|
# Then the task get called
|
|
70
|
-
self.assertTrue(
|
|
82
|
+
self.assertTrue(done.is_set())
|
|
71
83
|
|
|
72
|
-
def
|
|
84
|
+
def test_remove_job(self):
|
|
73
85
|
# Given a scheduler with a specific number of jobs
|
|
74
|
-
count = len(cherrypy.scheduler.
|
|
75
|
-
|
|
86
|
+
count = len(cherrypy.scheduler.get_jobs())
|
|
76
87
|
# Given a job schedule every seconds
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.assertEqual(count, len(cherrypy.scheduler.
|
|
84
|
-
|
|
85
|
-
def
|
|
88
|
+
cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
|
|
89
|
+
# Then number of job increase
|
|
90
|
+
self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
|
|
91
|
+
# When the job is unscheduled.
|
|
92
|
+
cherrypy.engine.publish('scheduler:remove_job', a_task)
|
|
93
|
+
# Then the number of job decrease.
|
|
94
|
+
self.assertEqual(count, len(cherrypy.scheduler.get_jobs()))
|
|
95
|
+
|
|
96
|
+
def test_remove_job_with_invalid_job(self):
|
|
86
97
|
# Given an unschedule job
|
|
87
|
-
def a_job(*args, **kwargs):
|
|
88
|
-
self.called = True
|
|
89
|
-
|
|
90
98
|
# When unscheduling an invalid job
|
|
91
|
-
cherrypy.engine.publish('
|
|
92
|
-
# Then
|
|
99
|
+
cherrypy.engine.publish('scheduler:remove_job', a_task)
|
|
100
|
+
# Then an error is not raised.
|
|
@@ -63,12 +63,15 @@ class SmtpPluginTest(helper.CPWebCase):
|
|
|
63
63
|
|
|
64
64
|
@skipUnless(hasattr(cherrypy, 'scheduler'), reason='Required scheduler')
|
|
65
65
|
def test_queue_mail(self):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
|
|
67
|
+
# Given a mail being queued.
|
|
68
|
+
cherrypy.engine.publish('queue_mail', to='target@test.com', subject='subjet', message='body')
|
|
69
|
+
# When waiting for all task to be processed
|
|
70
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
71
|
+
# Then smtplib is called to send the mail.
|
|
72
|
+
smtplib.SMTP.assert_called_once_with('__default__', 25)
|
|
73
|
+
smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
|
|
74
|
+
smtplib.SMTP.return_value.quit.assert_called_once_with()
|
|
72
75
|
|
|
73
76
|
def test_html2plaintext(self):
|
|
74
77
|
"""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# CherryPy Foundation
|
|
2
|
+
# Copyright (C) 2025 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 importlib
|
|
17
|
+
from unittest import skipUnless
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
from cherrypy.test import helper
|
|
21
|
+
from wtforms.fields import BooleanField, PasswordField, StringField, SubmitField
|
|
22
|
+
from wtforms.validators import InputRequired, Length
|
|
23
|
+
|
|
24
|
+
import cherrypy_foundation.tools.jinja2 # noqa
|
|
25
|
+
from cherrypy_foundation.form import CherryForm
|
|
26
|
+
from cherrypy_foundation.tools.i18n import gettext_lazy as _
|
|
27
|
+
|
|
28
|
+
HAS_JINJAX = importlib.util.find_spec("jinjax") is not None
|
|
29
|
+
|
|
30
|
+
env = cherrypy.tools.jinja2.create_env(
|
|
31
|
+
package_name=__package__,
|
|
32
|
+
globals={
|
|
33
|
+
'const1': 'STATIC VALUE',
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoginForm(CherryForm):
|
|
39
|
+
login = StringField(
|
|
40
|
+
_('User'),
|
|
41
|
+
validators=[
|
|
42
|
+
InputRequired(),
|
|
43
|
+
Length(max=256, message=_('User too long.')),
|
|
44
|
+
],
|
|
45
|
+
render_kw={
|
|
46
|
+
"placeholder": _('User'),
|
|
47
|
+
"autocorrect": "off",
|
|
48
|
+
"autocapitalize": "none",
|
|
49
|
+
"autocomplete": "off",
|
|
50
|
+
"autofocus": "autofocus",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
password = PasswordField(
|
|
54
|
+
_('Password'),
|
|
55
|
+
validators=[
|
|
56
|
+
InputRequired(),
|
|
57
|
+
Length(max=256, message=_('Password too long.')),
|
|
58
|
+
],
|
|
59
|
+
render_kw={"placeholder": _("Password")},
|
|
60
|
+
)
|
|
61
|
+
persistent = BooleanField(
|
|
62
|
+
_('Remember me'),
|
|
63
|
+
# All `label-*` are assigned to the label tag.
|
|
64
|
+
render_kw={'container_class': 'col-sm-6', 'label-attr': 'FOO'},
|
|
65
|
+
)
|
|
66
|
+
submit = SubmitField(
|
|
67
|
+
_('Login'),
|
|
68
|
+
# All `container-*` are assigned to the container tag.
|
|
69
|
+
render_kw={"class": "btn-primary float-end", 'container_class': 'col-sm-6', 'container-attr': 'BAR'},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cherrypy.tools.jinja2(env=env)
|
|
74
|
+
class Root:
|
|
75
|
+
|
|
76
|
+
@cherrypy.expose
|
|
77
|
+
@cherrypy.tools.jinja2(template='test_form.html')
|
|
78
|
+
def index(self):
|
|
79
|
+
form = LoginForm()
|
|
80
|
+
return {'form': form}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
84
|
+
class FormTest(helper.CPWebCase):
|
|
85
|
+
default_lang = None
|
|
86
|
+
interactive = False
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def setup_server(cls):
|
|
90
|
+
cherrypy.tree.mount(Root(), '/')
|
|
91
|
+
|
|
92
|
+
def test_get_form(self):
|
|
93
|
+
# Given a form
|
|
94
|
+
# When querying the page that include this form
|
|
95
|
+
self.getPage("/")
|
|
96
|
+
self.assertStatus(200)
|
|
97
|
+
# Then each field is render properly.
|
|
98
|
+
# 1. Check title
|
|
99
|
+
self.assertInBody('test-form')
|
|
100
|
+
# 2. Check user field
|
|
101
|
+
self.assertInBody('<label class="form-label" for="login">User</label>')
|
|
102
|
+
self.assertInBody(
|
|
103
|
+
'<input autocapitalize="none" autocomplete="off" autocorrect="off" autofocus="autofocus" class="form-control" id="login" maxlength="256" name="login" placeholder="User" required type="text" value="">'
|
|
104
|
+
)
|
|
105
|
+
# 3. Check password
|
|
106
|
+
self.assertInBody('<label class="form-label" for="password">Password</label>')
|
|
107
|
+
self.assertInBody(
|
|
108
|
+
'<input class="form-control" id="password" maxlength="256" name="password" placeholder="Password" required type="password" value="">'
|
|
109
|
+
)
|
|
110
|
+
# 4 Check remember me
|
|
111
|
+
self.assertInBody(
|
|
112
|
+
'<input class="form-check-input" container-class="col-sm-6" id="persistent" label-attr="FOO" name="persistent" type="checkbox" value="y">'
|
|
113
|
+
)
|
|
114
|
+
self.assertInBody('<label attr="FOO" class="form-check-label" for="persistent">Remember me</label>')
|
|
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">'
|
|
119
|
+
)
|
|
@@ -29,9 +29,6 @@ AUTH_DEFAULT_SESSION_KEY = "_auth_session_key"
|
|
|
29
29
|
AUTH_DEFAULT_REAUTH_TIMEOUT = 60 # minutes
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
logger = logging.getLogger(__name__)
|
|
33
|
-
|
|
34
|
-
|
|
35
32
|
class AuthManager(cherrypy.Tool):
|
|
36
33
|
"""
|
|
37
34
|
CherryPy tool handling authentication.
|
|
@@ -102,7 +99,12 @@ class AuthManager(cherrypy.Tool):
|
|
|
102
99
|
try:
|
|
103
100
|
currentuser = user_from_key_func(user_key)
|
|
104
101
|
except Exception:
|
|
105
|
-
|
|
102
|
+
cherrypy.log(
|
|
103
|
+
f'unexpected error searching for user_key={user_key}',
|
|
104
|
+
context='AUTH',
|
|
105
|
+
severity=logging.ERROR,
|
|
106
|
+
traceback=True,
|
|
107
|
+
)
|
|
106
108
|
currentuser = None
|
|
107
109
|
|
|
108
110
|
if currentuser:
|
|
@@ -122,7 +124,7 @@ class AuthManager(cherrypy.Tool):
|
|
|
122
124
|
Validate credentials with configured checkers; on success, call login_with_result.
|
|
123
125
|
"""
|
|
124
126
|
if not login or not password:
|
|
125
|
-
|
|
127
|
+
cherrypy.log('authentication failed reason=empty_credentials', context='AUTH', severity=logging.WARNING)
|
|
126
128
|
return None
|
|
127
129
|
# Validate credentials using checkpassword function(s).
|
|
128
130
|
conf = self._merged_args()
|
|
@@ -143,15 +145,23 @@ class AuthManager(cherrypy.Tool):
|
|
|
143
145
|
login, user_info = login, None
|
|
144
146
|
# If authentication is successful, initiate login process.
|
|
145
147
|
return self.login_with_result(login=login, user_info=user_info)
|
|
146
|
-
except Exception
|
|
147
|
-
|
|
148
|
-
'unexpected error
|
|
148
|
+
except Exception:
|
|
149
|
+
cherrypy.log(
|
|
150
|
+
f'unexpected error checking password login={login} checkpassword={func.__qualname__} - continue with next function',
|
|
151
|
+
context='AUTH',
|
|
152
|
+
severity=logging.ERROR,
|
|
153
|
+
traceback=True,
|
|
149
154
|
)
|
|
150
155
|
# If we reach here, authentication failed
|
|
151
156
|
if hasattr(cherrypy.serving, 'session'):
|
|
152
157
|
cherrypy.serving.session.regenerate() # Prevent session analysis
|
|
153
158
|
|
|
154
|
-
|
|
159
|
+
remote_ip = cherrypy.serving.request.remote.ip
|
|
160
|
+
cherrypy.log(
|
|
161
|
+
f'authentication failed login={login} ip={remote_ip} reason=wrong_credentials',
|
|
162
|
+
context='AUTH',
|
|
163
|
+
severity=logging.WARNING,
|
|
164
|
+
)
|
|
155
165
|
|
|
156
166
|
return None
|
|
157
167
|
|
|
@@ -166,11 +176,18 @@ class AuthManager(cherrypy.Tool):
|
|
|
166
176
|
try:
|
|
167
177
|
user_key, userobj = user_lookup_func(login=login, user_info=user_info or {})
|
|
168
178
|
except Exception:
|
|
169
|
-
|
|
179
|
+
cherrypy.log(
|
|
180
|
+
f"unexpected error searching user login={login} user_info={user_info}",
|
|
181
|
+
context='AUTH',
|
|
182
|
+
severity=logging.ERROR,
|
|
183
|
+
traceback=True,
|
|
184
|
+
)
|
|
170
185
|
return None
|
|
171
186
|
|
|
172
187
|
if not userobj:
|
|
173
|
-
|
|
188
|
+
cherrypy.log(
|
|
189
|
+
f"authentication failed login={login} reason=not_found", context='AUTH', severity=logging.WARNING
|
|
190
|
+
)
|
|
174
191
|
return None
|
|
175
192
|
|
|
176
193
|
# Notify plugins about user login
|
|
@@ -23,14 +23,12 @@ import cherrypy
|
|
|
23
23
|
|
|
24
24
|
from ..passwd import check_password, hash_password
|
|
25
25
|
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
26
|
MFA_CODE = '_auth_mfa_code'
|
|
29
27
|
MFA_CODE_TIME = '_auth_mfa_code_time'
|
|
30
28
|
MFA_CODE_ATTEMPT = '_auth_mfa_code_attempt'
|
|
31
29
|
MFA_REDIRECT_URL = '_auth_mfa_redirect_url'
|
|
32
30
|
MFA_TRUSTED_IP_LIST = '_auth_mfa_trusted_ip_list'
|
|
33
|
-
|
|
31
|
+
MFA_USER_KEY = '_auth_mfa_user_key'
|
|
34
32
|
MFA_VERIFICATION_TIME = '_auth_mfa_time'
|
|
35
33
|
|
|
36
34
|
MFA_DEFAULT_CODE_TIMEOUT = 10 # minutes
|
|
@@ -86,7 +84,7 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
86
84
|
length = self._code_length()
|
|
87
85
|
code = ''.join(secrets.choice(string.digits) for _ in range(length))
|
|
88
86
|
session = cherrypy.serving.session
|
|
89
|
-
session[
|
|
87
|
+
session[MFA_USER_KEY] = cherrypy.request.login
|
|
90
88
|
session[MFA_CODE] = hash_password(code)
|
|
91
89
|
session[MFA_CODE_TIME] = session.now()
|
|
92
90
|
session[MFA_CODE_ATTEMPT] = 0
|
|
@@ -99,7 +97,7 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
99
97
|
|
|
100
98
|
# Check if our username match current login user.
|
|
101
99
|
session = cherrypy.serving.session
|
|
102
|
-
if session.get(
|
|
100
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
103
101
|
return False
|
|
104
102
|
|
|
105
103
|
# Check if the current IP is trusted
|
|
@@ -128,7 +126,7 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
128
126
|
return True
|
|
129
127
|
|
|
130
128
|
session = cherrypy.serving.session
|
|
131
|
-
if session.get(
|
|
129
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
132
130
|
return True
|
|
133
131
|
|
|
134
132
|
# Check if code is defined.
|
|
@@ -208,11 +206,10 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
208
206
|
if is_expired or not code_valid:
|
|
209
207
|
if not is_expired: # Only increment if not expired
|
|
210
208
|
session[MFA_CODE_ATTEMPT] = attempts = int(session.get(MFA_CODE_ATTEMPT, 0)) + 1
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
attempts,
|
|
209
|
+
cherrypy.log(
|
|
210
|
+
f'verification failed user={cherrypy.request.login} ip={cherrypy.request.remote.ip} attempts={attempts}',
|
|
211
|
+
context='MFA',
|
|
212
|
+
severity=logging.WARNING,
|
|
216
213
|
)
|
|
217
214
|
return False
|
|
218
215
|
|
|
@@ -239,8 +236,8 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
239
236
|
|
|
240
237
|
# Rotate session id to prevent fixation
|
|
241
238
|
session.regenerate()
|
|
242
|
-
|
|
243
|
-
'
|
|
239
|
+
cherrypy.log(
|
|
240
|
+
f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
|
|
244
241
|
)
|
|
245
242
|
return True
|
|
246
243
|
|