-
Notifications
You must be signed in to change notification settings - Fork 93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added throttling to token generation (Closes #48) #122
Changes from 6 commits
8aa2230
4f20255
72291ef
b673da6
b966e1d
bb10746
dbc3fb5
29ebe3e
dbe61d7
5b49259
131a10e
87ba728
3746d8c
f3ab24b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
# Generated by Django 3.2.16 on 2023-05-12 11:37 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('otp_email', '0004_throttling'), | ||
] | ||
|
||
operations = [ | ||
migrations.AddField( | ||
model_name='emaildevice', | ||
name='last_generated_timestamp', | ||
field=models.DateTimeField(blank=True, help_text='The last time a token was generated for this device.', null=True), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,15 @@ | ||
from django.contrib.humanize.templatetags.humanize import naturaltime | ||
from django.core.mail import send_mail | ||
from django.db import models | ||
from django.template import Context, Template | ||
from django.template.loader import get_template | ||
from django.utils import timezone | ||
|
||
from django_otp.models import SideChannelDevice, ThrottlingMixin | ||
from django_otp.models import ( | ||
GenerationCooldownMixin, | ||
SideChannelDevice, | ||
ThrottlingMixin, | ||
) | ||
from django_otp.util import hex_validator, random_hex | ||
|
||
from .conf import settings | ||
|
@@ -19,7 +25,7 @@ def key_validator(value): # pragma: no cover | |
return hex_validator()(value) | ||
|
||
|
||
class EmailDevice(ThrottlingMixin, SideChannelDevice): | ||
class EmailDevice(GenerationCooldownMixin, ThrottlingMixin, SideChannelDevice): | ||
""" | ||
A :class:`~django_otp.models.SideChannelDevice` that delivers a token to | ||
the email address saved in this object or alternatively to the user's | ||
|
@@ -57,34 +63,50 @@ def generate_challenge(self, extra_context=None): | |
:type extra_context: dict | ||
|
||
""" | ||
self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY) | ||
|
||
context = {'token': self.token, **(extra_context or {})} | ||
if settings.OTP_EMAIL_BODY_TEMPLATE: | ||
body = Template(settings.OTP_EMAIL_BODY_TEMPLATE).render(Context(context)) | ||
else: | ||
body = get_template(settings.OTP_EMAIL_BODY_TEMPLATE_PATH).render(context) | ||
|
||
if settings.OTP_EMAIL_BODY_HTML_TEMPLATE: | ||
body_html = Template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE).render( | ||
Context(context) | ||
) | ||
elif settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH: | ||
body_html = get_template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH).render( | ||
context | ||
generate_allowed, data_dict = self.generate_is_allowed() | ||
if generate_allowed: | ||
self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is now doing two big things. Perhaps it's time to move the body of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Created a private method for that part (dbe61d7). |
||
self.last_generated_timestamp = timezone.now() | ||
|
||
context = {'token': self.token, **(extra_context or {})} | ||
if settings.OTP_EMAIL_BODY_TEMPLATE: | ||
body = Template(settings.OTP_EMAIL_BODY_TEMPLATE).render( | ||
Context(context) | ||
) | ||
else: | ||
body = get_template(settings.OTP_EMAIL_BODY_TEMPLATE_PATH).render(context) | ||
|
||
if settings.OTP_EMAIL_BODY_HTML_TEMPLATE: | ||
body_html = Template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE).render( | ||
Context(context) | ||
) | ||
elif settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH: | ||
body_html = get_template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH).render( | ||
context | ||
) | ||
else: | ||
body_html = None | ||
|
||
send_mail( | ||
str(settings.OTP_EMAIL_SUBJECT), | ||
body, | ||
settings.OTP_EMAIL_SENDER, | ||
[self.email or self.user.email], | ||
html_message=body_html, | ||
) | ||
else: | ||
body_html = None | ||
|
||
send_mail( | ||
str(settings.OTP_EMAIL_SUBJECT), | ||
body, | ||
settings.OTP_EMAIL_SENDER, | ||
[self.email or self.user.email], | ||
html_message=body_html, | ||
) | ||
|
||
message = "sent by email" | ||
message = "sent by email" | ||
else: | ||
if data_dict['reason'] == 'COOLDOWN_DURATION_PENDING': | ||
next_generation_naturaltime = naturaltime( | ||
data_dict['next_generation_at'] | ||
) | ||
message = ( | ||
"Token generation cooldown period has not expired yet. Next" | ||
f" generation allowed {next_generation_naturaltime}" | ||
) | ||
else: | ||
message = "Token generation is not allowed at this time" | ||
|
||
return message | ||
|
||
|
@@ -101,3 +123,6 @@ def verify_token(self, token): | |
verified = False | ||
|
||
return verified | ||
|
||
def get_cooldown_duration(self): | ||
return settings.OTP_EMAIL_GENERATION_INTERVAL |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ | |
|
||
from django.contrib.auth import get_user_model | ||
from django.contrib.auth.models import AnonymousUser | ||
from django.core import mail | ||
from django.core.management import call_command | ||
from django.core.management.base import CommandError | ||
from django.db import IntegrityError, connection | ||
|
@@ -164,6 +165,45 @@ def test_verify_is_allowed(self): | |
self.assertEqual(data3, None) | ||
|
||
|
||
class GenerationThrottlingTestMixin: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like this is based on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. I have added generic tests and moved email specific tests to the email test file (29ebe3e). |
||
def setUp(self): | ||
self.device = None | ||
|
||
def test_cooldown_imposed_after_successful_generation(self): | ||
message = self.device.generate_challenge() | ||
self.assertEqual(message, 'sent by email') | ||
message = self.device.generate_challenge() | ||
self.assertTrue( | ||
message.startswith('Token generation cooldown period has not expired yet.') | ||
) | ||
|
||
def test_allow_generation_after_cooldown(self): | ||
self.assertEqual(len(mail.outbox), 0) | ||
# First generation is allowed | ||
self.device.generate_challenge() | ||
# Assert that an email is sent | ||
self.assertEqual(len(mail.outbox), 1) | ||
|
||
with freeze_time(): | ||
# Second generation, within cooldown period | ||
message = self.device.generate_challenge() | ||
self.assertTrue( | ||
message.startswith( | ||
'Token generation cooldown period has not expired yet.' | ||
) | ||
) | ||
# Assert that no email is sent | ||
self.assertEqual(len(mail.outbox), 1) | ||
|
||
with freeze_time() as frozen_time: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test should work in practice as-is, but would it make sense to use this context for the entire method body? The point is to control time for the whole sequence of operations. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it makes perfect sense. I have corrected this in all tests (29ebe3e). |
||
# Third generation after cooldown period | ||
frozen_time.tick(delta=timedelta(seconds=1.1)) | ||
message = self.device.generate_challenge() | ||
self.assertEqual(message, 'sent by email') | ||
# Assert that a second email is sent | ||
self.assertEqual(len(mail.outbox), 2) | ||
|
||
|
||
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0) | ||
class APITestCase(TestCase): | ||
def setUp(self): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should
dt_now
beself.last_generated_timestamp
here?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed! I have corrected it (dbc3fb5) and added a test for the message as well.