Skip to content

Commit 4bd592c

Browse files
authored
Merge pull request #500 from MWeesenaar/enforce-setup-device-on-otp-required-page
Enforcing a redirect to setup of otp device when none available for user
2 parents 51b7fc2 + 6144f37 commit 4bd592c

File tree

4 files changed

+98
-12
lines changed

4 files changed

+98
-12
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## Unreleased
2+
3+
### Added
4+
- Enforcing a redirect to setup of otp device when none available for user [#550](https://github.com/jazzband/django-two-factor-auth/pull/500)
5+
16
## 1.14.0
27

38
### Added

tests/test_views_login.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_valid_login(self, mock_signal):
3838
response = self._post({'auth-username': '[email protected]',
3939
'auth-password': 'secret',
4040
'login_view-current_step': 'auth'})
41-
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
41+
self.assertRedirects(response, reverse('two_factor:setup'))
4242

4343
# No signal should be fired for non-verified user logins.
4444
self.assertFalse(mock_signal.called)
@@ -80,7 +80,8 @@ def test_valid_login_with_allowed_external_redirect(self):
8080
{'auth-username': '[email protected]',
8181
'auth-password': 'secret',
8282
'login_view-current_step': 'auth'})
83-
self.assertRedirects(response, redirect_url, fetch_redirect_response=False)
83+
self.assertEqual(self.client.session.get('next'), redirect_url)
84+
self.assertRedirects(response, reverse('two_factor:setup'), fetch_redirect_response=False)
8485

8586
def test_valid_login_with_disallowed_external_redirect(self):
8687
redirect_url = 'https://test.disallowed-success-url.com'
@@ -90,7 +91,7 @@ def test_valid_login_with_disallowed_external_redirect(self):
9091
{'auth-username': '[email protected]',
9192
'auth-password': 'secret',
9293
'login_view-current_step': 'auth'})
93-
self.assertRedirects(response, reverse('two_factor:profile'), fetch_redirect_response=False)
94+
self.assertRedirects(response, reverse('two_factor:setup'), fetch_redirect_response=False)
9495

9596
@mock.patch('two_factor.views.core.time')
9697
def test_valid_login_primary_key_stored(self, mock_time):
@@ -395,12 +396,12 @@ def test_login_different_user_on_existing_session(self, mock_logger):
395396
response = self._post({'auth-username': '[email protected]',
396397
'auth-password': 'secret',
397398
'login_view-current_step': 'auth'})
398-
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
399+
self.assertRedirects(response, reverse('two_factor:setup'))
399400

400401
response = self._post({'auth-username': '[email protected]',
401402
'auth-password': 'secret',
402403
'login_view-current_step': 'auth'})
403-
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
404+
self.assertRedirects(response, reverse('two_factor:setup'))
404405

405406
def test_missing_management_data(self):
406407
# missing management data
@@ -431,8 +432,7 @@ def test_login_different_user_with_otp_on_existing_session(self):
431432
response = self._post({'auth-username': '[email protected]',
432433
'auth-password': 'secret',
433434
'login_view-current_step': 'auth'})
434-
self.assertRedirects(response,
435-
resolve_url(settings.LOGIN_REDIRECT_URL))
435+
self.assertRedirects(response, reverse('two_factor:setup'))
436436

437437
response = self._post({'auth-username': '[email protected]',
438438
'auth-password': 'secret',

tests/test_views_mixins.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from binascii import unhexlify
2+
13
from django.conf import settings
24
from django.shortcuts import resolve_url
35
from django.test import TestCase
6+
from django.urls import reverse
7+
from django_otp.oath import totp
48

5-
from .utils import UserMixin
9+
from .utils import UserMixin, method_registry
610

711

812
class OTPRequiredMixinTest(UserMixin, TestCase):
@@ -53,3 +57,48 @@ def test_verified(self):
5357
self.login_user()
5458
response = self.client.get('/secure/')
5559
self.assertEqual(response.status_code, 200)
60+
61+
@method_registry(['generator'])
62+
def test_valid_login_with_redirect_field_name_without_device(self):
63+
self.create_user()
64+
protected_url = '/secure/'
65+
66+
# Open URL that is protected
67+
# assert go to login page
68+
# assert the next param not in the session (but still in url)
69+
response = self.client.get(protected_url)
70+
redirect_url = "%s?%s%s" % (reverse('two_factor:login'), 'next=', protected_url)
71+
self.assertRedirects(response, redirect_url)
72+
self.assertEqual(self.client.session.get('next'), None)
73+
74+
# Log in given the last redirect
75+
# assert redirect to setup
76+
response = self.client.post(
77+
redirect_url,
78+
{'auth-username': '[email protected]',
79+
'auth-password': 'secret',
80+
'login_view-current_step': 'auth'})
81+
self.assertRedirects(response, reverse('two_factor:setup'))
82+
self.assertEqual(self.client.session.get('next'), protected_url)
83+
84+
# Setup the device accordingly
85+
# assert redirect to setup completed
86+
# assert button for redirection to the original page
87+
response = self.client.post(
88+
reverse('two_factor:setup'),
89+
data={'setup_view-current_step': 'welcome'})
90+
self.assertContains(response, 'Token:')
91+
self.assertContains(response, 'autofocus="autofocus"')
92+
self.assertContains(response, 'inputmode="numeric"')
93+
self.assertContains(response, 'autocomplete="one-time-code"')
94+
95+
key = response.context_data['keys'].get('generator')
96+
bin_key = unhexlify(key.encode())
97+
response = self.client.post(
98+
reverse('two_factor:setup'),
99+
data={'setup_view-current_step': 'generator',
100+
'generator-token': totp(bin_key)},
101+
follow=True,
102+
)
103+
104+
self.assertRedirects(response, protected_url)

two_factor/views/core.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,13 @@ def done(self, form_list, **kwargs):
182182
samesite=getattr(settings, 'TWO_FACTOR_REMEMBER_COOKIE_SAMESITE', 'Lax'),
183183
)
184184

185-
return response
185+
return response
186+
187+
# If the user does not have a device.
188+
else:
189+
if self.request.GET.get('next'):
190+
self.request.session['next'] = self.get_success_url()
191+
return redirect('two_factor:setup')
186192

187193
# Copied from django.conrib.auth.views.LoginView (Branch: stable/1.11.x)
188194
# https://github.com/django/django/blob/58df8aa40fe88f753ba79e091a52f236246260b3/django/contrib/auth/views.py#L63
@@ -394,7 +400,7 @@ def dispatch(self, request, *args, **kwargs):
394400

395401
@class_view_decorator(never_cache)
396402
@class_view_decorator(login_required)
397-
class SetupView(IdempotentSessionWizardView):
403+
class SetupView(RedirectURLMixin, IdempotentSessionWizardView):
398404
"""
399405
View for handling OTP setup using a wizard.
400406
@@ -417,6 +423,27 @@ class SetupView(IdempotentSessionWizardView):
417423
# Other forms are dynamically added in get_form_list()
418424
)
419425

426+
# Copied from django.conrib.auth.views.LoginView (Branch: stable/1.11.x)
427+
# https://github.com/django/django/blob/58df8aa40fe88f753ba79e091a52f236246260b3/django/contrib/auth/views.py#L63
428+
def get_success_url(self):
429+
url = self.get_redirect_url()
430+
return url or reverse('two_factor:setup_complete')
431+
432+
# Copied from django.conrib.auth.views.LoginView (Branch: stable/1.11.x)
433+
# https://github.com/django/django/blob/58df8aa40fe88f753ba79e091a52f236246260b3/django/contrib/auth/views.py#L67
434+
def get_redirect_url(self):
435+
"""Return the user-originating redirect URL if it's safe."""
436+
redirect_to = self.request.POST.get(
437+
REDIRECT_FIELD_NAME,
438+
self.request.GET.get(REDIRECT_FIELD_NAME, '')
439+
)
440+
url_is_safe = url_has_allowed_host_and_scheme(
441+
url=redirect_to,
442+
allowed_hosts=self.get_success_url_allowed_hosts(),
443+
require_https=self.request.is_secure(),
444+
)
445+
return redirect_to if url_is_safe else ''
446+
420447
def get_method(self):
421448
method_data = self.storage.validated_step_data.get('method', {})
422449
method_key = method_data.get('method', None)
@@ -427,7 +454,7 @@ def get(self, request, *args, **kwargs):
427454
Start the setup wizard. Redirect if already enabled.
428455
"""
429456
if default_device(self.request.user):
430-
return redirect(self.success_url)
457+
return redirect(self.get_success_url())
431458
return super().get(request, *args, **kwargs)
432459

433460
def get_form(self, step=None, **kwargs):
@@ -495,7 +522,7 @@ def done(self, form_list, **kwargs):
495522
raise NotImplementedError("Unknown method '%s'" % method.code)
496523

497524
django_otp.login(self.request, device)
498-
return redirect(self.success_url)
525+
return redirect(self.get_success_url())
499526

500527
def get_form_kwargs(self, step=None):
501528
kwargs = {}
@@ -607,6 +634,11 @@ class SetupCompleteView(TemplateView):
607634
"""
608635
template_name = 'two_factor/core/setup_complete.html'
609636

637+
def get(self, request, *args, **kwargs):
638+
if request.session.get('next'):
639+
return redirect(request.session.get('next'))
640+
return super().get(request, *args, **kwargs)
641+
610642
def get_context_data(self):
611643
return {
612644
'phone_methods': get_available_phone_methods(),

0 commit comments

Comments
 (0)