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.
Files changed (31) hide show
  1. cherrypy_foundation/components/Field.jinja +14 -6
  2. cherrypy_foundation/components/__init__.py +1 -1
  3. cherrypy_foundation/error_page.py +18 -15
  4. cherrypy_foundation/plugins/db.py +34 -8
  5. cherrypy_foundation/plugins/ldap.py +28 -22
  6. cherrypy_foundation/plugins/scheduler.py +197 -84
  7. cherrypy_foundation/plugins/smtp.py +71 -45
  8. cherrypy_foundation/plugins/tests/test_db.py +2 -2
  9. cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
  10. cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
  11. cherrypy_foundation/tests/templates/test_form.html +10 -0
  12. cherrypy_foundation/tests/test_form.py +119 -0
  13. cherrypy_foundation/tools/auth.py +28 -11
  14. cherrypy_foundation/tools/auth_mfa.py +10 -13
  15. cherrypy_foundation/tools/errors.py +1 -1
  16. cherrypy_foundation/tools/i18n.py +15 -6
  17. cherrypy_foundation/tools/jinja2.py +15 -12
  18. cherrypy_foundation/tools/ratelimit.py +5 -9
  19. cherrypy_foundation/tools/secure_headers.py +0 -4
  20. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  21. cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
  22. cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
  23. cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
  24. cherrypy_foundation/tools/tests/test_auth.py +1 -1
  25. cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
  26. cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
  27. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/METADATA +1 -1
  28. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +31 -23
  29. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +0 -0
  30. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
  31. {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 = cherrypy.config.get('tools.i18n.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 = cherrypy.config.get('tools.i18n.default')
238
+ default = _get_config('tools.i18n.default')
230
239
  preferred_lang = getattr(_current, 'preferred_lang', [default])
231
- mo_dir = cherrypy.config.get('tools.i18n.mo_dir')
232
- domain = cherrypy.config.get('tools.i18n.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 = cherrypy.config.get('tools.i18n.mo_dir', False)
246
- domain = cherrypy.config.get('tools.i18n.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
- vars = self.oldhandler(*args, **kwargs)
55
+ context = self.oldhandler(*args, **kwargs)
55
56
  # Render template.
56
- return self.render_request(env=env, template=template, vars=vars, extra_processor=extra_processor)
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('Replacing request.handler', 'TOOLS.JINJA2')
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, vars={}, env=_UNDEFINED, extra_processor=_UNDEFINED):
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
- all_vars = {}
120
+ new_context = {}
118
121
  if extra_processor:
119
- all_vars.update(extra_processor())
120
- all_vars.update(vars)
122
+ new_context.update(extra_processor())
123
+ new_context.update(context)
121
124
  # Render templates
122
- return self.render(env=env, template=template, vars=all_vars)
125
+ return self.render(env=env, template=template, context=new_context)
123
126
 
124
- def render(self, env, template, vars={}):
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(**vars) for t in template]
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(vars)
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('ratelimit access to `%s`' % request.path_info, 'TOOLS.RATELIMIT')
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,2 @@
1
+ {# def label, link #}
2
+ <a {{ attrs.render(class='btn btn-primary', href=link) }}>{{ label }}</a>
@@ -0,0 +1,10 @@
1
+ <html>
2
+ <head>
3
+ <title>test-jinja2</title>
4
+ </head>
5
+ <body>
6
+ var1: {{ var1 }}<br/>
7
+ var2: {{ var2 }}<br/>
8
+ const1: {{ const1 }}<br/>
9
+ </body>
10
+ </html>
@@ -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>
@@ -0,0 +1,8 @@
1
+ <html>
2
+ <head>
3
+ <title>test-jinja2</title>
4
+ </head>
5
+ <body>
6
+ <Button link="http://example.com" label="foo" />
7
+ </body>
8
+ </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([('username', 'myuser'), ('password', 'changeme')]))
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')