Projet

Général

Profil

0001-forms-rate-limit-registration-and-password-reset-ema.patch

Valentin Deniaud, 16 avril 2020 17:34

Télécharger (8,57 ko)

Voir les différences:

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
src/authentic2/app_settings.py
314 314
    A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(
315 315
        default=True,
316 316
        definition='Enable authentication by email'),
317
    A2_MAX_EMAILS_PER_IP=Setting(
318
        default=10,
319
        definition='Maximum number of email sendings that can be trigger by the same IP address, '
320
        'per hour.'),
321
    A2_MAX_EMAILS_FOR_ADDRESS=Setting(
322
        default=3,
323
        definition='Maximum number of emails that can be sent to the same email address, per hour.'),
317 324
)
318 325

  
319 326
app_settings = AppSettings(default_settings)
src/authentic2/forms/mixins.py
17 17
from collections import OrderedDict
18 18

  
19 19
from django import forms
20
from django.core.cache import cache
21
from django.core.exceptions import ValidationError
20 22
from django.utils.translation import ugettext as _
21 23

  
24
from authentic2 import app_settings
25

  
22 26

  
23 27
class LockedFieldFormMixin(object):
24 28
    def __init__(self, *args, **kwargs):
......
75 79

  
76 80
    def is_field_locked(self, name):
77 81
        raise NotImplementedError
82

  
83

  
84
class RateLimitMixin(object):
85
    def __init__(self, *args, **kwargs):
86
        request = kwargs.pop('request', None)
87
        super(RateLimitMixin, self).__init__(*args, **kwargs)
88

  
89
        if request:
90
            self.remote_addr = request.META['REMOTE_ADDR']
91
        else:
92
            self.remote_addr = '0.0.0.0'
93

  
94
    def clean(self):
95
        super(RateLimitMixin, self).clean()
96

  
97
        email = self.cleaned_data.get('email')
98
        if not email:
99
            return
100

  
101
        limits = {
102
            self.cache_prefix + email: {
103
                'limit': app_settings.A2_MAX_EMAILS_FOR_ADDRESS,
104
                'err_msg': _("You can't do this more than %s times for the same email address.")
105
            },
106
            self.cache_prefix + self.remote_addr: {
107
                'limit': app_settings.A2_MAX_EMAILS_PER_IP,
108
                'err_msg': _("You can't do this more than %s times from the same IP address.")
109
            }
110
        }
111

  
112
        for key, limit_data in limits.items():
113
            limit = limit_data['limit']
114
            if limit and cache.get_or_set(key, 0, 3600) >= limit:
115
                raise ValidationError(limit_data['err_msg'] % limit)
116

  
117
        for key, limit_data in limits.items():
118
            if limit_data['limit']:
119
                cache.incr(key)
src/authentic2/forms/passwords.py
26 26
from .. import models, hooks, app_settings, utils
27 27
from ..backends import get_user_queryset
28 28
from .fields import PasswordField, NewPasswordField, CheckPasswordField, ValidatedEmailField
29
from .mixins import RateLimitMixin
29 30
from .utils import NextUrlFormMixin
30 31

  
31 32

  
32 33
logger = logging.getLogger(__name__)
33 34

  
34 35

  
35
class PasswordResetForm(forms.Form):
36
    next_url = forms.CharField(widget=forms.HiddenInput, required=False)
36
class PasswordResetForm(RateLimitMixin, forms.Form):
37
    cache_prefix = 'pw-reset-form-'
37 38

  
39
    next_url = forms.CharField(widget=forms.HiddenInput, required=False)
38 40
    email = ValidatedEmailField(
39 41
        label=_("Email"), max_length=254)
40 42

  
src/authentic2/forms/registration.py
29 29
from .. import app_settings, models
30 30
from . import profile as profile_forms
31 31
from .fields import ValidatedEmailField
32
from .mixins import RateLimitMixin
32 33

  
33 34
User = get_user_model()
34 35

  
35 36

  
36
class RegistrationForm(Form):
37
class RegistrationForm(RateLimitMixin, Form):
37 38
    error_css_class = 'form-field-error'
38 39
    required_css_class = 'form-field-required'
40
    cache_prefix = 'registration-form-'
39 41

  
40 42
    email = ValidatedEmailField(label=_('Email'))
41 43

  
src/authentic2/views.py
644 644
        kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs)
645 645
        initial = kwargs.setdefault('initial', {})
646 646
        initial['next_url'] = utils.select_next_url(self.request, '')
647
        kwargs['request'] = self.request
647 648
        return kwargs
648 649

  
649 650
    def get_context_data(self, **kwargs):
......
814 815
                                                       for block in blocks if block)
815 816
        return context
816 817

  
818
    def get_form_kwargs(self):
819
        kwargs = super(BaseRegistrationView, self).get_form_kwargs()
820
        kwargs['request'] = self.request
821
        return kwargs
822

  
817 823

  
818 824
class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView):
819 825
    pass
tests/settings.py
48 48
TEMPLATES[0]['DIRS'].append('tests/templates')
49 49

  
50 50
SITE_BASE_URL = 'http://localhost'
51

  
52
A2_MAX_EMAILS_PER_IP = 0
53
A2_MAX_EMAILS_FOR_ADDRESS = 0
tests/test_forms.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2020 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import pytest
18

  
19
from django.contrib.auth import get_user_model
20
from django.core.urlresolvers import reverse
21

  
22
User = get_user_model()
23

  
24

  
25
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset'])
26
def test_email_forms_ratelimit(app, db, simple_user, settings, mailoutbox, freezer, view_name):
27
    ip_limit = settings.A2_MAX_EMAILS_PER_IP = 10
28
    email_limit = settings.A2_MAX_EMAILS_FOR_ADDRESS = 3
29
    users = [User.objects.create(email='test%s@test.com' % i) for i in range(ip_limit + 1)]
30

  
31
    for _ in range(email_limit + 1):
32
        response = app.get(reverse(view_name))
33
        response.form.set('email', simple_user.email)
34
        response = response.form.submit()
35
    assert len(mailoutbox) == email_limit
36
    assert 'more than %s' % email_limit in response.text
37
    assert 'same email address' in response.text
38

  
39
    attempts_to_ip_limit = ip_limit - email_limit
40
    for i in range(attempts_to_ip_limit + 1):
41
        response = app.get(reverse(view_name))
42
        response.form.set('email', users[i].email)
43
        response = response.form.submit()
44
    assert len(mailoutbox) == ip_limit
45
    assert 'more than %s' % ip_limit in response.text
46
    assert 'same IP address' in response.text
47

  
48
    # limits are per hour
49
    freezer.tick(60 * 60)
50
    response = app.get(reverse(view_name))
51
    response.form.set('email', simple_user.email)
52
    response = response.form.submit()
53
    assert len(mailoutbox) == ip_limit + 1
0
-