From 7672d033d69d84d0aad430125cb14885d022816f Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Mon, 20 Apr 2020 17:32:47 +0200 Subject: [PATCH] views: ratelimit email form views (#41489) --- src/authentic2/app_settings.py | 6 ++++ src/authentic2/views.py | 37 +++++++++++++++++++++++ tests/settings.py | 3 ++ tests/test_views.py | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 6f5a7111..c700a9e6 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -314,6 +314,12 @@ default_settings = dict( A2_ACCEPT_EMAIL_AUTHENTICATION=Setting( default=True, definition='Enable authentication by email'), + A2_EMAILS_IP_RATELIMIT=Setting( + default='10/h', + definition='Maximum rate of email sendings triggered by the same IP address.'), + A2_EMAILS_ADDRESS_RATELIMIT=Setting( + default='3/d', + definition='Maximum rate of emails sent to the same email address.'), ) app_settings = AppSettings(default_settings) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 572c5ccb..96b407cc 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -20,6 +20,8 @@ import logging import random import re +from ratelimit.utils import is_ratelimited + from django.conf import settings from django.shortcuts import render, get_object_or_404 from django.template.loader import render_to_string @@ -654,6 +656,23 @@ class PasswordResetView(FormView): return ctx def form_valid(self, form): + if is_ratelimited(self.request, key='post:email', group='pw-reset-email', + rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT, increment=True): + form.add_error( + 'email', + _('Multiple emails have already been sent to this address. Further attempts are ' + 'blocked, please check your spam folder or try again later.') + ) + return self.form_invalid(form) + if is_ratelimited(self.request, key='ip', group='pw-reset-email', + rate=app_settings.A2_EMAILS_IP_RATELIMIT, increment=True): + form.add_error( + 'email', + _('Multiple password reset attempts have already been made from this IP address. No ' + 'further email will be sent, please check your spam folder or try again later.') + ) + return self.form_invalid(form) + form.save() self.request.session['reset_email'] = form.cleaned_data['email'] return super(PasswordResetView, self).form_valid(form) @@ -770,6 +789,7 @@ class BaseRegistrationView(FormView): def dispatch(self, request, *args, **kwargs): if not getattr(settings, 'REGISTRATION_OPEN', True): raise Http404('Registration is not open.') + self.token = {} self.ou = get_default_ou() # load pre-filled values @@ -787,6 +807,23 @@ class BaseRegistrationView(FormView): return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) def form_valid(self, form): + if is_ratelimited(self.request, key='post:email', group='registration-email', + rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT, increment=True): + form.add_error( + 'email', + _('Multiple emails have already been sent to this address. Further attempts are ' + 'blocked, please check your spam folder or try again later.') + ) + return self.form_invalid(form) + if is_ratelimited(self.request, key='ip', group='registration-email', + rate=app_settings.A2_EMAILS_IP_RATELIMIT, increment=True): + form.add_error( + 'email', + _('Multiple registration attempts have already been made from this IP address. No ' + 'further email will be sent, please check your spam folder or try again later.') + ) + return self.form_invalid(form) + email = form.cleaned_data.pop('email') for field in form.cleaned_data: self.token[field] = form.cleaned_data[field] diff --git a/tests/settings.py b/tests/settings.py index bf081897..8455fbe4 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -48,3 +48,6 @@ A2_VALIDATE_EMAIL_DOMAIN = False TEMPLATES[0]['DIRS'].append('tests/templates') SITE_BASE_URL = 'http://localhost' + +A2_MAX_EMAILS_PER_IP = None +A2_MAX_EMAILS_FOR_ADDRESS = None diff --git a/tests/test_views.py b/tests/test_views.py index 82c11ffd..fbd81cb9 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -15,6 +15,7 @@ # along with this program. If not, see . # authentic2 +import datetime from utils import login, logout, get_link_from_mail import pytest @@ -148,3 +149,57 @@ def test_custom_account(settings, app, simple_user): response = app.get(reverse('account_management')) assert response.status_code == 302 assert response['Location'] == settings.A2_ACCOUNTS_URL + + +@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset']) +def test_views_email_ratelimit(app, db, simple_user, settings, mailoutbox, freezer, view_name): + freezer.move_to('2020-01-01') + settings.A2_EMAILS_IP_RATELIMIT = '10/h' + settings.A2_EMAILS_ADDRESS_RATELIMIT = '3/d' + users = [User.objects.create(email='test%s@test.com' % i) for i in range(8)] + + # reach email limit + for _ in range(3): + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == 3 + + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == 3 + assert 'try again later' in response.text + + # reach ip limit + for i in range(7): + response = app.get(reverse(view_name)) + response.form.set('email', users[i].email) + response = response.form.submit() + assert len(mailoutbox) == 10 + + response = app.get(reverse(view_name)) + response.form.set('email', users[i + 1].email) + response = response.form.submit() + assert len(mailoutbox) == 10 + assert 'try again later' in response.text + + # ip ratelimits are lifted after an hour + freezer.tick(datetime.timedelta(hours=1)) + response = app.get(reverse(view_name)) + response.form.set('email', users[0].email) + response = response.form.submit() + assert len(mailoutbox) == 11 + + # email ratelimits are lifted after a day + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == 11 + assert 'try again later' in response.text + + freezer.tick(datetime.timedelta(days=1)) + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == 12 -- 2.20.1