From 8ecd390b9d6808746110f8b2b14341485ec9cd08 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 9 Apr 2020 15:09:53 +0200 Subject: [PATCH] forms: rate limit registration and password reset emails (#41489) --- src/authentic2/app_settings.py | 7 ++++ src/authentic2/forms/mixins.py | 42 ++++++++++++++++++++++ src/authentic2/forms/passwords.py | 6 ++-- src/authentic2/forms/registration.py | 4 ++- src/authentic2/views.py | 6 ++++ tests/settings.py | 3 ++ tests/test_forms.py | 53 ++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/test_forms.py diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 6f5a7111..b93e9b84 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -314,6 +314,13 @@ default_settings = dict( A2_ACCEPT_EMAIL_AUTHENTICATION=Setting( default=True, definition='Enable authentication by email'), + A2_MAX_EMAILS_PER_IP=Setting( + default=10, + definition='Maximum number of email sendings that can be trigger by the same IP address, ' + 'per hour.'), + A2_MAX_EMAILS_FOR_ADDRESS=Setting( + default=3, + definition='Maximum number of emails that can be sent to the same email address, per hour.'), ) app_settings = AppSettings(default_settings) diff --git a/src/authentic2/forms/mixins.py b/src/authentic2/forms/mixins.py index 79abbf62..ca394b9a 100644 --- a/src/authentic2/forms/mixins.py +++ b/src/authentic2/forms/mixins.py @@ -17,8 +17,12 @@ from collections import OrderedDict from django import forms +from django.core.cache import cache +from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ +from authentic2 import app_settings + class LockedFieldFormMixin(object): def __init__(self, *args, **kwargs): @@ -75,3 +79,41 @@ class LockedFieldFormMixin(object): def is_field_locked(self, name): raise NotImplementedError + + +class RateLimitMixin(object): + def __init__(self, *args, **kwargs): + request = kwargs.pop('request', None) + super(RateLimitMixin, self).__init__(*args, **kwargs) + + if request: + self.remote_addr = request.META['REMOTE_ADDR'] + else: + self.remote_addr = '0.0.0.0' + + def clean(self): + super(RateLimitMixin, self).clean() + + email = self.cleaned_data.get('email') + if not email: + return + + limits = { + self.cache_prefix + email: { + 'limit': app_settings.A2_MAX_EMAILS_FOR_ADDRESS, + 'err_msg': _("You can't do this more than %s times for the same email address.") + }, + self.cache_prefix + self.remote_addr: { + 'limit': app_settings.A2_MAX_EMAILS_PER_IP, + 'err_msg': _("You can't do this more than %s times from the same IP address.") + } + } + + for key, limit_data in limits.items(): + limit = limit_data['limit'] + if limit and cache.get_or_set(key, 0, 3600) >= limit: + raise ValidationError(limit_data['err_msg'] % limit) + + for key, limit_data in limits.items(): + if limit_data['limit']: + cache.incr(key) diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py index 62b769b9..7632fbf3 100644 --- a/src/authentic2/forms/passwords.py +++ b/src/authentic2/forms/passwords.py @@ -26,15 +26,17 @@ from django.utils.translation import ugettext_lazy as _ from .. import models, hooks, app_settings, utils from ..backends import get_user_queryset from .fields import PasswordField, NewPasswordField, CheckPasswordField, ValidatedEmailField +from .mixins import RateLimitMixin from .utils import NextUrlFormMixin logger = logging.getLogger(__name__) -class PasswordResetForm(forms.Form): - next_url = forms.CharField(widget=forms.HiddenInput, required=False) +class PasswordResetForm(RateLimitMixin, forms.Form): + cache_prefix = 'pw-reset-form-' + next_url = forms.CharField(widget=forms.HiddenInput, required=False) email = ValidatedEmailField( label=_("Email"), max_length=254) diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py index 60283ede..7593f6e4 100644 --- a/src/authentic2/forms/registration.py +++ b/src/authentic2/forms/registration.py @@ -29,13 +29,15 @@ from authentic2.a2_rbac.models import OrganizationalUnit from .. import app_settings, models from . import profile as profile_forms from .fields import ValidatedEmailField +from .mixins import RateLimitMixin User = get_user_model() -class RegistrationForm(Form): +class RegistrationForm(RateLimitMixin, Form): error_css_class = 'form-field-error' required_css_class = 'form-field-required' + cache_prefix = 'registration-form-' email = ValidatedEmailField(label=_('Email')) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 572c5ccb..0c984994 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -644,6 +644,7 @@ class PasswordResetView(FormView): kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs) initial = kwargs.setdefault('initial', {}) initial['next_url'] = utils.select_next_url(self.request, '') + kwargs['request'] = self.request return kwargs def get_context_data(self, **kwargs): @@ -814,6 +815,11 @@ class BaseRegistrationView(FormView): for block in blocks if block) return context + def get_form_kwargs(self): + kwargs = super(BaseRegistrationView, self).get_form_kwargs() + kwargs['request'] = self.request + return kwargs + class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): pass diff --git a/tests/settings.py b/tests/settings.py index bf081897..cf81e470 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 = 0 +A2_MAX_EMAILS_FOR_ADDRESS = 0 diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 00000000..5caade12 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,53 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2020 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse + +User = get_user_model() + + +@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset']) +def test_email_forms_ratelimit(app, db, simple_user, settings, mailoutbox, freezer, view_name): + ip_limit = settings.A2_MAX_EMAILS_PER_IP = 10 + email_limit = settings.A2_MAX_EMAILS_FOR_ADDRESS = 3 + users = [User.objects.create(email='test%s@test.com' % i) for i in range(ip_limit + 1)] + + for _ in range(email_limit + 1): + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == email_limit + assert 'more than %s' % email_limit in response.text + assert 'same email address' in response.text + + attempts_to_ip_limit = ip_limit - email_limit + for i in range(attempts_to_ip_limit + 1): + response = app.get(reverse(view_name)) + response.form.set('email', users[i].email) + response = response.form.submit() + assert len(mailoutbox) == ip_limit + assert 'more than %s' % ip_limit in response.text + assert 'same IP address' in response.text + + # limits are per hour + freezer.tick(60 * 60) + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == ip_limit + 1 -- 2.20.1