Skip to content
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

Merged

Conversation

demestav
Copy link
Contributor

@demestav demestav commented May 8, 2023

As the #48 issue describes, it is beneficial to optionally throttle the token generation, to prevent accidental or malicious abuse of the token delivery medium.

The current state of this pull request is functional but not fully tested. If the maintainer sees some value to this, I could use some feedback to finalize this.

Copy link
Member

@psagers psagers left a comment

Choose a reason for hiding this comment

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

Looks like a good start.

If the interaction between GenerationThrottlingMixin and SideChannelDevice gets complicated, it might worth considering whether singledispatch could be used to cut through some of the MRO subtleties.

src/django_otp/models.py Outdated Show resolved Hide resolved
src/django_otp/models.py Outdated Show resolved Hide resolved
src/django_otp/forms.py Outdated Show resolved Hide resolved
src/django_otp/models.py Outdated Show resolved Hide resolved
@demestav
Copy link
Contributor Author

demestav commented May 9, 2023

Thank you for the quick response and feedback. I will respond to each comment in their respective dialog.

If the interaction between GenerationThrottlingMixin and SideChannelDevice gets complicated, it might worth considering whether singledispatch could be used to cut through some of the MRO subtleties.

Let's see how it turns out with the Mixin, and we reconsider.

Update the timestamp of the last token generation to the current time.
"""
self.last_generated_timestamp = timezone.now()
super().generate_token(*args, **kwargs)
Copy link
Member

Choose a reason for hiding this comment

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

Is generate_token guaranteed to exist on all devices? This appears to be a secret dependency on SideChannelDevice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be resolved now.

@psagers
Copy link
Member

psagers commented May 20, 2023

I'll take another look at this once it includes documentation and test coverage.

@demestav
Copy link
Contributor Author

I'll take another look at this once it includes documentation and test coverage.

I will re-request a review once I have something ready.

@demestav demestav requested a review from psagers August 15, 2023 20:24
@demestav demestav force-pushed the feature/48-add-token-generation-throttling branch from afffcc3 to bb10746 Compare October 18, 2023 08:32
@demestav
Copy link
Contributor Author

demestav commented Oct 18, 2023

Had to rebase to resolve conflicts with master. Re-requesting review. @psagers Are you interested in proceeding with this?

Copy link
Member

@psagers psagers left a comment

Choose a reason for hiding this comment

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

Structurally, this is looking pretty solid. I added a few suggestions for fixes and cleanup. And of course there's still the documentation.

Remember to run hatch run check (and, if necessary, hatch run fix) before checking in.

else:
return False, {
'reason': "COOLDOWN_DURATION_PENDING",
'next_generation_at': dt_now
Copy link
Member

Choose a reason for hiding this comment

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

Should dt_now be self.last_generated_timestamp here?

Copy link
Contributor Author

@demestav demestav Oct 22, 2023

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.

@@ -164,6 +165,45 @@ def test_verify_is_allowed(self):
self.assertEqual(data3, None)


class GenerationThrottlingTestMixin:
Copy link
Member

Choose a reason for hiding this comment

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

It looks like this is based on ThrottlingTestMixin, which makes sense. However, it's asserting on len(mail.outbox), which is email-specific. This will presumably need at least one abstract method to actually be generic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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).

# Assert that no email is sent
self.assertEqual(len(mail.outbox), 1)

with freeze_time() as frozen_time:
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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).

context
generate_allowed, data_dict = self.generate_is_allowed()
if generate_allowed:
self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY)
Copy link
Member

Choose a reason for hiding this comment

The 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 if generate_allowed: into a private method for clarity. See https://github.com/django-otp/django-otp-twilio/blob/9e1aab519c926a3d281292d549ce2040c821fe1a/src/otp_twilio/models.py#L55, e.g.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Created a private method for that part (dbe61d7).

@demestav
Copy link
Contributor Author

Remember to run hatch run check (and, if necessary, hatch run fix) before checking in.

Yes, sorry about that, will do.

I added a few suggestions for fixes and cleanup.

Thank you for the feedback. I believe I have addressed all open issues and commented on relevant conversations above.

And of course there's still the documentation.

I used ThrottlingMixin as a guide and replicated for this Mixin. I was not sure whether generate_is_allowed should be listed in "members" in the "Helpers" sections.

Please note

  • I have renamed the feature as Cooldown to avoid reusing the word "Throttling" which is used in the ThrottlingMixin and to shorten the name a bit. I believe CooldownMixin is a more appropriate name for this feature.
  • I have set the default value for email cooldown to 60 seconds.

@demestav demestav requested a review from psagers October 30, 2023 07:39
Copy link
Member

@psagers psagers left a comment

Choose a reason for hiding this comment

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

Almost there, I think. There's a critical bug in the email integration making it a noop. Also, liberal use of self.device.refresh_from_db() or equivalent in the tests will allow them to catch these sorts of bugs.

self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY)
self.last_generated_timestamp = timezone.now()
Copy link
Member

Choose a reason for hiding this comment

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

This never gets saved. As importantly, the tests aren't catching this (see companion note in the tests).

(Hint: try swapping these two lines. Adding the technically redundant commit=True will help to highlight the fact that you're relying on generate_token to save the model instance for you.)

src/django_otp/tests.py Show resolved Hide resolved
@demestav
Copy link
Contributor Author

demestav commented Nov 8, 2023

There's a critical bug in the email integration

Indeed, I missed this. I have added cooldown_set method to the mixin to keep it consistent, and updated the email integration code.

liberal use of self.device.refresh_from_db() or equivalent in the tests will allow them to catch these sorts of bugs.

I have updated the tests.

@demestav demestav requested a review from psagers November 8, 2023 06:46
Copy link
Member

@psagers psagers left a comment

Choose a reason for hiding this comment

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

Looks like it's ready. Thanks for all the work.

@psagers psagers merged commit d6b37d7 into django-otp:master Nov 8, 2023
1 check passed
@demestav
Copy link
Contributor Author

demestav commented Nov 9, 2023

Looks like it's ready. Thanks for all the work.

I am glad we got this through! Thank you for your valuable feedback and patience.

@psagers I know this is merged now, but it occurred to me that maybe the default value for OTP_EMAIL_COOLDOWN_DURATION should have been 0 in order to disable the cooldown feature by default? The reason I am suggesting this, is that people that are already using the email plugin, and upgrade, and they are unware of this feature, they will unintentionally activate cooldown which might affect how their application works.

@psagers
Copy link
Member

psagers commented Nov 9, 2023

I considered that, although OTP_EMAIL_THROTTLE_FACTOR also has a default value, so it's consistent. 60 seconds isn't a very aggressive cooldown and I noted the new behavior in the changelog, so erring on the side of safety seems reasonable here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants