Skip to content

Commit 1d2d7f5

Browse files
authored
Merge pull request #787 from apapsch/context-data-for-email
Pass extra context to generate_challenge.
2 parents bdafe71 + 374d3aa commit 1d2d7f5

File tree

6 files changed

+80
-7
lines changed

6 files changed

+80
-7
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{{ token }}
2+
Context: {{ test }}

tests/test_email.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,24 @@ def test_login(self, mock_signal):
177177
mock_signal.assert_called_with(sender=mock.ANY, request=mock.ANY,
178178
user=self.user, device=device)
179179

180+
@override_settings(OTP_EMAIL_THROTTLE_FACTOR=0)
181+
@override_settings(OTP_EMAIL_BODY_TEMPLATE_PATH="email_with_context.txt")
182+
def test_login_with_context(self):
183+
self.user.emaildevice_set.create(name="default", email="bouke@example.com")
184+
response = self.client.post(reverse("custom-device-context-login"),
185+
{"auth-username": "bouke@example.com",
186+
"auth-password": "secret",
187+
"login_view_with_context-current_step": "auth"})
188+
189+
self.assertContains(response, "Token:")
190+
# Test that one message has been sent and that it includes
191+
# the string passed in as context. Rest of login procedure
192+
# is not tested, as it is already tested by test_login.
193+
self.assertEqual(len(mail.outbox), 1)
194+
msg = mail.outbox.pop(0)
195+
self.assertIn("OTP token", msg.subject)
196+
self.assertIn("hello, test", msg.body)
197+
180198
def test_device_without_email(self):
181199
self.user.emaildevice_set.create(name="default")
182200
response = self.client.get(reverse("two_factor:profile"))

tests/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from two_factor.urls import urlpatterns as tf_urls
66
from two_factor.views import LoginView, SetupView
77

8-
from .views import SecureView, plain_view
8+
from .views import LoginViewWithContext, SecureView, plain_view
99

1010
urlpatterns = [
1111
path(
@@ -37,6 +37,11 @@
3737
),
3838
name='custom-redirect-authenticated-user-login',
3939
),
40+
path(
41+
'account/custom-device-context-login/',
42+
LoginViewWithContext.as_view(),
43+
name='custom-device-context-login',
44+
),
4045
path(
4146
'account/setup-backup-tokens-redirect/',
4247
SetupView.as_view(success_url='two_factor:backup_tokens'),

tests/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
from django.http import HttpResponse
22
from django.views.generic import TemplateView
33

4-
from two_factor.views import OTPRequiredMixin
4+
from two_factor.views import LoginView, OTPRequiredMixin
55

66

77
class SecureView(OTPRequiredMixin, TemplateView):
88
template_name = 'secure.html'
99

1010

11+
class LoginViewWithContext(LoginView):
12+
13+
def post(self, *args, **kwargs):
14+
return super().post(*args, **kwargs)
15+
16+
def get_device_context_data(self, **kwargs):
17+
return super().get_device_context_data(**kwargs) | {
18+
"test": "hello, test",
19+
}
20+
21+
1122
def plain_view(request):
1223
""" Non-class based view """
1324
return HttpResponse('plain')

two_factor/views/core.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from two_factor import signals
4040
from two_factor.plugins.registry import MethodNotFoundError, registry
4141
from two_factor.utils import totp_digits
42-
from two_factor.views.mixins import OTPRequiredMixin
42+
from two_factor.views.mixins import DeviceContextDataMixin, OTPRequiredMixin
4343

4444
from ..forms import (
4545
AuthenticationTokenForm, BackupTokenForm, DeviceValidationForm, MethodForm,
@@ -72,7 +72,7 @@ def login_not_required(view_func):
7272
[login_not_required, sensitive_post_parameters(), csrf_protect, never_cache],
7373
name='dispatch'
7474
)
75-
class LoginView(RedirectURLMixin, IdempotentSessionWizardView):
75+
class LoginView(DeviceContextDataMixin, RedirectURLMixin, IdempotentSessionWizardView):
7676
"""
7777
View for handling the login process, including OTP verification.
7878
@@ -341,7 +341,7 @@ def render(self, form=None, **kwargs):
341341
if self.steps.current == self.TOKEN_STEP:
342342
form_with_errors = form and form.is_bound and not form.is_valid()
343343
if not form_with_errors:
344-
self.get_device().generate_challenge()
344+
self.generate_challenge_with_context(self.get_device())
345345
return super().render(form, **kwargs)
346346

347347
def get_user(self):
@@ -427,7 +427,7 @@ def dispatch(self, request, *args, **kwargs):
427427

428428

429429
@method_decorator([never_cache, login_required], name='dispatch')
430-
class SetupView(RedirectURLMixin, IdempotentSessionWizardView):
430+
class SetupView(DeviceContextDataMixin, RedirectURLMixin, IdempotentSessionWizardView):
431431
"""
432432
View for handling OTP setup using a wizard.
433433
@@ -522,7 +522,7 @@ def render_next_step(self, form, **kwargs):
522522
next_step = self.steps.next
523523
if next_step == 'validation':
524524
try:
525-
self.get_device().generate_challenge()
525+
self.generate_challenge_with_context(self.get_device())
526526
kwargs["challenge_succeeded"] = True
527527
except Exception:
528528
logger.exception("Could not generate challenge")

two_factor/views/mixins.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from inspect import signature
2+
13
from django.contrib.auth import REDIRECT_FIELD_NAME
24
from django.contrib.auth.views import redirect_to_login
35
from django.core.exceptions import PermissionDenied
@@ -8,6 +10,41 @@
810
from ..utils import default_device
911

1012

13+
class DeviceContextDataMixin:
14+
"""
15+
View mixin allowing customization of context data passed to device
16+
generate_challenge method.
17+
"""
18+
19+
def get_device_context_data(self, **kwargs):
20+
"""
21+
Get context data for device generate_challenge method.
22+
23+
Override this method to pass custom context to the email device template.
24+
Context data is only passed to generate_challenge if the method has the
25+
parameter extra_context.
26+
"""
27+
return {}
28+
29+
def generate_challenge_with_context(self, device):
30+
"""
31+
Call device generate_challenge method.
32+
33+
If device supports extra_context parameter, context data
34+
from get_device_context_data is passed to generate_challenge.
35+
"""
36+
if self.device_supports_extra_context(device):
37+
return device.generate_challenge(self.get_device_context_data())
38+
else:
39+
return device.generate_challenge()
40+
41+
def device_supports_extra_context(self, device):
42+
"""
43+
Test whether device generate_challenge method supports extra_context parameter.
44+
"""
45+
return "extra_context" in signature(device.generate_challenge).parameters
46+
47+
1148
class OTPRequiredMixin:
1249
"""
1350
View mixin which verifies that the user logged in using OTP.

0 commit comments

Comments
 (0)