From c24cedf772d2cf558a40a679939bd3a7addca27d Mon Sep 17 00:00:00 2001 From: Lindsay Date: Thu, 27 Aug 2020 10:13:55 +0200 Subject: [PATCH] Add the possibility for the user to change the double authenticaiton method he uses --- example/settings.py | 6 +- .../templates/two_factor/_wizard_actions.html | 15 + .../two_factor/core/backup_tokens.html | 28 ++ example/templates/two_factor/core/login.html | 52 +++ .../two_factor/core/otp_required.html | 20 ++ .../two_factor/core/phone_register.html | 24 ++ example/templates/two_factor/core/setup.html | 55 +++ .../two_factor/core/setup_complete.html | 24 ++ .../setup_reset_generator_or_yubikey.html | 28 ++ .../core/setup_reset_phone_or_generator.html | 49 +++ .../core/setup_reset_phone_or_yubikey.html | 45 +++ .../templates/two_factor/profile/disable.html | 14 + .../templates/two_factor/profile/profile.html | 93 +++++ ...test_views_change_double_authentication.py | 222 ++++++++++++ two_factor/forms.py | 30 +- two_factor/models.py | 18 + .../setup_reset_generator_or_yubikey.html | 28 ++ .../core/setup_reset_phone_or_generator.html | 49 +++ .../core/setup_reset_phone_or_yubikey.html | 45 +++ .../templates/two_factor/profile/profile.html | 30 ++ two_factor/urls.py | 19 +- two_factor/views/__init__.py | 4 +- two_factor/views/core.py | 320 +++++++++++++++--- two_factor/views/utils.py | 62 ++++ 24 files changed, 1221 insertions(+), 59 deletions(-) create mode 100644 example/templates/two_factor/_wizard_actions.html create mode 100644 example/templates/two_factor/core/backup_tokens.html create mode 100644 example/templates/two_factor/core/login.html create mode 100644 example/templates/two_factor/core/otp_required.html create mode 100644 example/templates/two_factor/core/phone_register.html create mode 100644 example/templates/two_factor/core/setup.html create mode 100644 example/templates/two_factor/core/setup_complete.html create mode 100644 example/templates/two_factor/core/setup_reset_generator_or_yubikey.html create mode 100644 example/templates/two_factor/core/setup_reset_phone_or_generator.html create mode 100644 example/templates/two_factor/core/setup_reset_phone_or_yubikey.html create mode 100644 example/templates/two_factor/profile/disable.html create mode 100644 example/templates/two_factor/profile/profile.html create mode 100644 tests/test_views_change_double_authentication.py create mode 100644 two_factor/templates/two_factor/core/setup_reset_generator_or_yubikey.html create mode 100644 two_factor/templates/two_factor/core/setup_reset_phone_or_generator.html create mode 100644 two_factor/templates/two_factor/core/setup_reset_phone_or_yubikey.html diff --git a/example/settings.py b/example/settings.py index c851431cc..e517810ac 100644 --- a/example/settings.py +++ b/example/settings.py @@ -52,7 +52,7 @@ }, ] -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'user_sessions', @@ -64,11 +64,9 @@ 'django_otp.plugins.otp_totp', 'two_factor', 'example', - 'debug_toolbar', 'bootstrapform' -) - +] LOGOUT_REDIRECT_URL = 'home' LOGIN_URL = 'two_factor:login' diff --git a/example/templates/two_factor/_wizard_actions.html b/example/templates/two_factor/_wizard_actions.html new file mode 100644 index 000000000..4abfa20c8 --- /dev/null +++ b/example/templates/two_factor/_wizard_actions.html @@ -0,0 +1,15 @@ +{% load i18n %} + +{% if cancel_url %} + {% trans "Cancel" %} +{% endif %} +{% if wizard.steps.prev %} + +{% else %} + +{% endif %} + diff --git a/example/templates/two_factor/core/backup_tokens.html b/example/templates/two_factor/core/backup_tokens.html new file mode 100644 index 000000000..3211df796 --- /dev/null +++ b/example/templates/two_factor/core/backup_tokens.html @@ -0,0 +1,28 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Backup Tokens" %}{% endblock %}

+

{% blocktrans trimmed %}Backup tokens can be used when your primary and backup + phone numbers aren't available. The backup tokens below can be used + for login verification. If you've used up all your backup tokens, you + can generate a new set of backup tokens. Only the backup tokens shown + below will be valid.{% endblocktrans %}

+ + {% if device.token_set.count %} + +

{% blocktrans %}Print these tokens and keep them somewhere safe.{% endblocktrans %}

+ {% else %} +

{% trans "You don't have any backup codes yet." %}

+ {% endif %} + +
{% csrf_token %}{{ form }} + {% trans "Back to Account Security" %} + +
+{% endblock %} diff --git a/example/templates/two_factor/core/login.html b/example/templates/two_factor/core/login.html new file mode 100644 index 000000000..607dc2ce0 --- /dev/null +++ b/example/templates/two_factor/core/login.html @@ -0,0 +1,52 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n two_factor %} + +{% block content %} +

{% block title %}{% trans "Login" %}{% endblock %}

+ + {% if wizard.steps.current == 'auth' %} +

{% blocktrans %}Enter your credentials.{% endblocktrans %}

+ {% elif wizard.steps.current == 'token' %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% else %} +

{% blocktrans trimmed %}Please enter the tokens generated by your token + generator.{% endblocktrans %}

+ {% endif %} + {% elif wizard.steps.current == 'backup' %} +

{% blocktrans trimmed %}Use this form for entering backup tokens for logging in. + These tokens have been generated for you to print and keep safe. Please + enter one of these backup tokens to login to your account.{% endblocktrans %}

+ {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% if other_devices %} +

{% trans "Or, alternatively, use one of your backup phones:" %}

+

+ {% for other in other_devices %} + + {% endfor %}

+ {% endif %} + {% if backup_tokens %} +

{% trans "As a last resort, you can use a backup token:" %}

+

+ +

+ {% endif %} + + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/core/otp_required.html b/example/templates/two_factor/core/otp_required.html new file mode 100644 index 000000000..7221a4a92 --- /dev/null +++ b/example/templates/two_factor/core/otp_required.html @@ -0,0 +1,20 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Permission Denied" %}{% endblock %}

+ +

{% blocktrans trimmed %}The page you requested, enforces users to verify using + two-factor authentication for security reasons. You need to enable these + security features in order to access this page.{% endblocktrans %}

+ +

{% blocktrans trimmed %}Two-factor authentication is not enabled for your + account. Enable two-factor authentication for enhanced account + security.{% endblocktrans %}

+

+ {% trans "Go back" %} + + {% trans "Enable Two-Factor Authentication" %} +

+{% endblock %} diff --git a/example/templates/two_factor/core/phone_register.html b/example/templates/two_factor/core/phone_register.html new file mode 100644 index 000000000..f471de559 --- /dev/null +++ b/example/templates/two_factor/core/phone_register.html @@ -0,0 +1,24 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Add Backup Phone" %}{% endblock %}

+ + {% if wizard.steps.current == 'setup' %} +

{% blocktrans trimmed %}You'll be adding a backup phone number to your + account. This number will be used if your primary method of + registration is not available.{% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} +

{% blocktrans trimmed %}We've sent a token to your phone number. Please + enter the token you've received.{% endblocktrans %}

+ {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/core/setup.html b/example/templates/two_factor/core/setup.html new file mode 100644 index 000000000..364398a07 --- /dev/null +++ b/example/templates/two_factor/core/setup.html @@ -0,0 +1,55 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} +{% block content %} +

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}

+ {% if wizard.steps.current == 'welcome' %} +

{% blocktrans trimmed %}You are about to take your account security to the + next level. Follow the steps in this wizard to enable two-factor + authentication.{% endblocktrans %}

+ {% elif wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+ {% elif wizard.steps.current == 'generator' %} +

{% blocktrans trimmed %}To start using a token generator, please use your + smartphone to scan the QR code below. For example, use Google + Authenticator. Then, enter the token generated by the app. + {% endblocktrans %}

+

QR Code

+ {% elif wizard.steps.current == 'sms' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + text messages on. This number will be validated in the next step. + {% endblocktrans %}

+ {% elif wizard.steps.current == 'call' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. + This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} + {% if challenge_succeeded %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% endif %} + {% else %} + + {% endif %} + {% elif wizard.steps.current == 'yubikey' %} +

{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a + token in the field below. Your YubiKey will be linked to your + account.{% endblocktrans %}

+ {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/core/setup_complete.html b/example/templates/two_factor/core/setup_complete.html new file mode 100644 index 000000000..c16e9835e --- /dev/null +++ b/example/templates/two_factor/core/setup_complete.html @@ -0,0 +1,24 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}

+ +

{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor + authentication.{% endblocktrans %}

+ + {% if not phone_methods %} +

{% trans "Back to Profile" %}

+ {% else %} +

{% blocktrans trimmed %}However, it might happen that you don't have access to + your primary token device. To enable account recovery, add a phone + number.{% endblocktrans %}

+ + {% trans "Back to Profile" %} +

{% trans "Add Phone Number" %}

+ {% endif %} + +{% endblock %} diff --git a/example/templates/two_factor/core/setup_reset_generator_or_yubikey.html b/example/templates/two_factor/core/setup_reset_generator_or_yubikey.html new file mode 100644 index 000000000..464f9e894 --- /dev/null +++ b/example/templates/two_factor/core/setup_reset_generator_or_yubikey.html @@ -0,0 +1,28 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+{% elif wizard.steps.current == 'generator' %} +

{% blocktrans trimmed %}To start using a token generator, please use your + smartphone to scan the QR code below. For example, use Google + Authenticator. Then, enter the token generated by the app. + {% endblocktrans %}

+

QR Code

+ {% elif wizard.steps.current == 'yubikey' %} +

{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a + token in the field below. Your YubiKey will be linked to your + account.{% endblocktrans %}

+{% endif %} +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/core/setup_reset_phone_or_generator.html b/example/templates/two_factor/core/setup_reset_phone_or_generator.html new file mode 100644 index 000000000..b9d6d268c --- /dev/null +++ b/example/templates/two_factor/core/setup_reset_phone_or_generator.html @@ -0,0 +1,49 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ + {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+ {% elif wizard.steps.current == 'generator' %} +

{% blocktrans trimmed %}To start using a token generator, please use your + smartphone to scan the QR code below. For example, use Google + Authenticator. Then, enter the token generated by the app. + {% endblocktrans %}

+

QR Code

+ {% elif wizard.steps.current == 'sms' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + text messages on. This number will be validated in the next step. + {% endblocktrans %}

+ {% elif wizard.steps.current == 'call' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. + This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} + {% if challenge_succeeded %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% endif %} + {% else %} + + {% endif %} + {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/core/setup_reset_phone_or_yubikey.html b/example/templates/two_factor/core/setup_reset_phone_or_yubikey.html new file mode 100644 index 000000000..ddde7187f --- /dev/null +++ b/example/templates/two_factor/core/setup_reset_phone_or_yubikey.html @@ -0,0 +1,45 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+ {% elif wizard.steps.current == 'sms' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + text messages on. This number will be validated in the next step. + {% endblocktrans %}

+ {% elif wizard.steps.current == 'call' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. + This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} + {% if challenge_succeeded %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% endif %} + {% else %} + + {% endif %} + {% elif wizard.steps.current == 'yubikey' %} +

{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a + token in the field below. Your YubiKey will be linked to your + account.{% endblocktrans %}

+ {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/example/templates/two_factor/profile/disable.html b/example/templates/two_factor/profile/disable.html new file mode 100644 index 000000000..249db4ab0 --- /dev/null +++ b/example/templates/two_factor/profile/disable.html @@ -0,0 +1,14 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}

+

{% blocktrans trimmed %}You are about to disable two-factor authentication. This + weakens your account security, are you sure?{% endblocktrans %}

+
+ {% csrf_token %} + {{ form }}
+ +
+{% endblock %} diff --git a/example/templates/two_factor/profile/profile.html b/example/templates/two_factor/profile/profile.html new file mode 100644 index 000000000..125d75377 --- /dev/null +++ b/example/templates/two_factor/profile/profile.html @@ -0,0 +1,93 @@ +{% extends "two_factor/_base.html" %} +{% load i18n two_factor %} + +{% block content %} +

{% block title %}{% trans "Account Security" %}{% endblock %}

+ + {% if default_device %} + {% if default_device_type == 'TOTPDevice' %} +

{% trans "Tokens will be generated by your token generator." %}

+ {% elif default_device_type == 'PhoneDevice' %} +

{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}

+ {% elif default_device_type == 'RemoteYubikeyDevice' %} +

{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}

+ {% endif %} + + {% if available_phone_methods %} +

{% trans "Backup Phone Numbers" %}

+

{% blocktrans trimmed %}If your primary method is not available, we are able to + send backup tokens to the phone numbers listed below.{% endblocktrans %}

+ +

{% trans "Add Phone Number" %}

+ {% endif %} + +

{% trans "Backup Tokens" %}

+

+ {% blocktrans trimmed %}If you don't have any device with you, you can access + your account using backup tokens.{% endblocktrans %} + {% blocktrans trimmed count counter=backup_tokens %} + You have only one backup token remaining. + {% plural %} + You have {{ counter }} backup tokens remaining. + {% endblocktrans %} +

+

{% trans "Show Codes" %}

+ +

{% trans "Disable Two-Factor Authentication" %}

+

{% blocktrans trimmed %}However we strongly discourage you to do so, you can + also disable two-factor authentication for your account.{% endblocktrans %}

+

+ {% trans "Disable Two-Factor Authentication" %}

+ + +

{% trans "Change Two-Factor Authentication method" %}

+ + {% if default_device_type == 'TOTPDevice' %} +

{% blocktrans trimmed %} + You choose to get the 6-digits authentication code using Google Authenticator. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% elif default_device_type == 'PhoneDevice' %} +

+ {% blocktrans trimmed %} + You choose to get the 6-digits authentication code using SMS. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% elif default_device_type == 'YubikeyDevice' %} +

+ {% blocktrans trimmed %} + You choose to get the tokens through your YubiKey. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% endif %} + + + {% else %} +

{% blocktrans trimmed %}Two-factor authentication is not enabled for your + account. Enable two-factor authentication for enhanced account + security.{% endblocktrans %}

+

+ {% trans "Enable Two-Factor Authentication" %} +

+ {% endif %} +{% endblock %} diff --git a/tests/test_views_change_double_authentication.py b/tests/test_views_change_double_authentication.py new file mode 100644 index 000000000..69b3bf5d0 --- /dev/null +++ b/tests/test_views_change_double_authentication.py @@ -0,0 +1,222 @@ +from unittest import mock + +from binascii import unhexlify +from django.test import TestCase +from django.conf import settings +from django.shortcuts import resolve_url +from django.test.utils import modify_settings, override_settings +from django.urls import reverse +from django_otp.oath import totp + +from .utils import UserMixin +from two_factor.models import random_hex_str + + +class SetupTest(UserMixin, TestCase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.login_user() + + def _post_phone_or_yubikey(self, data): + return self.client.post(reverse('two_factor:setup_reset_phone_or_yubikey'), data=data) + + def _post_generator_or_yubikey(self, data): + return self.client.post(reverse('two_factor:setup_reset_generator_or_yubikey'), data=data) + + def _post_phone_or_generator(self, data): + return self.client.post(reverse('two_factor:setup_reset_phone_or_generator'), data=data) + + @mock.patch('two_factor.gateways.fake.Fake') + @override_settings(TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake') + def test_setup_first_generator_switch_to_phone_sms(self, mock_signal): + # First assume that Generator was used + self.user.totpdevice_set.create(name='default', + key=random_hex_str()) + + totp_device = self.user.totpdevice_set.all() + self.assertEqual(len(totp_device), 1) + self.assertEqual(totp_device[0].name, 'default') + + # Go To reset and check that we can choose another method here + response = self._post_phone_or_yubikey( + data={'reset_setup_phone_or_yubikey_view-current_step': 'method'}) + self.assertContains(response, 'Method:') + + # Go for phone method -- SMS + response = self._post_phone_or_yubikey( + data={'reset_setup_phone_or_yubikey_view-current_step': 'method', + 'method-method': 'sms'}) + self.assertContains(response, 'Number:') + + response = self._post_phone_or_yubikey( + data={'reset_setup_phone_or_yubikey_view-current_step': 'sms', + 'sms-number': '+31101234567'}) + self.assertContains(response, 'Token:') + self.assertContains(response, 'We sent you a text message') + + # assert that the token was send to the gateway + self.assertEqual( + mock_signal.return_value.method_calls, + [mock.call.send_sms(device=mock.ANY, token=mock.ANY)] + ) + # assert that tokens are verified + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'validation', + 'validation-token': '666'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + # submitting correct token should finish the setup + token = mock_signal.return_value.send_sms.call_args[1]['token'] + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'validation', + 'validation-token': token}) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + + phones = self.user.phonedevice_set.all() + self.assertEqual(len(phones), 1) + self.assertEqual(phones[0].name, 'default') + self.assertEqual(phones[0].number.as_e164, '+31101234567') + self.assertEqual(phones[0].method, 'sms') + + # Now totpdevice should be deleted + totp_device = self.user.totpdevice_set.all() + self.assertEqual(len(totp_device), 0) + + @mock.patch('two_factor.gateways.fake.Fake') + @override_settings(TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake') + def test_setup_first_generator_switch_to_phone_call(self, mock_signal): + # First assume that Generator was used + self.user.totpdevice_set.create(name='default', + key=random_hex_str()) + + totp_device = self.user.totpdevice_set.all() + self.assertEqual(len(totp_device), 1) + self.assertEqual(totp_device[0].name, 'default') + + # Go To reset and check that we can choose another method here + response = self._post_phone_or_yubikey( + data={'reset_setup_phone_or_yubikey_view-current_step': 'method'}) + self.assertContains(response, 'Method:') + + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'method', + 'method-method': 'call'}) + self.assertContains(response, 'Number:') + + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'call', + 'call-number': '+31101234567'}) + self.assertContains(response, 'Token:') + self.assertContains(response, 'We are calling your phone right now') + + # assert that the token was send to the gateway + self.assertEqual( + mock_signal.return_value.method_calls, + [mock.call.make_call(device=mock.ANY, token=mock.ANY)] + ) + + # assert that tokens are verified + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'validation', + 'validation-token': '666'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + # submitting correct token should finish the setup + token = mock_signal.return_value.make_call.call_args[1]['token'] + response = self._post_phone_or_yubikey(data={'reset_setup_phone_or_yubikey_view-current_step': 'validation', + 'validation-token': token}) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + + phones = self.user.phonedevice_set.all() + self.assertEqual(len(phones), 1) + self.assertEqual(phones[0].name, 'default') + self.assertEqual(phones[0].number.as_e164, '+31101234567') + self.assertEqual(phones[0].method, 'call') + + # Now totpdevice should be deleted + totp_device = self.user.totpdevice_set.all() + self.assertEqual(len(totp_device), 0) + + @modify_settings(INSTALLED_APPS={ + 'remove': ['otp_yubikey'], + }) + def test_setup_first_phone_call_switch_to_generator(self): + # First assume that phone call was used + self.user.phonedevice_set.create(name='default', number='+12024561111', method='call') + + phone_device = self.user.phonedevice_set.all() + self.assertEqual(len(phone_device), 1) + self.assertEqual(phone_device[0].name, 'default') + + # Go To reset and check that we can choose another method here + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'method'}) + self.assertContains(response, 'Method:') + + # Go for generator + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'method', + 'method-method': 'generator'}) + + self.assertContains(response, 'Token:') + session = self.client.session + self.assertIn('django_two_factor-qr_secret_key', session.keys()) + + # assert that tokens are verified + response = self._post_generator_or_yubikey(data={'reset_setup_generator_or_yubikey_view-current_step': 'generator', + 'generator-token': '123456'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + key = response.context_data['keys'].get('generator') + bin_key = unhexlify(key.encode()) + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'generator', + 'generator-token': totp(bin_key)}) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + self.assertEqual(1, self.user.totpdevice_set.count()) + + # Now phonedevice should be deleted + phone_device = self.user.phonedevice_set.all() + self.assertEqual(len(phone_device), 0) + + @modify_settings(INSTALLED_APPS={ + 'remove': ['otp_yubikey'], + }) + def test_setup_first_phone_sms_switch_to_generator(self): + # First assume that phone sms was used + self.user.phonedevice_set.create(name='default', number='+12024561111', method='sms') + + phone_device = self.user.phonedevice_set.all() + self.assertEqual(len(phone_device), 1) + self.assertEqual(phone_device[0].name, 'default') + + # Go To reset and check that we can choose another method here + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'method'}) + self.assertContains(response, 'Method:') + + # Go for generator + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'method', + 'method-method': 'generator'}) + + self.assertContains(response, 'Token:') + session = self.client.session + self.assertIn('django_two_factor-qr_secret_key', session.keys()) + + # assert that tokens are verified + response = self._post_generator_or_yubikey(data={'reset_setup_generator_or_yubikey_view-current_step': 'generator', + 'generator-token': '123456'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + key = response.context_data['keys'].get('generator') + bin_key = unhexlify(key.encode()) + response = self._post_generator_or_yubikey( + data={'reset_setup_generator_or_yubikey_view-current_step': 'generator', + 'generator-token': totp(bin_key)}) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + self.assertEqual(1, self.user.totpdevice_set.count()) + + # Now phonedevice should be deleted + phone_device = self.user.phonedevice_set.all() + self.assertEqual(len(phone_device), 0) diff --git a/two_factor/forms.py b/two_factor/forms.py index ba26779c9..f28591248 100644 --- a/two_factor/forms.py +++ b/two_factor/forms.py @@ -10,7 +10,8 @@ from django_otp.plugins.otp_totp.models import TOTPDevice from .models import ( - PhoneDevice, get_available_methods, get_available_phone_methods, + PhoneDevice, get_available_methods, get_available_phone_methods, get_generator_or_phone_methods, + get_generator_or_yubikey_methods, get_phone_or_yubikey_methods, ) from .utils import totp_digits from .validators import validate_international_phonenumber @@ -31,6 +32,33 @@ def __init__(self, **kwargs): self.fields['method'].choices = get_available_methods() +class ResetPhoneOrYubikeyMethodForm(forms.Form): + method = forms.ChoiceField(label=_("Method"), + widget=forms.RadioSelect) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fields['method'].choices = get_phone_or_yubikey_methods() + + +class ResetGeneratorOrYubikeyMethodForm(forms.Form): + method = forms.ChoiceField(label=_("Method"), + widget=forms.RadioSelect) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fields['method'].choices = get_generator_or_yubikey_methods() + + +class ResetPhoneOrGeneratorMethodForm(forms.Form): + method = forms.ChoiceField(label=_("Method"), + widget=forms.RadioSelect) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fields['method'].choices = get_generator_or_phone_methods() + + class PhoneNumberMethodForm(ModelForm): number = forms.CharField(label=_("Phone Number"), validators=[validate_international_phonenumber]) diff --git a/two_factor/models.py b/two_factor/models.py index 8080d45d7..49bd55e1f 100644 --- a/two_factor/models.py +++ b/two_factor/models.py @@ -49,6 +49,24 @@ def get_available_methods(): return methods +def get_phone_or_yubikey_methods(): + methods = get_available_phone_methods() + methods.extend(get_available_yubikey_methods()) + return methods + + +def get_generator_or_yubikey_methods(): + methods = [('generator', _('Token generator'))] + methods.extend(get_available_yubikey_methods()) + return methods + + +def get_generator_or_phone_methods(): + methods = [('generator', _('Token generator'))] + methods.extend(get_available_phone_methods()) + return methods + + def key_validator(*args, **kwargs): """Wraps hex_validator generator, to keep makemigrations happy.""" return hex_validator()(*args, **kwargs) diff --git a/two_factor/templates/two_factor/core/setup_reset_generator_or_yubikey.html b/two_factor/templates/two_factor/core/setup_reset_generator_or_yubikey.html new file mode 100644 index 000000000..464f9e894 --- /dev/null +++ b/two_factor/templates/two_factor/core/setup_reset_generator_or_yubikey.html @@ -0,0 +1,28 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+{% elif wizard.steps.current == 'generator' %} +

{% blocktrans trimmed %}To start using a token generator, please use your + smartphone to scan the QR code below. For example, use Google + Authenticator. Then, enter the token generated by the app. + {% endblocktrans %}

+

QR Code

+ {% elif wizard.steps.current == 'yubikey' %} +

{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a + token in the field below. Your YubiKey will be linked to your + account.{% endblocktrans %}

+{% endif %} +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/two_factor/templates/two_factor/core/setup_reset_phone_or_generator.html b/two_factor/templates/two_factor/core/setup_reset_phone_or_generator.html new file mode 100644 index 000000000..b9d6d268c --- /dev/null +++ b/two_factor/templates/two_factor/core/setup_reset_phone_or_generator.html @@ -0,0 +1,49 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ + {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+ {% elif wizard.steps.current == 'generator' %} +

{% blocktrans trimmed %}To start using a token generator, please use your + smartphone to scan the QR code below. For example, use Google + Authenticator. Then, enter the token generated by the app. + {% endblocktrans %}

+

QR Code

+ {% elif wizard.steps.current == 'sms' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + text messages on. This number will be validated in the next step. + {% endblocktrans %}

+ {% elif wizard.steps.current == 'call' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. + This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} + {% if challenge_succeeded %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% endif %} + {% else %} + + {% endif %} + {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/two_factor/templates/two_factor/core/setup_reset_phone_or_yubikey.html b/two_factor/templates/two_factor/core/setup_reset_phone_or_yubikey.html new file mode 100644 index 000000000..ddde7187f --- /dev/null +++ b/two_factor/templates/two_factor/core/setup_reset_phone_or_yubikey.html @@ -0,0 +1,45 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} +{% block content %} +

{% block title %}{% trans "Change Two-Factor Authentication method" %}{% endblock %}

+ {% if wizard.steps.current == 'method' %} +

{% blocktrans trimmed %}Please select which authentication method you would + like to use.{% endblocktrans %}

+ {% elif wizard.steps.current == 'sms' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + text messages on. This number will be validated in the next step. + {% endblocktrans %}

+ {% elif wizard.steps.current == 'call' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. + This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'validation' %} + {% if challenge_succeeded %} + {% if device.method == 'call' %} +

{% blocktrans trimmed %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}

+ {% elif device.method == 'sms' %} +

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}

+ {% endif %} + {% else %} + + {% endif %} + {% elif wizard.steps.current == 'yubikey' %} +

{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a + token in the field below. Your YubiKey will be linked to your + account.{% endblocktrans %}

+ {% endif %} + +
{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} +
+ + {% include "two_factor/_wizard_actions.html" %} +
+{% endblock %} diff --git a/two_factor/templates/two_factor/profile/profile.html b/two_factor/templates/two_factor/profile/profile.html index 265e046e2..125d75377 100644 --- a/two_factor/templates/two_factor/profile/profile.html +++ b/two_factor/templates/two_factor/profile/profile.html @@ -52,6 +52,36 @@

{% trans "Disable Two-Factor Authentication" %}

also disable two-factor authentication for your account.{% endblocktrans %}

{% trans "Disable Two-Factor Authentication" %}

+ + +

{% trans "Change Two-Factor Authentication method" %}

+ + {% if default_device_type == 'TOTPDevice' %} +

{% blocktrans trimmed %} + You choose to get the 6-digits authentication code using Google Authenticator. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% elif default_device_type == 'PhoneDevice' %} +

+ {% blocktrans trimmed %} + You choose to get the 6-digits authentication code using SMS. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% elif default_device_type == 'YubikeyDevice' %} +

+ {% blocktrans trimmed %} + You choose to get the tokens through your YubiKey. + If you want to change this and use another method, please click on the link below.{% endblocktrans %} +

+

+ {% trans "Change Two-Factor Authentication method" %}

+ {% endif %} + + {% else %}

{% blocktrans trimmed %}Two-factor authentication is not enabled for your account. Enable two-factor authentication for enhanced account diff --git a/two_factor/urls.py b/two_factor/urls.py index 8482978dd..0135a5222 100644 --- a/two_factor/urls.py +++ b/two_factor/urls.py @@ -2,7 +2,8 @@ from two_factor.views import ( BackupTokensView, DisableView, LoginView, PhoneDeleteView, PhoneSetupView, - ProfileView, QRGeneratorView, SetupCompleteView, SetupView, + ProfileView, QRGeneratorView, ResetSetupGeneratorOrYubikeyView, ResetSetupPhoneOrGeneratorView, + ResetSetupPhoneOrYubikeyView, SetupCompleteView, SetupView, ) core = [ @@ -26,6 +27,21 @@ SetupCompleteView.as_view(), name='setup_complete', ), + path( + 'account/two_factor/setup/reset/1/', + view=ResetSetupGeneratorOrYubikeyView.as_view(), + name='setup_reset_generator_or_yubikey', + ), + path( + 'account/two_factor/setup/reset/2/', + view=ResetSetupPhoneOrYubikeyView.as_view(), + name='setup_reset_phone_or_yubikey', + ), + path( + 'account/two_factor/setup/reset/3/', + view=ResetSetupPhoneOrGeneratorView.as_view(), + name='setup_reset_phone_or_generator', + ), path( 'account/two_factor/backup/tokens/', BackupTokensView.as_view(), @@ -55,5 +71,4 @@ name='disable', ), ] - urlpatterns = (core + profile, 'two_factor') diff --git a/two_factor/views/__init__.py b/two_factor/views/__init__.py index c2abab1e4..c58a185d7 100644 --- a/two_factor/views/__init__.py +++ b/two_factor/views/__init__.py @@ -1,6 +1,8 @@ from .core import ( BackupTokensView, LoginView, PhoneDeleteView, PhoneSetupView, - QRGeneratorView, SetupCompleteView, SetupView, + QRGeneratorView, ResetSetupGeneratorOrYubikeyView, ResetSetupPhoneOrGeneratorView, + ResetSetupPhoneOrYubikeyView, SetupCompleteView, SetupView ) from .mixins import OTPRequiredMixin from .profile import DisableView, ProfileView + diff --git a/two_factor/views/core.py b/two_factor/views/core.py index c7ce0defc..75ef0a178 100644 --- a/two_factor/views/core.py +++ b/two_factor/views/core.py @@ -39,14 +39,14 @@ from ..forms import ( AuthenticationTokenForm, BackupTokenForm, DeviceValidationForm, MethodForm, - PhoneNumberForm, PhoneNumberMethodForm, TOTPDeviceForm, YubiKeyDeviceForm, + PhoneNumberForm, PhoneNumberMethodForm, ResetGeneratorOrYubikeyMethodForm, ResetPhoneOrGeneratorMethodForm, + ResetPhoneOrYubikeyMethodForm, TOTPDeviceForm, YubiKeyDeviceForm ) from ..models import PhoneDevice, get_available_phone_methods from ..utils import backup_phones, default_device, get_otpauth_url -from .utils import ( +from .utils import (CustomSessionWizardView, IdempotentSessionWizardView, class_view_decorator, - get_remember_device_cookie, validate_remember_device_cookie, -) + get_remember_device_cookie, validate_remember_device_cookie) try: from otp_yubikey.models import ValidationService, RemoteYubikeyDevice @@ -381,7 +381,7 @@ def dispatch(self, request, *args, **kwargs): @class_view_decorator(never_cache) @class_view_decorator(login_required) -class SetupView(IdempotentSessionWizardView): +class SetupView(IdempotentSessionWizardView, CustomSessionWizardView): """ View for handling OTP setup using a wizard. @@ -418,10 +418,6 @@ class SetupView(IdempotentSessionWizardView): 'yubikey': False, } - def get_method(self): - method_data = self.storage.validated_step_data.get('method', {}) - return method_data.get('method', None) - def get(self, request, *args, **kwargs): """ Start the setup wizard. Redirect if already enabled. @@ -442,20 +438,6 @@ def get_form_list(self): self.storage.validated_step_data['method'] = {'method': method_key} return form_list - def render_next_step(self, form, **kwargs): - """ - In the validation step, ask the device to generate a challenge. - """ - next_step = self.steps.next - if next_step == 'validation': - try: - self.get_device().generate_challenge() - kwargs["challenge_succeeded"] = True - except Exception: - logger.exception("Could not generate challenge") - kwargs["challenge_succeeded"] = False - return super().render_next_step(form, **kwargs) - def done(self, form_list, **kwargs): """ Finish the wizard. Save all forms and redirect. @@ -465,7 +447,6 @@ def done(self, form_list, **kwargs): del self.request.session[self.session_key_name] except KeyError: pass - # TOTPDeviceForm if self.get_method() == 'generator': form = [form for form in form_list if isinstance(form, TOTPDeviceForm)][0] @@ -528,39 +509,12 @@ def get_device(self, **kwargs): raise KeyError("Multiple ValidationService found with name 'default'") return RemoteYubikeyDevice(**kwargs) - def get_key(self, step): - self.storage.extra_data.setdefault('keys', {}) - if step in self.storage.extra_data['keys']: - return self.storage.extra_data['keys'].get(step) - key = random_hex_str(20) - self.storage.extra_data['keys'][step] = key - return key - - def get_context_data(self, form, **kwargs): - context = super().get_context_data(form, **kwargs) - if self.steps.current == 'generator': - key = self.get_key('generator') - rawkey = unhexlify(key.encode('ascii')) - b32key = b32encode(rawkey).decode('utf-8') - self.request.session[self.session_key_name] = b32key - context.update({ - 'QR_URL': reverse(self.qrcode_url) - }) - elif self.steps.current == 'validation': - context['device'] = self.get_device() - context['cancel_url'] = resolve_url(settings.LOGIN_REDIRECT_URL) - return context - def process_step(self, form): if hasattr(form, 'metadata'): self.storage.extra_data.setdefault('forms', {}) self.storage.extra_data['forms'][self.steps.current] = form.metadata return super().process_step(form) - def get_form_metadata(self, step): - self.storage.extra_data.setdefault('forms', {}) - return self.storage.extra_data['forms'].get(step, None) - @class_view_decorator(never_cache) @class_view_decorator(otp_required) @@ -700,6 +654,270 @@ def get_context_data(self): } +@class_view_decorator(never_cache) +@class_view_decorator(login_required) +class ResetSetupGeneratorOrYubikeyView(IdempotentSessionWizardView, CustomSessionWizardView): + """ + View for changing the two-factor authentication method from phone number to token generator or yubikey. + """ + template_name = 'two_factor/core/setup_reset_generator_or_yubikey.html' + success_url = settings.LOGIN_REDIRECT_URL + qrcode_url = 'two_factor:qr' + session_key_name = 'django_two_factor-qr_secret_key' + + form_list = ( + ('method', ResetGeneratorOrYubikeyMethodForm), + ('generator', TOTPDeviceForm), + ('yubikey', YubiKeyDeviceForm) + ) + + condition_dict = { + 'generator': lambda self: self.get_method() == 'generator', + 'yubikey': lambda self: self.get_method() == 'yubikey', + } + idempotent_dict = { + 'yubikey': False, + } + + def done(self, form_list, **kwargs): + """ + Finish the wizard. Save all forms and redirect. + """ + # Remove secret key used for QR code generation + self.delete_previous_device() + + form = [form for form in form_list if isinstance(form, TOTPDeviceForm)][0] + device = form.save() + + django_otp.login(self.request, device) + return redirect(self.success_url) + + def get_form_kwargs(self, step=None): + kwargs = {} + if step == 'generator': + kwargs.update({ + 'key': self.get_key(step), + 'user': self.request.user, + }) + if step == 'yubikey': + kwargs.update({ + 'device': self.get_device() + }) + metadata = self.get_form_metadata(step) + if metadata: + kwargs.update({ + 'metadata': metadata, + }) + return kwargs + + def get_device(self, **kwargs): + """ + Uses the data from the setup step and generated key to recreate device. + + Only used for call / sms -- generator uses other procedure. + """ + method = self.get_method() + kwargs = kwargs or {} + kwargs['name'] = 'default' + kwargs['user'] = self.request.user + + if method == 'yubikey': + kwargs['public_id'] = self.storage.validated_step_data\ + .get('yubikey', {}).get('token', '')[:-32] + try: + kwargs['service'] = ValidationService.objects.get(name='default') + except ValidationService.DoesNotExist: + raise KeyError("No ValidationService found with name 'default'") + except ValidationService.MultipleObjectsReturned: + raise KeyError("Multiple ValidationService found with name 'default'") + return RemoteYubikeyDevice(**kwargs) + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form, **kwargs) + if self.steps.current == 'generator': + key = self.get_key('generator') + rawkey = unhexlify(key.encode('ascii')) + b32key = b32encode(rawkey).decode('utf-8') + self.request.session[self.session_key_name] = b32key + context.update({ + 'QR_URL': reverse(self.qrcode_url) + }) + context['cancel_url'] = resolve_url(settings.LOGIN_REDIRECT_URL) + return context + + +@class_view_decorator(never_cache) +@class_view_decorator(login_required) +class ResetSetupPhoneOrYubikeyView(IdempotentSessionWizardView, CustomSessionWizardView): + """ + View for changing the two-factor authentication method from token generator to phone number or yubikey. + """ + template_name = 'two_factor/core/setup_reset_phone_or_yubikey.html' + success_url = settings.LOGIN_REDIRECT_URL + + form_list = ( + ('method', ResetPhoneOrYubikeyMethodForm), + ('sms', PhoneNumberForm), + ('call', PhoneNumberForm), + ('validation', DeviceValidationForm), + ('yubikey', YubiKeyDeviceForm), + ) + condition_dict = { + 'call': lambda self: self.get_method() == 'call', + 'sms': lambda self: self.get_method() == 'sms', + 'validation': lambda self: self.get_method() in ('sms', 'call'), + 'yubikey': lambda self: self.get_method() == 'yubikey', + } + idempotent_dict = { + 'yubikey': False, + } + key_name = 'key' + + def done(self, form_list, **kwargs): + """ + Finish the wizard. Save all forms and redirect. + """ + self.delete_previous_device() + + device = self.get_device() + device.save() + + django_otp.login(self.request, device) + return redirect(self.success_url) + + def get_form_kwargs(self, step=None): + kwargs = {} + if step in ('validation', 'yubikey'): + kwargs.update({ + 'device': self.get_device() + }) + metadata = self.get_form_metadata(step) + if metadata: + kwargs.update({ + 'metadata': metadata, + }) + return kwargs + + def get_device(self, **kwargs): + """ + Uses the data from the setup step and generated key to recreate device. + + Only used for call / sms -- generator uses other procedure. + """ + method = self.get_method() + kwargs = kwargs or {} + kwargs['name'] = 'default' + kwargs['user'] = self.request.user + + if method in ('call', 'sms'): + kwargs['method'] = method + kwargs['number'] = self.storage.validated_step_data\ + .get(method, {}).get('number') + return PhoneDevice(key=self.get_key(method), **kwargs) + + if method == 'yubikey': + kwargs['public_id'] = self.storage.validated_step_data\ + .get('yubikey', {}).get('token', '')[:-32] + try: + kwargs['service'] = ValidationService.objects.get(name='default') + except ValidationService.DoesNotExist: + raise KeyError("No ValidationService found with name 'default'") + except ValidationService.MultipleObjectsReturned: + raise KeyError("Multiple ValidationService found with name 'default'") + return RemoteYubikeyDevice(**kwargs) + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form, **kwargs) + if self.steps.current == 'validation': + context['device'] = self.get_device() + context['cancel_url'] = resolve_url(settings.LOGIN_REDIRECT_URL) + return context + + +class ResetSetupPhoneOrGeneratorView(IdempotentSessionWizardView, CustomSessionWizardView): + """ + View for changing the two-factor authentication method from yubikey to phone number or phone. + """ + + success_url = settings.LOGIN_REDIRECT_URL + + template_name = 'two_factor/core/setup_reset_phone_or_generator.html' + form_list = ( + ('method', ResetPhoneOrGeneratorMethodForm), + ('generator', TOTPDeviceForm), + ('sms', PhoneNumberForm), + ('call', PhoneNumberForm), + ('validation', DeviceValidationForm), + ) + condition_dict = { + 'generator': lambda self: self.get_method() == 'generator', + 'call': lambda self: self.get_method() == 'call', + 'sms': lambda self: self.get_method() == 'sms', + 'validation': lambda self: self.get_method() in ('sms', 'call'), + } + + def done(self, form_list, **kwargs): + """ + Finish the wizard. Save all forms and redirect. + """ + self.delete_previous_device() + # Remove secret key used for QR code generation + try: + del self.request.session[self.session_key_name] + except KeyError: + pass + # TOTPDeviceForm + if self.get_method() == 'generator': + form = [form for form in form_list if isinstance(form, TOTPDeviceForm)][0] + device = form.save() + + # PhoneNumberForm + elif self.get_method() in ('call', 'sms'): + device = self.get_device() + device.save() + + else: + raise NotImplementedError("Unknown method '%s'" % self.get_method()) + + django_otp.login(self.request, device) + return redirect(self.success_url) + + def get_form_kwargs(self, step=None): + kwargs = {} + if step == 'generator': + kwargs.update({ + 'key': self.get_key(step), + 'user': self.request.user, + }) + if step == 'validation': + kwargs.update({ + 'device': self.get_device() + }) + metadata = self.get_form_metadata(step) + if metadata: + kwargs.update({ + 'metadata': metadata, + }) + return kwargs + + def get_device(self, **kwargs): + """ + Uses the data from the setup step and generated key to recreate device. + + Only used for call / sms -- generator uses other procedure. + """ + method = self.get_method() + kwargs = kwargs or {} + kwargs['name'] = 'default' + kwargs['user'] = self.request.user + + if method in ('call', 'sms'): + kwargs['method'] = method + kwargs['number'] = self.storage.validated_step_data\ + .get(method, {}).get('number') + return PhoneDevice(key=self.get_key(method), **kwargs) + + @class_view_decorator(never_cache) @class_view_decorator(login_required) class QRGeneratorView(View): diff --git a/two_factor/views/utils.py b/two_factor/views/utils.py index ad3b92bfa..fafa34b05 100644 --- a/two_factor/views/utils.py +++ b/two_factor/views/utils.py @@ -4,10 +4,14 @@ import logging import time +from base64 import b32encode +from binascii import unhexlify + from django.conf import settings from django.contrib.auth import load_backend from django.core.exceptions import SuspiciousOperation from django.core.signing import BadSignature, SignatureExpired +from django_otp import devices_for_user, user_has_device from django.utils import baseconv from django.utils.decorators import method_decorator from django.utils.encoding import force_bytes @@ -15,6 +19,10 @@ from formtools.wizard.forms import ManagementForm from formtools.wizard.storage.session import SessionStorage from formtools.wizard.views import SessionWizardView +from django.shortcuts import resolve_url +from django.urls import reverse + +from two_factor.models import random_hex_str logger = logging.getLogger(__name__) @@ -81,6 +89,13 @@ class IdempotentSessionWizardView(SessionWizardView): storage_name = 'two_factor.views.utils.ExtraSessionStorage' idempotent_dict = {} + def delete_previous_device(self): + # Delete the previous device associated to the user if you want to change device + if user_has_device(self.request.user): + devices = devices_for_user(self.request.user) + for current_device in devices: + current_device.delete() + def is_step_visible(self, step): """ Returns whether the given `step` should be included in the wizard; it @@ -219,6 +234,53 @@ def render_done(self, form, **kwargs): return done_response +class CustomSessionWizardView(SessionWizardView): + def get_method(self): + method_data = self.storage.validated_step_data.get('method', {}) + return method_data.get('method', None) + + def render_next_step(self, form, **kwargs): + """ + In the validation step, ask the device to generate a challenge. + """ + next_step = self.steps.next + if next_step == 'validation': + try: + self.get_device().generate_challenge() + kwargs["challenge_succeeded"] = True + except Exception: + logger.exception("Could not generate challenge") + kwargs["challenge_succeeded"] = False + return super().render_next_step(form, **kwargs) + + def get_key(self, step): + self.storage.extra_data.setdefault('keys', {}) + if step in self.storage.extra_data['keys']: + return self.storage.extra_data['keys'].get(step) + key = random_hex_str(20) + self.storage.extra_data['keys'][step] = key + return key + + def get_form_metadata(self, step): + self.storage.extra_data.setdefault('forms', {}) + return self.storage.extra_data['forms'].get(step, None) + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form, **kwargs) + if self.steps.current == 'generator': + key = self.get_key('generator') + rawkey = unhexlify(key.encode('ascii')) + b32key = b32encode(rawkey).decode('utf-8') + self.request.session[self.session_key_name] = b32key + context.update({ + 'QR_URL': reverse(self.qrcode_url) + }) + elif self.steps.current == 'validation': + context['device'] = self.get_device() + context['cancel_url'] = resolve_url(settings.LOGIN_REDIRECT_URL) + return context + + def class_view_decorator(function_decorator): """ Converts a function based decorator into a class based decorator usable