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
|
@@ -105,6 +105,15 @@ from babel.support import LazyProxy, NullTranslations, Translations
|
|
|
105
105
|
_current = threading.local()
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _get_config(key, default=None):
|
|
109
|
+
"""
|
|
110
|
+
Lookup configuration from request, if available. Fallback to global config.
|
|
111
|
+
"""
|
|
112
|
+
if getattr(cherrypy, 'request') and getattr(cherrypy.request, 'config') and key in cherrypy.request.config:
|
|
113
|
+
return cherrypy.request.config[key]
|
|
114
|
+
return cherrypy.config.get(key, default)
|
|
115
|
+
|
|
116
|
+
|
|
108
117
|
@contextmanager
|
|
109
118
|
def preferred_lang(lang):
|
|
110
119
|
"""
|
|
@@ -199,7 +208,7 @@ def get_timezone():
|
|
|
199
208
|
return tzinfo
|
|
200
209
|
# Otherwise search for a valid timezone.
|
|
201
210
|
tzinfo = None
|
|
202
|
-
default_timezone =
|
|
211
|
+
default_timezone = _get_config('tools.i18n.default_timezone')
|
|
203
212
|
preferred_timezone = getattr(_current, 'preferred_timezone', [default_timezone])
|
|
204
213
|
for timezone in preferred_timezone:
|
|
205
214
|
try:
|
|
@@ -226,10 +235,10 @@ def get_translation():
|
|
|
226
235
|
# Otherwise, we need to search the translation.
|
|
227
236
|
# `preferred_lang` should always has a sane value within a cherrypy request because of hooks
|
|
228
237
|
# But we also need to support calls outside cherrypy.
|
|
229
|
-
default =
|
|
238
|
+
default = _get_config('tools.i18n.default')
|
|
230
239
|
preferred_lang = getattr(_current, 'preferred_lang', [default])
|
|
231
|
-
mo_dir =
|
|
232
|
-
domain =
|
|
240
|
+
mo_dir = _get_config('tools.i18n.mo_dir')
|
|
241
|
+
domain = _get_config('tools.i18n.domain')
|
|
233
242
|
trans = _search_translation(mo_dir, domain, *preferred_lang)
|
|
234
243
|
if trans is None:
|
|
235
244
|
trans = NullTranslations()
|
|
@@ -242,8 +251,8 @@ def list_available_locales():
|
|
|
242
251
|
"""
|
|
243
252
|
Return a list of available translations.
|
|
244
253
|
"""
|
|
245
|
-
mo_dir =
|
|
246
|
-
domain =
|
|
254
|
+
mo_dir = _get_config('tools.i18n.mo_dir', False)
|
|
255
|
+
domain = _get_config('tools.i18n.domain')
|
|
247
256
|
if not mo_dir:
|
|
248
257
|
return
|
|
249
258
|
for lang in os.listdir(mo_dir):
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
17
|
import importlib
|
|
18
|
+
import logging
|
|
18
19
|
import time
|
|
19
20
|
|
|
20
21
|
import cherrypy
|
|
@@ -51,15 +52,15 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
51
52
|
|
|
52
53
|
def wrap(*args, **kwargs):
|
|
53
54
|
# Call original handler
|
|
54
|
-
|
|
55
|
+
context = self.oldhandler(*args, **kwargs)
|
|
55
56
|
# Render template.
|
|
56
|
-
return self.render_request(env=env, template=template,
|
|
57
|
+
return self.render_request(env=env, template=template, context=context, extra_processor=extra_processor)
|
|
57
58
|
|
|
58
59
|
request = cherrypy.serving.request
|
|
59
60
|
if request.handler is not None:
|
|
60
61
|
# Replace request.handler with self
|
|
61
62
|
if debug:
|
|
62
|
-
cherrypy.log('
|
|
63
|
+
cherrypy.log('replacing request handler', context='TOOLS.JINJA2', severity=logging.DEBUG)
|
|
63
64
|
self.oldhandler = request.handler
|
|
64
65
|
request.handler = wrap
|
|
65
66
|
|
|
@@ -79,12 +80,14 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
79
80
|
|
|
80
81
|
# Enable translation if available
|
|
81
82
|
if hasattr(cherrypy.tools, 'i18n'):
|
|
82
|
-
from .i18n import format_datetime, get_language_name, ugettext, ungettext
|
|
83
|
+
from .i18n import format_date, format_datetime, get_language_name, get_translation, ugettext, ungettext
|
|
83
84
|
|
|
84
85
|
env.add_extension('jinja2.ext.i18n')
|
|
85
86
|
env.install_gettext_callables(ugettext, ungettext, newstyle=True)
|
|
87
|
+
env.filters['format_date'] = format_date
|
|
86
88
|
env.filters['format_datetime'] = format_datetime
|
|
87
89
|
env.globals['get_language_name'] = get_language_name
|
|
90
|
+
env.globals['get_translation'] = get_translation
|
|
88
91
|
|
|
89
92
|
# Update globals, filters and tests
|
|
90
93
|
env.globals.update(globals)
|
|
@@ -104,7 +107,7 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
104
107
|
|
|
105
108
|
return env
|
|
106
109
|
|
|
107
|
-
def render_request(self, template,
|
|
110
|
+
def render_request(self, template, context={}, env=_UNDEFINED, extra_processor=_UNDEFINED):
|
|
108
111
|
"""
|
|
109
112
|
Render template for a given cherrypy request.
|
|
110
113
|
"""
|
|
@@ -114,24 +117,24 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
114
117
|
if extra_processor is _UNDEFINED:
|
|
115
118
|
extra_processor = request.config.get('tools.jinja2.extra_processor')
|
|
116
119
|
# Execute extra processor if defined.
|
|
117
|
-
|
|
120
|
+
new_context = {}
|
|
118
121
|
if extra_processor:
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
new_context.update(extra_processor())
|
|
123
|
+
new_context.update(context)
|
|
121
124
|
# Render templates
|
|
122
|
-
return self.render(env=env, template=template,
|
|
125
|
+
return self.render(env=env, template=template, context=new_context)
|
|
123
126
|
|
|
124
|
-
def render(self, env, template,
|
|
127
|
+
def render(self, env, template, context={}):
|
|
125
128
|
"""
|
|
126
129
|
Lower level function used to render a template using the given jinja2 environment, template(s) and variable context.
|
|
127
130
|
"""
|
|
128
131
|
# Get the right templates
|
|
129
132
|
if isinstance(template, (list, tuple)):
|
|
130
|
-
names = [t.format(**
|
|
133
|
+
names = [t.format(**context) for t in template]
|
|
131
134
|
tmpl = env.select_template(names)
|
|
132
135
|
else:
|
|
133
136
|
tmpl = env.get_template(template)
|
|
134
|
-
out = tmpl.render(
|
|
137
|
+
out = tmpl.render(context)
|
|
135
138
|
|
|
136
139
|
# With JinjaX > 0.60 render explicitly here.
|
|
137
140
|
if 'catalog' in env.globals and getattr(env.globals['catalog'], '_emit_assets_later', False):
|
|
@@ -146,6 +146,8 @@ class FileRateLimit(_DataStore):
|
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
class Ratelimit(cherrypy.Tool):
|
|
149
|
+
CONTEXT = 'TOOLS.RATELIMIT'
|
|
150
|
+
|
|
149
151
|
def __init__(self, priority=60):
|
|
150
152
|
super().__init__('before_handler', self.check_ratelimit, 'ratelimit', priority)
|
|
151
153
|
|
|
@@ -182,10 +184,7 @@ class Ratelimit(cherrypy.Tool):
|
|
|
182
184
|
request = cherrypy.request
|
|
183
185
|
if methods is not None and request.method not in methods:
|
|
184
186
|
if debug:
|
|
185
|
-
cherrypy.log(
|
|
186
|
-
'skip rate limit for HTTP method %s' % (request.method,),
|
|
187
|
-
'TOOLS.RATELIMIT',
|
|
188
|
-
)
|
|
187
|
+
cherrypy.log(f'skip rate limit for HTTP method {request.method}', context=self.CONTEXT)
|
|
189
188
|
return
|
|
190
189
|
|
|
191
190
|
# If datastore is not pass as configuration, create it for the first time.
|
|
@@ -213,10 +212,7 @@ class Ratelimit(cherrypy.Tool):
|
|
|
213
212
|
# Get hits count using datastore.
|
|
214
213
|
hits, timeout = datastore.get_and_increment(token, delay, hit)
|
|
215
214
|
if debug:
|
|
216
|
-
cherrypy.log(
|
|
217
|
-
'check and increase rate limit for scope %s, limit %s, hits %s' % (token, limit, hits),
|
|
218
|
-
'TOOLS.RATELIMIT',
|
|
219
|
-
)
|
|
215
|
+
cherrypy.log(f'check and increase limit token={token} limit={limit} hits={hits}', context=self.CONTEXT)
|
|
220
216
|
|
|
221
217
|
# Verify user has not exceeded rate limit
|
|
222
218
|
remaining = max(0, limit - hits)
|
|
@@ -227,7 +223,7 @@ class Ratelimit(cherrypy.Tool):
|
|
|
227
223
|
cherrypy.response.headers['X-RateLimit-Reset'] = str(timeout)
|
|
228
224
|
|
|
229
225
|
if limit < hits: # block only after 'limit' successful requests
|
|
230
|
-
cherrypy.log('
|
|
226
|
+
cherrypy.log(f'block access to path_info={request.path_info}', context=self.CONTEXT)
|
|
231
227
|
if logout:
|
|
232
228
|
if hasattr(cherrypy.serving, 'session'):
|
|
233
229
|
cherrypy.serving.session.clear()
|
|
@@ -15,13 +15,9 @@
|
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
17
|
import http.cookies
|
|
18
|
-
import logging
|
|
19
18
|
|
|
20
19
|
import cherrypy
|
|
21
20
|
|
|
22
|
-
# Define the logger
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
21
|
DEFAULT_CSP = {
|
|
26
22
|
'default-src': 'self',
|
|
27
23
|
'style-src': ('self', 'unsafe-inline'),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<html lang="{{ get_translation().locale }}">
|
|
2
|
+
<head>
|
|
3
|
+
<title>test-jinja2</title>
|
|
4
|
+
</head>
|
|
5
|
+
<body>
|
|
6
|
+
{{ get_language_name(get_translation().locale) }}<br/>
|
|
7
|
+
{% trans %}Some text to translate{% endtrans %}<br/>
|
|
8
|
+
{{ my_datetime | format_datetime(format='full') }}<br/>
|
|
9
|
+
{{ my_date | format_date(format='full') }}
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -83,7 +83,7 @@ class AuthManagerTest(helper.CPWebCase):
|
|
|
83
83
|
def test_auth_login(self):
|
|
84
84
|
# Given unauthenticated user
|
|
85
85
|
# When posting valid login
|
|
86
|
-
self.getPage('/login/', method='POST', body=urlencode(
|
|
86
|
+
self.getPage('/login/', method='POST', body=urlencode({'username': 'myuser', 'password': 'changeme'}))
|
|
87
87
|
# Then user is redirect to index.
|
|
88
88
|
self.assertStatus(303)
|
|
89
89
|
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# CherryPy
|
|
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
|
+
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
from collections import namedtuple
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
import cherrypy
|
|
23
|
+
from cherrypy.lib.sessions import RamSession
|
|
24
|
+
from cherrypy.test import helper
|
|
25
|
+
|
|
26
|
+
from ..auth import AUTH_LAST_PASSWORD_AT
|
|
27
|
+
from ..auth_mfa import (
|
|
28
|
+
MFA_CODE_TIME,
|
|
29
|
+
MFA_DEFAULT_CODE_TIMEOUT,
|
|
30
|
+
MFA_DEFAULT_TRUST_DURATION,
|
|
31
|
+
MFA_TRUSTED_IP_LIST,
|
|
32
|
+
MFA_USER_KEY,
|
|
33
|
+
MFA_VERIFICATION_TIME,
|
|
34
|
+
)
|
|
35
|
+
from ..sessions_timeout import SESSION_PERSISTENT
|
|
36
|
+
|
|
37
|
+
User = namedtuple('User', 'id,username,password,email,mfa', defaults=[False])
|
|
38
|
+
|
|
39
|
+
users = {
|
|
40
|
+
User(2, 'myuser', 'changeme', 'myuser@example.com', False),
|
|
41
|
+
User(3, 'mfauser', 'changeme', 'mfauser@example.com', True),
|
|
42
|
+
User(4, 'noemail', 'changeme', '', True),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def checkpassword(username, password):
|
|
47
|
+
for u in users:
|
|
48
|
+
if u.username == username and u.password == password:
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def user_lookup_func(login, user_info):
|
|
54
|
+
for u in users:
|
|
55
|
+
if u.username == login:
|
|
56
|
+
return u.id, u
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def user_from_key_func(userkey):
|
|
61
|
+
for u in users:
|
|
62
|
+
if u.id == userkey:
|
|
63
|
+
return u
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cherrypy.tools.sessions()
|
|
68
|
+
@cherrypy.tools.auth(
|
|
69
|
+
user_lookup_func=user_lookup_func,
|
|
70
|
+
user_from_key_func=user_from_key_func,
|
|
71
|
+
checkpassword=checkpassword,
|
|
72
|
+
)
|
|
73
|
+
@cherrypy.tools.auth_mfa(
|
|
74
|
+
mfa_enabled=lambda: hasattr(cherrypy.serving.request, 'currentuser') and cherrypy.request.currentuser.mfa
|
|
75
|
+
)
|
|
76
|
+
class Root:
|
|
77
|
+
|
|
78
|
+
@cherrypy.expose
|
|
79
|
+
def index(self):
|
|
80
|
+
return "OK"
|
|
81
|
+
|
|
82
|
+
@cherrypy.expose()
|
|
83
|
+
def login(self, username=None, password=None):
|
|
84
|
+
if cherrypy.serving.request.method == 'POST' and username and password:
|
|
85
|
+
userobj = cherrypy.tools.auth.login_with_credentials(username, password)
|
|
86
|
+
if userobj:
|
|
87
|
+
raise cherrypy.tools.auth.redirect_to_original_url()
|
|
88
|
+
else:
|
|
89
|
+
return "invalid credentials"
|
|
90
|
+
return "login"
|
|
91
|
+
|
|
92
|
+
@cherrypy.expose()
|
|
93
|
+
def mfa(self, code=None, resend_code=None, persistent=False):
|
|
94
|
+
html = "mfa\n"
|
|
95
|
+
if cherrypy.serving.request.method == 'POST' and code:
|
|
96
|
+
if cherrypy.tools.auth_mfa.verify_code(code=code, persistent=persistent):
|
|
97
|
+
raise cherrypy.tools.auth.redirect_to_original_url()
|
|
98
|
+
else:
|
|
99
|
+
html += "<p>invalid verification code</p>\n"
|
|
100
|
+
# Send verification code if previous code expired.
|
|
101
|
+
# Or when requested by user.
|
|
102
|
+
if (resend_code and cherrypy.serving.request.method == 'POST') or cherrypy.tools.auth_mfa.is_code_expired():
|
|
103
|
+
code = cherrypy.tools.auth_mfa.generate_code()
|
|
104
|
+
# Here the code should be send by email, SMS or any other means.
|
|
105
|
+
# For our test we store it in the session.
|
|
106
|
+
cherrypy.serving.session['code'] = code
|
|
107
|
+
html += "<p>new verification code sent to your email</p>\n"
|
|
108
|
+
return html
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AuthManagerMfaTest(helper.CPWebCase):
|
|
112
|
+
interactive = False
|
|
113
|
+
# Authenticated by default.
|
|
114
|
+
login = True
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def setup_server(cls):
|
|
118
|
+
cherrypy.tree.mount(Root(), '/')
|
|
119
|
+
|
|
120
|
+
def getPage(self, *args, **kwargs):
|
|
121
|
+
"""
|
|
122
|
+
This implementation keep track of session cookies.
|
|
123
|
+
"""
|
|
124
|
+
headers = kwargs.pop('headers', [])
|
|
125
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
126
|
+
headers.extend(self.cookies)
|
|
127
|
+
return helper.CPWebCase.getPage(self, *args, headers=headers, **kwargs)
|
|
128
|
+
|
|
129
|
+
def _login(self, username, password):
|
|
130
|
+
self.getPage('/login/', method='POST', body=urlencode({'username': username, 'password': password}))
|
|
131
|
+
self.assertStatus(303)
|
|
132
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def _session_id(self):
|
|
136
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
137
|
+
for unused, value in self.cookies:
|
|
138
|
+
for part in value.split(';'):
|
|
139
|
+
key, unused, value = part.partition('=')
|
|
140
|
+
if key == 'session_id':
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
def _get_code(self):
|
|
144
|
+
# Query MFA page to generate a code
|
|
145
|
+
self.getPage("/mfa/")
|
|
146
|
+
self.assertStatus(200)
|
|
147
|
+
self.assertInBody("new verification code sent to your email")
|
|
148
|
+
# Extract code from user session for testing only.
|
|
149
|
+
session = RamSession.cache[self._session_id][0]
|
|
150
|
+
return session['code']
|
|
151
|
+
|
|
152
|
+
def test_get_without_login(self):
|
|
153
|
+
# Given the user is not authenticated.
|
|
154
|
+
# When requesting /mfa/
|
|
155
|
+
self.getPage("/mfa/")
|
|
156
|
+
# Then user is redirected to /login/
|
|
157
|
+
self.assertStatus(303)
|
|
158
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
159
|
+
|
|
160
|
+
def test_get_with_mfa_disabled(self):
|
|
161
|
+
# Given an authenticated user with MFA Disable
|
|
162
|
+
self._login('myuser', 'changeme')
|
|
163
|
+
# When requesting /mfa/ page
|
|
164
|
+
self.getPage("/mfa/")
|
|
165
|
+
# Then user is redirected to root page
|
|
166
|
+
self.assertStatus(303)
|
|
167
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
168
|
+
# Then index is enabled.
|
|
169
|
+
self.getPage("/")
|
|
170
|
+
self.assertStatus(200)
|
|
171
|
+
self.assertInBody('OK')
|
|
172
|
+
|
|
173
|
+
def test_get_with_trusted(self):
|
|
174
|
+
# Given an authenticated user with MFA Disable
|
|
175
|
+
self._login('mfauser', 'changeme')
|
|
176
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
177
|
+
session = RamSession.cache[self._session_id][0]
|
|
178
|
+
session[MFA_USER_KEY] = 3
|
|
179
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now()
|
|
180
|
+
session[MFA_TRUSTED_IP_LIST] = ['127.0.0.1']
|
|
181
|
+
|
|
182
|
+
# When requesting /mfa/ page when we are already trusted
|
|
183
|
+
self.getPage("/mfa/")
|
|
184
|
+
# Then user is redirected to root page
|
|
185
|
+
self.assertStatus(303)
|
|
186
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
187
|
+
|
|
188
|
+
def test_get_with_trusted_expired(self):
|
|
189
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
190
|
+
self._login('mfauser', 'changeme')
|
|
191
|
+
session = RamSession.cache[self._session_id][0]
|
|
192
|
+
session[MFA_USER_KEY] = 3
|
|
193
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now() - datetime.timedelta(minutes=60)
|
|
194
|
+
|
|
195
|
+
# When requesting /mfa/ page
|
|
196
|
+
self.getPage("/mfa/")
|
|
197
|
+
self.assertStatus(200)
|
|
198
|
+
# Then a verification code is send to the user
|
|
199
|
+
self.assertInBody("new verification code sent to your email")
|
|
200
|
+
|
|
201
|
+
def test_get_with_trusted_different_ip(self):
|
|
202
|
+
# Given an authenticated user with MFA enabled and already verified
|
|
203
|
+
self._login('mfauser', 'changeme')
|
|
204
|
+
session = RamSession.cache[self._session_id][0]
|
|
205
|
+
session[MFA_USER_KEY] = 3
|
|
206
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now()
|
|
207
|
+
|
|
208
|
+
# When requesting /mfa/ page from a different ip
|
|
209
|
+
self.getPage("/mfa/", headers=[('X-Forwarded-For', '10.255.14.23')])
|
|
210
|
+
self.assertStatus(200)
|
|
211
|
+
# Then a verification code is send to the user
|
|
212
|
+
self.assertInBody("new verification code sent to your email")
|
|
213
|
+
|
|
214
|
+
def test_get_without_verified(self):
|
|
215
|
+
# Given an authenticated user With MFA enabled
|
|
216
|
+
self._login('mfauser', 'changeme')
|
|
217
|
+
# When requesting /mfa/ page
|
|
218
|
+
self.getPage("/mfa/")
|
|
219
|
+
self.assertStatus(200)
|
|
220
|
+
# Then a verification code is send to the user
|
|
221
|
+
self.assertInBody("new verification code sent to your email")
|
|
222
|
+
|
|
223
|
+
def test_verify_code_valid(self):
|
|
224
|
+
prev_session_id = self._session_id
|
|
225
|
+
# Given an authenticated user With MFA enabled
|
|
226
|
+
self._login('mfauser', 'changeme')
|
|
227
|
+
code = self._get_code()
|
|
228
|
+
# When sending a valid verification code
|
|
229
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
230
|
+
# Then a new session_id is generated
|
|
231
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
232
|
+
# Then user is redirected to root page
|
|
233
|
+
self.assertStatus(303)
|
|
234
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
235
|
+
# Then user has access
|
|
236
|
+
self.getPage("/")
|
|
237
|
+
self.assertStatus(200)
|
|
238
|
+
|
|
239
|
+
def test_verify_code_invalid(self):
|
|
240
|
+
# Given an authenticated user With MFA enabled
|
|
241
|
+
# When sending an invalid verification code
|
|
242
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
243
|
+
# Then user is redirected to login page
|
|
244
|
+
self.assertStatus(303)
|
|
245
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
246
|
+
|
|
247
|
+
def test_verify_code_expired(self):
|
|
248
|
+
# Given an authenticated user With MFA enabled
|
|
249
|
+
self._login('mfauser', 'changeme')
|
|
250
|
+
code = self._get_code()
|
|
251
|
+
# When sending a valid verification code that expired
|
|
252
|
+
session = RamSession.cache[self._session_id][0]
|
|
253
|
+
|
|
254
|
+
session[MFA_CODE_TIME] = datetime.datetime.now() - datetime.timedelta(minutes=MFA_DEFAULT_CODE_TIMEOUT + 1)
|
|
255
|
+
|
|
256
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
257
|
+
# Then a new code get generated.
|
|
258
|
+
self.assertStatus(200)
|
|
259
|
+
self.assertInBody("invalid verification code")
|
|
260
|
+
|
|
261
|
+
def test_verify_code_invalid_after_3_tentative(self):
|
|
262
|
+
# Given an authenticated user With MFA
|
|
263
|
+
self._login('mfauser', 'changeme')
|
|
264
|
+
code = self._get_code()
|
|
265
|
+
# When user enter an invalid verification code 3 times
|
|
266
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': '1234567'}))
|
|
267
|
+
self.assertStatus(200)
|
|
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
|
+
# Then an error get displayed to the user
|
|
272
|
+
self.assertStatus(200)
|
|
273
|
+
self.assertInBody("invalid verification code")
|
|
274
|
+
# Then a new code get send to the user.
|
|
275
|
+
self.assertInBody("new verification code sent to your email")
|
|
276
|
+
session = RamSession.cache[self._session_id][0]
|
|
277
|
+
new_code = session['code']
|
|
278
|
+
self.assertNotEqual(code, new_code)
|
|
279
|
+
|
|
280
|
+
def test_resend_code(self):
|
|
281
|
+
# Given an authenticated user With MFA enabled with an existing code
|
|
282
|
+
self._login('mfauser', 'changeme')
|
|
283
|
+
code = self._get_code()
|
|
284
|
+
# When user request a new code
|
|
285
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'resend_code': '1'}))
|
|
286
|
+
# Then A success message is displayedto the user.
|
|
287
|
+
self.assertInBody("new verification code sent to your email")
|
|
288
|
+
session = RamSession.cache[self._session_id][0]
|
|
289
|
+
new_code = session['code']
|
|
290
|
+
self.assertNotEqual(code, new_code)
|
|
291
|
+
|
|
292
|
+
def test_redirect_to_original_url(self):
|
|
293
|
+
# Given an authenticated user
|
|
294
|
+
self._login('mfauser', 'changeme')
|
|
295
|
+
# When querying a page that required mfa
|
|
296
|
+
self.getPage('/prefs/general')
|
|
297
|
+
# Then user is redirected to mfa page
|
|
298
|
+
self.assertStatus(303)
|
|
299
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/mfa/')
|
|
300
|
+
# When providing verification code
|
|
301
|
+
code = self._get_code()
|
|
302
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code}))
|
|
303
|
+
# Then user is redirected to original url
|
|
304
|
+
self.assertStatus(303)
|
|
305
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/prefs/general')
|
|
306
|
+
|
|
307
|
+
def test_login_persistent_when_login_timeout(self):
|
|
308
|
+
prev_session_id = self._session_id
|
|
309
|
+
# Given a user authenticated with MFA with "persistent"
|
|
310
|
+
self._login('mfauser', 'changeme')
|
|
311
|
+
code = self._get_code()
|
|
312
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
313
|
+
self.assertStatus(303)
|
|
314
|
+
self.getPage("/")
|
|
315
|
+
self.assertStatus(200)
|
|
316
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
317
|
+
session = RamSession.cache[self._session_id][0]
|
|
318
|
+
self.assertTrue(session[SESSION_PERSISTENT])
|
|
319
|
+
# When the re-auth time expired (after 15 min)
|
|
320
|
+
session[AUTH_LAST_PASSWORD_AT] = datetime.datetime.now() - datetime.timedelta(minutes=60, seconds=1)
|
|
321
|
+
|
|
322
|
+
# Then next query redirect user to /login/ page (by mfa)
|
|
323
|
+
self.getPage("/")
|
|
324
|
+
self.assertStatus(303)
|
|
325
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/login/')
|
|
326
|
+
prev_session_id = self._session_id
|
|
327
|
+
# When user enter valid username password
|
|
328
|
+
self.getPage("/login/", method='POST', body=urlencode({'username': 'mfauser', 'password': 'changeme'}))
|
|
329
|
+
# Then user is redirected to original url without need to pass MFA again.
|
|
330
|
+
self.assertStatus(303)
|
|
331
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/')
|
|
332
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
333
|
+
self.getPage("/")
|
|
334
|
+
self.assertStatus(200)
|
|
335
|
+
self.assertInBody('OK')
|
|
336
|
+
|
|
337
|
+
def test_login_persistent_when_mfa_timeout(self):
|
|
338
|
+
prev_session_id = self._session_id
|
|
339
|
+
# Given a user authenticated with MFA with "persistent"
|
|
340
|
+
self._login('mfauser', 'changeme')
|
|
341
|
+
code = self._get_code()
|
|
342
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
343
|
+
self.assertStatus(303)
|
|
344
|
+
self.getPage("/")
|
|
345
|
+
self.assertStatus(200)
|
|
346
|
+
self.assertNotEqual(prev_session_id, self._session_id)
|
|
347
|
+
session = RamSession.cache[self._session_id][0]
|
|
348
|
+
|
|
349
|
+
self.assertTrue(session[SESSION_PERSISTENT])
|
|
350
|
+
# When the mfa verification timeout (after 15 min)
|
|
351
|
+
session[MFA_VERIFICATION_TIME] = datetime.datetime.now() - datetime.timedelta(
|
|
352
|
+
minutes=MFA_DEFAULT_TRUST_DURATION, seconds=1
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Then next query redirect user to mfa page
|
|
356
|
+
self.getPage("/prefs/general")
|
|
357
|
+
self.assertStatus(303)
|
|
358
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/mfa/')
|
|
359
|
+
# When user enter valid code
|
|
360
|
+
code = self._get_code()
|
|
361
|
+
self.getPage("/mfa/", method='POST', body=urlencode({'code': code, 'persistent': '1'}))
|
|
362
|
+
# Then user is redirected to original page.
|
|
363
|
+
self.assertStatus(303)
|
|
364
|
+
self.assertHeaderItemValue('Location', f'http://{self.HOST}:{self.PORT}/prefs/general')
|
|
365
|
+
self.getPage("/")
|
|
366
|
+
self.assertStatus(200)
|
|
367
|
+
self.assertInBody('OK')
|