Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tests/templates/email_with_context.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{{ token }}
Context: {{ test }}
18 changes: 18 additions & 0 deletions tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]")
response = self.client.post(reverse("custom-device-context-login"),
{"auth-username": "[email protected]",
"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"))
Expand Down
7 changes: 6 additions & 1 deletion tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'),
Expand Down
13 changes: 12 additions & 1 deletion tests/views.py
Original file line number Diff line number Diff line change
@@ -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')
10 changes: 5 additions & 5 deletions two_factor/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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")
Expand Down
40 changes: 40 additions & 0 deletions two_factor/views/mixins.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Comment on lines +45 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to test if device.generate_challenge callable? It's defined on the base Device model so I don't think this test is needed.

return "extra_context" in signature(generate_challenge).parameters


class OTPRequiredMixin:
"""
View mixin which verifies that the user logged in using OTP.
Expand Down