Skip to content

Commit 454fd98

Browse files
authored
Merge pull request from GHSA-vhr6-pvjm-9qwf
Discontinue storing credentials in the session store
2 parents a5b588a + fb9ee19 commit 454fd98

File tree

5 files changed

+230
-34
lines changed

5 files changed

+230
-34
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
## [Unreleased]
2+
### Added
3+
- It is possible to set a timeout between a user authenticiating in the LoginView and them needing to re-authenticate. By default this is 10 minutes.
4+
5+
### Removed
6+
- The final step in the LoginView no longer re-validates a user's credentials
7+
8+
### Changed
9+
- Security Fix: LoginView no longer stores credentials in plaintext in the session store
210

311
## 1.11.0 - 2020-03-13
412
### Added

docs/configuration.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ General Settings
7373
`the upstream ticket`_). Don't set this option to 8 unless all of your
7474
users use a 8 digit compatible token generator app.
7575

76+
``TWO_FACTOR_LOGIN_TIMEOUT`` (default ``600``)
77+
The number of seconds between a user successfully passing the "authentication"
78+
step (usually by entering a valid username and password) and them having to
79+
restart the login flow and re-authenticate. This ensures that users can't sit
80+
indefinately in a state of having entered their password successfully but not
81+
having passed two factor authentication. Set to ``0`` to disable.
82+
7683
``PHONENUMBER_DEFAULT_REGION`` (default: ``None``)
7784
The default region for parsing phone numbers. If your application's primary
7885
audience is a certain country, setting the region to that country allows

tests/test_views_login.py

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from unittest import mock
23

34
from django.conf import settings
@@ -88,6 +89,100 @@ def test_valid_login_with_disallowed_external_redirect(self):
8889
'login_view-current_step': 'auth'})
8990
self.assertRedirects(response, reverse('two_factor:profile'), fetch_redirect_response=False)
9091

92+
@mock.patch('two_factor.views.core.time')
93+
def test_valid_login_primary_key_stored(self, mock_time):
94+
mock_time.time.return_value = 12345.12
95+
user = self.create_user()
96+
user.totpdevice_set.create(name='default',
97+
key=random_hex_str())
98+
99+
response = self._post({'auth-username': '[email protected]',
100+
'auth-password': 'secret',
101+
'login_view-current_step': 'auth'})
102+
self.assertContains(response, 'Token:')
103+
104+
self.assertEqual(self.client.session['wizard_login_view']['user_pk'], str(user.pk))
105+
self.assertEqual(
106+
self.client.session['wizard_login_view']['user_backend'],
107+
'django.contrib.auth.backends.ModelBackend')
108+
self.assertEqual(self.client.session['wizard_login_view']['authentication_time'], 12345)
109+
110+
@mock.patch('two_factor.views.core.time')
111+
def test_valid_login_post_auth_session_clear_of_form_data(self, mock_time):
112+
mock_time.time.return_value = 12345.12
113+
user = self.create_user()
114+
user.totpdevice_set.create(name='default',
115+
key=random_hex_str())
116+
117+
response = self._post({'auth-username': '[email protected]',
118+
'auth-password': 'secret',
119+
'login_view-current_step': 'auth'})
120+
self.assertContains(response, 'Token:')
121+
122+
self.assertEqual(self.client.session['wizard_login_view']['user_pk'], str(user.pk))
123+
self.assertEqual(self.client.session['wizard_login_view']['step'], 'token')
124+
self.assertEqual(self.client.session['wizard_login_view']['step_data'], {'auth': None})
125+
self.assertEqual(self.client.session['wizard_login_view']['step_files'], {'auth': {}})
126+
self.assertEqual(self.client.session['wizard_login_view']['validated_step_data'], {})
127+
128+
@mock.patch('two_factor.views.core.logger')
129+
@mock.patch('two_factor.views.core.time')
130+
def test_valid_login_expired(self, mock_time, mock_logger):
131+
mock_time.time.return_value = 12345.12
132+
user = self.create_user()
133+
device = user.totpdevice_set.create(name='default',
134+
key=random_hex_str())
135+
136+
response = self._post({'auth-username': '[email protected]',
137+
'auth-password': 'secret',
138+
'login_view-current_step': 'auth'})
139+
self.assertContains(response, 'Token:')
140+
141+
self.assertEqual(self.client.session['wizard_login_view']['user_pk'], str(user.pk))
142+
self.assertEqual(
143+
self.client.session['wizard_login_view']['user_backend'],
144+
'django.contrib.auth.backends.ModelBackend')
145+
self.assertEqual(self.client.session['wizard_login_view']['authentication_time'], 12345)
146+
147+
mock_time.time.return_value = 20345.12
148+
149+
response = self._post({'token-otp_token': totp(device.bin_key),
150+
'login_view-current_step': 'token'})
151+
self.assertEqual(response.status_code, 200)
152+
self.assertNotContains(response, 'Token:')
153+
self.assertContains(response, 'Password:')
154+
self.assertContains(response, 'Your session has timed out. Please login again.')
155+
156+
# Check that a message was logged.
157+
mock_logger.info.assert_called_with(
158+
"User's authentication flow has timed out. The user "
159+
"has been redirected to the initial auth form.")
160+
161+
@override_settings(TWO_FACTOR_LOGIN_TIMEOUT=0)
162+
@mock.patch('two_factor.views.core.time')
163+
def test_valid_login_no_timeout(self, mock_time):
164+
mock_time.time.return_value = 12345.12
165+
user = self.create_user()
166+
device = user.totpdevice_set.create(name='default',
167+
key=random_hex_str())
168+
169+
response = self._post({'auth-username': '[email protected]',
170+
'auth-password': 'secret',
171+
'login_view-current_step': 'auth'})
172+
self.assertContains(response, 'Token:')
173+
174+
self.assertEqual(self.client.session['wizard_login_view']['user_pk'], str(user.pk))
175+
self.assertEqual(
176+
self.client.session['wizard_login_view']['user_backend'],
177+
'django.contrib.auth.backends.ModelBackend')
178+
self.assertEqual(self.client.session['wizard_login_view']['authentication_time'], 12345)
179+
180+
mock_time.time.return_value = 20345.12
181+
182+
response = self._post({'token-otp_token': totp(device.bin_key),
183+
'login_view-current_step': 'token'})
184+
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
185+
self.assertEqual(self.client.session['_auth_user_id'], str(user.pk))
91186

92187
def test_valid_login_with_redirect_authenticated_user(self):
93188
user = self.create_user()
@@ -251,35 +346,6 @@ def test_with_backup_token(self, mock_signal):
251346
# Check that the signal was fired.
252347
mock_signal.assert_called_with(sender=mock.ANY, request=mock.ANY, user=user, device=device)
253348

254-
@mock.patch('two_factor.views.utils.logger')
255-
def test_change_password_in_between(self, mock_logger):
256-
"""
257-
When the password of the user is changed while trying to login, should
258-
not result in errors. Refs #63.
259-
"""
260-
user = self.create_user()
261-
self.enable_otp()
262-
263-
response = self._post({'auth-username': '[email protected]',
264-
'auth-password': 'secret',
265-
'login_view-current_step': 'auth'})
266-
self.assertContains(response, 'Token:')
267-
268-
# Now, the password is changed. When the form is submitted, the
269-
# credentials should be checked again. If that's the case, the
270-
# login form should note that the credentials are invalid.
271-
user.set_password('secret2')
272-
user.save()
273-
response = self._post({'login_view-current_step': 'token'})
274-
self.assertContains(response, 'Please enter a correct')
275-
self.assertContains(response, 'and password.')
276-
277-
# Check that a message was logged.
278-
mock_logger.warning.assert_called_with(
279-
"Current step '%s' is no longer valid, returning to last valid "
280-
"step in the wizard.",
281-
'token')
282-
283349
@mock.patch('two_factor.views.utils.logger')
284350
def test_reset_wizard_state(self, mock_logger):
285351
self.create_user()
@@ -332,6 +398,19 @@ def test_missing_management_data(self):
332398
# view should return HTTP 400 Bad Request
333399
self.assertEqual(response.status_code, 400)
334400

401+
def test_no_password_in_session(self):
402+
self.create_user()
403+
self.enable_otp()
404+
405+
response = self._post({'auth-username': '[email protected]',
406+
'auth-password': 'secret',
407+
'login_view-current_step': 'auth'})
408+
self.assertContains(response, 'Token:')
409+
410+
session_contents = json.dumps(list(self.client.session.items()))
411+
412+
self.assertNotIn('secret', session_contents)
413+
335414

336415
class BackupTokensTest(UserMixin, TestCase):
337416
def setUp(self):

two_factor/views/core.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import warnings
33
from base64 import b32encode
44
from binascii import unhexlify
5+
import time
56

67
import django_otp
78
import qrcode
@@ -12,13 +13,15 @@
1213
from django.contrib.auth.forms import AuthenticationForm
1314
from django.contrib.auth.views import SuccessURLAllowedHostsMixin
1415
from django.contrib.sites.shortcuts import get_current_site
15-
from django.forms import Form
16+
from django.forms import Form, ValidationError
1617
from django.http import Http404, HttpResponse, HttpResponseRedirect
1718
from django.shortcuts import redirect, resolve_url
1819
from django.urls import reverse
1920
from django.utils.decorators import method_decorator
21+
from django.utils.functional import cached_property
2022
from django.utils.http import is_safe_url
2123
from django.utils.module_loading import import_string
24+
from django.utils.translation import gettext as _
2225
from django.views.decorators.cache import never_cache
2326
from django.views.decorators.csrf import csrf_protect
2427
from django.views.decorators.debug import sensitive_post_parameters
@@ -72,6 +75,7 @@ class LoginView(SuccessURLAllowedHostsMixin, IdempotentSessionWizardView):
7275
'backup': False,
7376
}
7477
redirect_authenticated_user = False
78+
storage_name = 'two_factor.views.utils.LoginStorage'
7579

7680
def has_token_step(self):
7781
return default_device(self.get_user())
@@ -80,6 +84,14 @@ def has_backup_step(self):
8084
return default_device(self.get_user()) and \
8185
'token' not in self.storage.validated_step_data
8286

87+
@cached_property
88+
def expired(self):
89+
login_timeout = getattr(settings, 'TWO_FACTOR_LOGIN_TIMEOUT', 600)
90+
if login_timeout == 0:
91+
return False
92+
expiration_time = self.storage.data.get("authentication_time", 0) + login_timeout
93+
return int(time.time()) > expiration_time
94+
8395
condition_dict = {
8496
'token': has_token_step,
8597
'backup': has_backup_step,
@@ -90,12 +102,25 @@ def __init__(self, **kwargs):
90102
super().__init__(**kwargs)
91103
self.user_cache = None
92104
self.device_cache = None
105+
self.show_timeout_error = False
93106

94107
def post(self, *args, **kwargs):
95108
"""
96109
The user can select a particular device to challenge, being the backup
97110
devices added to the account.
98111
"""
112+
wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
113+
114+
if wizard_goto_step == 'auth':
115+
self.storage.reset()
116+
117+
if self.expired and self.steps.current != 'auth':
118+
logger.info("User's authentication flow has timed out. The user "
119+
"has been redirected to the initial auth form.")
120+
self.storage.reset()
121+
self.show_timeout_error = True
122+
return self.render_goto_step('auth')
123+
99124
# Generating a challenge doesn't require to validate the form.
100125
if 'challenge_device' in self.request.POST:
101126
return self.render_goto_step('token')
@@ -152,6 +177,54 @@ def get_form_kwargs(self, step=None):
152177
}
153178
return {}
154179

180+
def get_done_form_list(self):
181+
"""
182+
Return the forms that should be processed during the final step
183+
"""
184+
# Intentionally do not process the auth form on the final step. We
185+
# haven't stored this data, and it isn't required to login the user
186+
form_list = self.get_form_list()
187+
form_list.pop('auth')
188+
return form_list
189+
190+
def process_step(self, form):
191+
"""
192+
Process an individual step in the flow
193+
"""
194+
# To prevent saving any private auth data to the session store, we
195+
# validate the authentication form, determine the resulting user, then
196+
# only store the minimum needed to login that user (the user's primary
197+
# key and the backend used)
198+
if self.steps.current == 'auth':
199+
user = form.is_valid() and form.user_cache
200+
self.storage.reset()
201+
self.storage.authenticated_user = user
202+
self.storage.data["authentication_time"] = int(time.time())
203+
204+
# By returning None when the user clicks the "back" button to the
205+
# auth step the form will be blank with validation warnings
206+
return None
207+
208+
return super().process_step(form)
209+
210+
def process_step_files(self, form):
211+
"""
212+
Process the files submitted from a specific test
213+
"""
214+
if self.steps.current == 'auth':
215+
return {}
216+
return super().process_step_files(form)
217+
218+
def get_form(self, *args, **kwargs):
219+
"""
220+
Returns the form for the step
221+
"""
222+
form = super().get_form(*args, **kwargs)
223+
if self.show_timeout_error:
224+
form.cleaned_data = getattr(form, 'cleaned_data', {})
225+
form.add_error(None, ValidationError(_('Your session has timed out. Please login again.')))
226+
return form
227+
155228
def get_device(self, step=None):
156229
"""
157230
Returns the OTP device selected by the user, or his default device.
@@ -187,9 +260,7 @@ def get_user(self):
187260
if not a valid user; see also issue #65.
188261
"""
189262
if not self.user_cache:
190-
form_obj = self.get_form(step='auth',
191-
data=self.storage.get_step_data('auth'))
192-
self.user_cache = form_obj.is_valid() and form_obj.user_cache
263+
self.user_cache = self.storage.authenticated_user
193264
return self.user_cache
194265

195266
def get_context_data(self, form, **kwargs):

two_factor/views/utils.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from django.contrib.auth import load_backend
34
from django.core.exceptions import SuspiciousOperation
45
from django.utils.decorators import method_decorator
56
from django.utils.translation import gettext as _
@@ -37,6 +38,33 @@ def _set_validated_step_data(self, validated_step_data):
3738
_set_validated_step_data)
3839

3940

41+
class LoginStorage(ExtraSessionStorage):
42+
"""
43+
SessionStorage that includes the property 'authenticated_user' for storing
44+
backend authenticated users while logging in.
45+
"""
46+
def _get_authenticated_user(self):
47+
# Ensure that both user_pk and user_backend exist in the session
48+
if not all([self.data.get("user_pk"), self.data.get("user_backend")]):
49+
return False
50+
# Acquire the user the same way django.contrib.auth.get_user does
51+
backend = load_backend(self.data["user_backend"])
52+
user = backend.get_user(self.data["user_pk"])
53+
if not user:
54+
return False
55+
# Set user.backend to the dotted path version of the backend for login()
56+
user.backend = self.data["user_backend"]
57+
return user
58+
59+
def _set_authenticated_user(self, user):
60+
# Acquire the PK the same way django's auth middleware does
61+
self.data["user_pk"] = user._meta.pk.value_to_string(user)
62+
self.data["user_backend"] = user.backend
63+
64+
authenticated_user = property(_get_authenticated_user,
65+
_set_authenticated_user)
66+
67+
4068
class IdempotentSessionWizardView(SessionWizardView):
4169
"""
4270
WizardView that allows certain steps to be marked non-idempotent, in which
@@ -153,6 +181,9 @@ def process_step(self, form):
153181

154182
return super().process_step(form)
155183

184+
def get_done_form_list(self):
185+
return self.get_form_list()
186+
156187
def render_done(self, form, **kwargs):
157188
"""
158189
This method gets called when all forms passed. The method should also
@@ -162,7 +193,7 @@ def render_done(self, form, **kwargs):
162193
"""
163194
final_form_list = []
164195
# walk through the form list and try to validate the data again.
165-
for form_key in self.get_form_list():
196+
for form_key in self.get_done_form_list():
166197
form_obj = self.get_form(step=form_key,
167198
data=self.storage.get_step_data(form_key),
168199
files=self.storage.get_step_files(

0 commit comments

Comments
 (0)