diff --git a/tests/templates/email_with_context.txt b/tests/templates/email_with_context.txt new file mode 100644 index 000000000..9659a15a1 --- /dev/null +++ b/tests/templates/email_with_context.txt @@ -0,0 +1,2 @@ +{{ token }} +Context: {{ test }} \ No newline at end of file diff --git a/tests/test_email.py b/tests/test_email.py index cd21761b9..a854ef53e 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -177,6 +177,24 @@ def test_login(self, mock_signal): mock_signal.assert_called_with(sender=mock.ANY, request=mock.ANY, user=self.user, device=device) + @override_settings(OTP_EMAIL_THROTTLE_FACTOR=0) + @override_settings(OTP_EMAIL_BODY_TEMPLATE_PATH="email_with_context.txt") + def test_login_with_context(self): + self.user.emaildevice_set.create(name="default", email="bouke@example.com") + response = self.client.post(reverse("custom-device-context-login"), + {"auth-username": "bouke@example.com", + "auth-password": "secret", + "login_view_with_context-current_step": "auth"}) + + self.assertContains(response, "Token:") + # Test that one message has been sent and that it includes + # the string passed in as context. Rest of login procedure + # is not tested, as it is already tested by test_login. + self.assertEqual(len(mail.outbox), 1) + msg = mail.outbox.pop(0) + self.assertIn("OTP token", msg.subject) + self.assertIn("hello, test", msg.body) + def test_device_without_email(self): self.user.emaildevice_set.create(name="default") response = self.client.get(reverse("two_factor:profile")) diff --git a/tests/urls.py b/tests/urls.py index 0b02429fb..6f8fcdff4 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -5,7 +5,7 @@ from two_factor.urls import urlpatterns as tf_urls from two_factor.views import LoginView, SetupView -from .views import SecureView, plain_view +from .views import LoginViewWithContext, SecureView, plain_view urlpatterns = [ path( @@ -37,6 +37,11 @@ ), name='custom-redirect-authenticated-user-login', ), + path( + 'account/custom-device-context-login/', + LoginViewWithContext.as_view(), + name='custom-device-context-login', + ), path( 'account/setup-backup-tokens-redirect/', SetupView.as_view(success_url='two_factor:backup_tokens'), diff --git a/tests/views.py b/tests/views.py index 32d276247..77580b85e 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,13 +1,24 @@ from django.http import HttpResponse from django.views.generic import TemplateView -from two_factor.views import OTPRequiredMixin +from two_factor.views import LoginView, OTPRequiredMixin class SecureView(OTPRequiredMixin, TemplateView): template_name = 'secure.html' +class LoginViewWithContext(LoginView): + + def post(self, *args, **kwargs): + return super().post(*args, **kwargs) + + def get_device_context_data(self, **kwargs): + return super().get_device_context_data(**kwargs) | { + "test": "hello, test", + } + + def plain_view(request): """ Non-class based view """ return HttpResponse('plain') diff --git a/two_factor/views/core.py b/two_factor/views/core.py index e23d1b6bd..768726906 100644 --- a/two_factor/views/core.py +++ b/two_factor/views/core.py @@ -39,7 +39,7 @@ from two_factor import signals from two_factor.plugins.registry import MethodNotFoundError, registry from two_factor.utils import totp_digits -from two_factor.views.mixins import OTPRequiredMixin +from two_factor.views.mixins import DeviceContextDataMixin, OTPRequiredMixin from ..forms import ( AuthenticationTokenForm, BackupTokenForm, DeviceValidationForm, MethodForm, @@ -72,7 +72,7 @@ def login_not_required(view_func): [login_not_required, sensitive_post_parameters(), csrf_protect, never_cache], name='dispatch' ) -class LoginView(RedirectURLMixin, IdempotentSessionWizardView): +class LoginView(DeviceContextDataMixin, RedirectURLMixin, IdempotentSessionWizardView): """ View for handling the login process, including OTP verification. @@ -341,7 +341,7 @@ def render(self, form=None, **kwargs): if self.steps.current == self.TOKEN_STEP: form_with_errors = form and form.is_bound and not form.is_valid() if not form_with_errors: - self.get_device().generate_challenge() + self.generate_challenge_with_context(self.get_device()) return super().render(form, **kwargs) def get_user(self): @@ -427,7 +427,7 @@ def dispatch(self, request, *args, **kwargs): @method_decorator([never_cache, login_required], name='dispatch') -class SetupView(RedirectURLMixin, IdempotentSessionWizardView): +class SetupView(DeviceContextDataMixin, RedirectURLMixin, IdempotentSessionWizardView): """ View for handling OTP setup using a wizard. @@ -522,7 +522,7 @@ def render_next_step(self, form, **kwargs): next_step = self.steps.next if next_step == 'validation': try: - self.get_device().generate_challenge() + self.generate_challenge_with_context(self.get_device()) kwargs["challenge_succeeded"] = True except Exception: logger.exception("Could not generate challenge") diff --git a/two_factor/views/mixins.py b/two_factor/views/mixins.py index d5c15067d..f364d7e61 100644 --- a/two_factor/views/mixins.py +++ b/two_factor/views/mixins.py @@ -1,3 +1,5 @@ +from inspect import signature + from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.views import redirect_to_login from django.core.exceptions import PermissionDenied @@ -8,6 +10,44 @@ from ..utils import default_device +class DeviceContextDataMixin: + """ + View mixin allowing customization of context data passed to device + generate_challenge method. + """ + + def get_device_context_data(self, **kwargs): + """ + Get context data for device generate_challenge method. + + Override this method to pass custom context to the email device template. + Context data is only passed to generate_challenge if the method has the + parameter extra_context. + """ + return {} + + def generate_challenge_with_context(self, device): + """ + Call device generate_challenge method. + + If device supports extra_context parameter, context data + from get_device_context_data is passed to generate_challenge. + """ + if self.device_supports_extra_context(device): + return device.generate_challenge(self.get_device_context_data()) + else: + return device.generate_challenge() + + def device_supports_extra_context(self, device): + """ + Test whether device generate_challenge method supports extra_context parameter. + """ + generate_challenge = getattr(device, "generate_challenge", None) + if not callable(generate_challenge): + raise TypeError("Device has no generate_challenge method") + return "extra_context" in signature(generate_challenge).parameters + + class OTPRequiredMixin: """ View mixin which verifies that the user logged in using OTP.