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.
- cherrypy_foundation/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# CherryPy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections import namedtuple
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import cherrypy
|
|
22
|
+
from cherrypy.lib.sessions import FileSession
|
|
23
|
+
from cherrypy.test import helper
|
|
24
|
+
|
|
25
|
+
from .. import auth # noqa
|
|
26
|
+
|
|
27
|
+
User = namedtuple('User', 'id,username')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def checkpassword(username, password):
|
|
31
|
+
return username == 'myuser' and password == 'changeme'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def user_lookup_func(login, user_info):
|
|
35
|
+
if login == 'myuser':
|
|
36
|
+
return login, User(2, login)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def user_from_key_func(userkey):
|
|
41
|
+
if userkey == 'myuser':
|
|
42
|
+
return User(2, 'myuser')
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@cherrypy.tools.sessions(locking='explicit', storage_class=FileSession)
|
|
47
|
+
@cherrypy.tools.auth(
|
|
48
|
+
user_lookup_func=user_lookup_func,
|
|
49
|
+
user_from_key_func=user_from_key_func,
|
|
50
|
+
checkpassword=checkpassword,
|
|
51
|
+
)
|
|
52
|
+
class Root:
|
|
53
|
+
|
|
54
|
+
@cherrypy.expose
|
|
55
|
+
def index(self):
|
|
56
|
+
return "OK"
|
|
57
|
+
|
|
58
|
+
@cherrypy.expose()
|
|
59
|
+
def login(self, username=None, password=None):
|
|
60
|
+
if cherrypy.serving.request.method == 'POST' and username and password:
|
|
61
|
+
userobj = cherrypy.tools.auth.login_with_credentials(username, password)
|
|
62
|
+
if userobj:
|
|
63
|
+
raise cherrypy.tools.auth.redirect_to_original_url()
|
|
64
|
+
else:
|
|
65
|
+
return "invalid credentials"
|
|
66
|
+
return "login"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthManagerTest(helper.CPWebCase):
|
|
70
|
+
interactive = False
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def setup_class(cls):
|
|
74
|
+
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-session')
|
|
75
|
+
cls.session_dir = cls.tempdir.name
|
|
76
|
+
super().setup_class()
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def teardown_class(cls):
|
|
80
|
+
cls.tempdir.cleanup()
|
|
81
|
+
super().teardown_class()
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def setup_server(cls):
|
|
85
|
+
cherrypy.config.update(
|
|
86
|
+
{
|
|
87
|
+
'tools.sessions.storage_path': cls.session_dir,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
cherrypy.tree.mount(Root(), '/')
|
|
91
|
+
|
|
92
|
+
def test_auth_redirect(self):
|
|
93
|
+
# Given unauthenticated user
|
|
94
|
+
# When requesting protected page
|
|
95
|
+
self.getPage('/')
|
|
96
|
+
# Then user is redirected to login page
|
|
97
|
+
self.assertStatus(303)
|
|
98
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
99
|
+
|
|
100
|
+
def test_auth_login(self):
|
|
101
|
+
# Given unauthenticated user
|
|
102
|
+
# When posting valid login
|
|
103
|
+
self.getPage('/login/', method='POST', body=urlencode({'username': 'myuser', 'password': 'changeme'}))
|
|
104
|
+
# Then user is redirect to index.
|
|
105
|
+
self.assertStatus(303)
|
|
106
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
107
|
+
# Then page is accessible
|
|
108
|
+
self.getPage('/', headers=self.cookies)
|
|
109
|
+
self.assertStatus(200)
|
|
110
|
+
self.assertInBody('OK')
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# CherryPy
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
from collections import namedtuple
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import cherrypy
|
|
22
|
+
from cherrypy.lib.sessions import RamSession
|
|
23
|
+
from cherrypy.test import helper
|
|
24
|
+
|
|
25
|
+
from cherrypy_foundation.sessions import session_lock
|
|
26
|
+
|
|
27
|
+
from ..auth import AUTH_LAST_PASSWORD_AT
|
|
28
|
+
from ..auth_mfa import (
|
|
29
|
+
MFA_CODE_TIME,
|
|
30
|
+
MFA_DEFAULT_CODE_TIMEOUT,
|
|
31
|
+
MFA_DEFAULT_TRUST_DURATION,
|
|
32
|
+
MFA_TRUSTED_IP_LIST,
|
|
33
|
+
MFA_USER_KEY,
|
|
34
|
+
MFA_VERIFICATION_TIME,
|
|
35
|
+
)
|
|
36
|
+
from ..sessions_timeout import SESSION_PERSISTENT
|
|
37
|
+
|
|
38
|
+
User = namedtuple('User', 'id,username,password,email,mfa', defaults=[False])
|
|
39
|
+
|
|
40
|
+
users = {
|
|
41
|
+
User(2, 'myuser', 'changeme', 'myuser@example.com', False),
|
|
42
|
+
User(3, 'mfauser', 'changeme', 'mfauser@example.com', True),
|
|
43
|
+
User(4, 'noemail', 'changeme', '', True),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def checkpassword(username, password):
|
|
48
|
+
for u in users:
|
|
49
|
+
if u.username == username and u.password == password:
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_lookup_func(login, user_info):
|
|
55
|
+
for u in users:
|
|
56
|
+
if u.username == login:
|
|
57
|
+
return u.id, u
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def user_from_key_func(userkey):
|
|
62
|
+
for u in users:
|
|
63
|
+
if u.id == userkey:
|
|
64
|
+
return u
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cherrypy.tools.sessions(locking='explicit')
|
|
69
|
+
@cherrypy.tools.auth(
|
|
70
|
+
user_lookup_func=user_lookup_func,
|
|
71
|
+
user_from_key_func=user_from_key_func,
|
|
72
|
+
checkpassword=checkpassword,
|
|
73
|
+
)
|
|
74
|
+
@cherrypy.tools.auth_mfa(
|
|
75
|
+
mfa_enabled=lambda: hasattr(cherrypy.serving.request, 'currentuser') and cherrypy.request.currentuser.mfa
|
|
76
|
+
)
|
|
77
|
+
class Root:
|
|
78
|
+
|
|
79
|
+
@cherrypy.expose
|
|
80
|
+
def index(self):
|
|
81
|
+
return "OK"
|
|
82
|
+
|
|
83
|
+
@cherrypy.expose()
|
|
84
|
+
def login(self, username=None, password=None):
|
|
85
|
+
if cherrypy.serving.request.method == 'POST' and username and password:
|
|
86
|
+
userobj = cherrypy.tools.auth.login_with_credentials(username, password)
|
|
87
|
+
if userobj:
|
|
88
|
+
raise cherrypy.tools.auth.redirect_to_original_url()
|
|
89
|
+
else:
|
|
90
|
+
return "invalid credentials"
|
|
91
|
+
return "login"
|
|
92
|
+
|
|
93
|
+
@cherrypy.expose()
|
|
94
|
+
def mfa(self, code=None, resend_code=None, persistent=False):
|
|
95
|
+
html = "mfa\n"
|
|
96
|
+
if cherrypy.serving.request.method == 'POST' and code:
|
|
97
|
+
if cherrypy.tools.auth_mfa.verify_code(code=code, persistent=persistent):
|
|
98
|
+
raise cherrypy.tools.auth.redirect_to_original_url()
|
|
99
|
+
else:
|
|
100
|
+
html += "<p>invalid verification code</p>\n"
|
|
101
|
+
# Send verification code if previous code expired.
|
|
102
|
+
# Or when requested by user.
|
|
103
|
+
if (resend_code and cherrypy.serving.request.method == 'POST') or cherrypy.tools.auth_mfa.is_code_expired():
|
|
104
|
+
code = cherrypy.tools.auth_mfa.generate_code()
|
|
105
|
+
# Here the code should be send by email, SMS or any other means.
|
|
106
|
+
# For our test we store it in the session.
|
|
107
|
+
with session_lock() as session:
|
|
108
|
+
session['code'] = code
|
|
109
|
+
html += "<p>new verification code sent to your email</p>\n"
|
|
110
|
+
return html
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AuthManagerMfaTest(helper.CPWebCase):
|
|
114
|
+
interactive = False
|
|
115
|
+
# Authenticated by default.
|
|
116
|
+
login = True
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def setup_server(cls):
|
|
120
|
+
cherrypy.tree.mount(Root(), '/')
|
|
121
|
+
|
|
122
|
+
def getPage(self, *args, **kwargs):
|
|
123
|
+
"""
|
|
124
|
+
This implementation keep track of session cookies.
|
|
125
|
+
"""
|
|
126
|
+
headers = kwargs.pop('headers', [])
|
|
127
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
128
|
+
headers.extend(self.cookies)
|
|
129
|
+
return helper.CPWebCase.getPage(self, *args, headers=headers, **kwargs)
|
|
130
|
+
|
|
131
|
+
def _login(self, username, password):
|
|
132
|
+
self.getPage('/login/', method='POST', body=urlencode({'username': username, 'password': password}))
|
|
133
|
+
self.assertStatus(303)
|
|
134
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def _session_id(self):
|
|
138
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
139
|
+
for unused, value in self.cookies:
|
|
140
|
+
for part in value.split(';'):
|
|
141
|
+
key, unused, value = part.partition('=')
|
|
142
|
+
if key == 'session_id':
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
def _get_code(self):
|
|
146
|
+
# Query MFA page to generate a code
|
|
147
|
+
self.getPage("/mfa/")
|
|
148
|
+
self.assertStatus(200)
|
|
149
|
+
self.assertInBody("new verification code sent to your email")
|
|
150
|
+
# Extract code from user session for testing only.
|
|
151
|
+
session = RamSession.cache[self._session_id][0]
|
|
152
|
+
return session['code']
|
|
153
|
+
|
|
154
|
+
def test_get_without_login(self):
|
|
155
|
+
# Given the user is not authenticated.
|
|
156
|
+
# When requesting /mfa/
|
|
157
|
+
self.getPage("/mfa/")
|
|
158
|
+
# Then user is redirected to /login/
|
|
159
|
+
self.assertStatus(303)
|
|
160
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
161
|
+
|
|
162
|
+
def test_get_with_mfa_disabled(self):
|
|
163
|
+
# Given an authenticated user with MFA Disable
|
|
164
|
+
self._login('myuser', 'changeme')
|
|
165
|
+
# When requesting /mfa/ page
|
|
166
|
+
self.getPage("/mfa/")
|
|
167
|
+
# Then user is redirected to root page
|
|
168
|
+
self.assertStatus(303)
|
|
169
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
170
|
+
# Then index is enabled.
|
|
171
|
+
self.getPage("/")
|
|
172
|
+
self.assertStatus(200)
|
|
173
|
+
self.assertInBody('OK')
|
|
174
|
+
|
|
175
|
+
def test_get_with_trusted(self):
|
|
176
|
+
# Given an authenticated user with MFA Disable
|
|
177
|
+
self._login('mfauser', 'changeme')
|
|
178
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
179
|
+
session = RamSession.cache[self._session_id][0]
|
|
180
|
+
session[MFA_USER_KEY] = 3
|
|
181
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now()
|
|
182
|
+
session[MFA_TRUSTED_IP_LIST] = ['127.0.0.1']
|
|
183
|
+
|
|
184
|
+
# When requesting /mfa/ page when we are already trusted
|
|
185
|
+
self.getPage("/mfa/")
|
|
186
|
+
# Then user is redirected to root page
|
|
187
|
+
self.assertStatus(303)
|
|
188
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
189
|
+
|
|
190
|
+
def test_get_with_trusted_expired(self):
|
|
191
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
192
|
+
self._login('mfauser', 'changeme')
|
|
193
|
+
session = RamSession.cache[self._session_id][0]
|
|
194
|
+
session[MFA_USER_KEY] = 3
|
|
195
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now() - datetime.timedelta(minutes=60)
|
|
196
|
+
|
|
197
|
+
# When requesting /mfa/ page
|
|
198
|
+
self.getPage("/mfa/")
|
|
199
|
+
self.assertStatus(200)
|
|
200
|
+
# Then a verification code is send to the user
|
|
201
|
+
self.assertInBody("new verification code sent to your email")
|
|
202
|
+
|
|
203
|
+
def test_get_with_trusted_different_ip(self):
|
|
204
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
205
|
+
self._login('mfauser', 'changeme')
|
|
206
|
+
session = RamSession.cache[self._session_id][0]
|
|
207
|
+
session[MFA_USER_KEY] = 3
|
|
208
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now()
|
|
209
|
+
|
|
210
|
+
# When requesting /mfa/ page from a different ip
|
|
211
|
+
self.getPage("/mfa/", headers=[('X-Forwarded-For', '10.255.14.23')])
|
|
212
|
+
self.assertStatus(200)
|
|
213
|
+
# Then a verification code is send to the user
|
|
214
|
+
self.assertInBody("new verification code sent to your email")
|
|
215
|
+
|
|
216
|
+
def test_get_without_verified(self):
|
|
217
|
+
# Given an authenticated user With MFA enabled
|
|
218
|
+
self._login('mfauser', 'changeme')
|
|
219
|
+
# When requesting /mfa/ page
|
|
220
|
+
self.getPage("/mfa/")
|
|
221
|
+
self.assertStatus(200)
|
|
222
|
+
# Then a verification code is send to the user
|
|
223
|
+
self.assertInBody("new verification code sent to your email")
|
|
224
|
+
|
|
225
|
+
def test_verify_code_valid(self):
|
|
226
|
+
prev_session_id = self._session_id
|
|
227
|
+
# Given an authenticated user With MFA enabled
|
|
228
|
+
self._login('mfauser', 'changeme')
|
|
229
|
+
code = self._get_code()
|
|
230
|
+
# When sending a valid verification code
|
|
231
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
232
|
+
# Then a new session_id is generated
|
|
233
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
234
|
+
# Then user is redirected to root page
|
|
235
|
+
self.assertStatus(303)
|
|
236
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
237
|
+
# Then user has access
|
|
238
|
+
self.getPage("/")
|
|
239
|
+
self.assertStatus(200)
|
|
240
|
+
|
|
241
|
+
def test_verify_code_invalid(self):
|
|
242
|
+
# Given an authenticated user With MFA enabled
|
|
243
|
+
# When sending an invalid verification code
|
|
244
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
245
|
+
# Then user is redirected to login page
|
|
246
|
+
self.assertStatus(303)
|
|
247
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
248
|
+
|
|
249
|
+
def test_verify_code_expired(self):
|
|
250
|
+
# Given an authenticated user With MFA enabled
|
|
251
|
+
self._login('mfauser', 'changeme')
|
|
252
|
+
code = self._get_code()
|
|
253
|
+
# When sending a valid verification code that expired
|
|
254
|
+
session = RamSession.cache[self._session_id][0]
|
|
255
|
+
|
|
256
|
+
session[MFA_CODE_TIME] = datetime.datetime.now() - datetime.timedelta(minutes=MFA_DEFAULT_CODE_TIMEOUT + 1)
|
|
257
|
+
|
|
258
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
259
|
+
# Then a new code get generated.
|
|
260
|
+
self.assertStatus(200)
|
|
261
|
+
self.assertInBody("invalid verification code")
|
|
262
|
+
|
|
263
|
+
def test_verify_code_invalid_after_3_tentative(self):
|
|
264
|
+
# Given an authenticated user With MFA
|
|
265
|
+
self._login('mfauser', 'changeme')
|
|
266
|
+
code = self._get_code()
|
|
267
|
+
# When user enter an invalid verification code 3 times
|
|
268
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
269
|
+
self.assertStatus(200)
|
|
270
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
271
|
+
self.assertStatus(200)
|
|
272
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
273
|
+
# Then an error get displayed to the user
|
|
274
|
+
self.assertStatus(200)
|
|
275
|
+
self.assertInBody("invalid verification code")
|
|
276
|
+
# Then a new code get send to the user.
|
|
277
|
+
self.assertInBody("new verification code sent to your email")
|
|
278
|
+
session = RamSession.cache[self._session_id][0]
|
|
279
|
+
new_code = session['code']
|
|
280
|
+
self.assertNotEqual(code, new_code)
|
|
281
|
+
|
|
282
|
+
def test_resend_code(self):
|
|
283
|
+
# Given an authenticated user With MFA enabled with an existing code
|
|
284
|
+
self._login('mfauser', 'changeme')
|
|
285
|
+
code = self._get_code()
|
|
286
|
+
# When user request a new code
|
|
287
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'resend_code': '1'}))
|
|
288
|
+
# Then A success message is displayedto the user.
|
|
289
|
+
self.assertInBody("new verification code sent to your email")
|
|
290
|
+
session = RamSession.cache[self._session_id][0]
|
|
291
|
+
new_code = session['code']
|
|
292
|
+
self.assertNotEqual(code, new_code)
|
|
293
|
+
|
|
294
|
+
def test_redirect_to_original_url(self):
|
|
295
|
+
# Given an authenticated user
|
|
296
|
+
self._login('mfauser', 'changeme')
|
|
297
|
+
# When querying a page that required mfa
|
|
298
|
+
self.getPage('/prefs/general')
|
|
299
|
+
# Then user is redirected to mfa page
|
|
300
|
+
self.assertStatus(303)
|
|
301
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/mfa/')
|
|
302
|
+
# When providing verification code
|
|
303
|
+
code = self._get_code()
|
|
304
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
305
|
+
# Then user is redirected to original url
|
|
306
|
+
self.assertStatus(303)
|
|
307
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/prefs/general')
|
|
308
|
+
|
|
309
|
+
def test_login_persistent_when_login_timeout(self):
|
|
310
|
+
prev_session_id = self._session_id
|
|
311
|
+
# Given a user authenticated with MFA with "persistent"
|
|
312
|
+
self._login('mfauser', 'changeme')
|
|
313
|
+
code = self._get_code()
|
|
314
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
315
|
+
self.assertStatus(303)
|
|
316
|
+
self.getPage("/")
|
|
317
|
+
self.assertStatus(200)
|
|
318
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
319
|
+
session = RamSession.cache[self._session_id][0]
|
|
320
|
+
self.assertTrue(session[SESSION_PERSISTENT])
|
|
321
|
+
# When the re-auth time expired (after 15 min)
|
|
322
|
+
session[AUTH_LAST_PASSWORD_AT] = datetime.datetime.now() - datetime.timedelta(minutes=60, seconds=1)
|
|
323
|
+
|
|
324
|
+
# Then next query redirect user to /login/ page (by mfa)
|
|
325
|
+
self.getPage("/")
|
|
326
|
+
self.assertStatus(303)
|
|
327
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
328
|
+
prev_session_id = self._session_id
|
|
329
|
+
# When user enter valid username password
|
|
330
|
+
self.getPage("/login/", method='POST', body=urlencode({'username': 'mfauser', 'password': 'changeme'}))
|
|
331
|
+
# Then user is redirected to original url without need to pass MFA again.
|
|
332
|
+
self.assertStatus(303)
|
|
333
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
334
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
335
|
+
self.getPage("/")
|
|
336
|
+
self.assertStatus(200)
|
|
337
|
+
self.assertInBody('OK')
|
|
338
|
+
|
|
339
|
+
def test_login_persistent_when_mfa_timeout(self):
|
|
340
|
+
prev_session_id = self._session_id
|
|
341
|
+
# Given a user authenticated with MFA with "persistent"
|
|
342
|
+
self._login('mfauser', 'changeme')
|
|
343
|
+
code = self._get_code()
|
|
344
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
345
|
+
self.assertStatus(303)
|
|
346
|
+
self.getPage("/")
|
|
347
|
+
self.assertStatus(200)
|
|
348
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
349
|
+
session = RamSession.cache[self._session_id][0]
|
|
350
|
+
|
|
351
|
+
self.assertTrue(session[SESSION_PERSISTENT])
|
|
352
|
+
# When the mfa verification timeout (after 15 min)
|
|
353
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now() - datetime.timedelta(
|
|
354
|
+
minutes=MFA_DEFAULT_TRUST_DURATION, seconds=1
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Then next query redirect user to mfa page
|
|
358
|
+
self.getPage("/prefs/general")
|
|
359
|
+
self.assertStatus(303)
|
|
360
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/mfa/')
|
|
361
|
+
# When user enter valid code
|
|
362
|
+
code = self._get_code()
|
|
363
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
364
|
+
# Then user is redirected to original page.
|
|
365
|
+
self.assertStatus(303)
|
|
366
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/prefs/general')
|
|
367
|
+
self.getPage("/")
|
|
368
|
+
self.assertStatus(200)
|
|
369
|
+
self.assertInBody('OK')
|