Projet

Général

Profil

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

Valentin Deniaud, 16 avril 2020 18:13

Télécharger (8,88 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       | 43 ++++++++++++++++++++
 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                  | 61 ++++++++++++++++++++++++++++
 7 files changed, 127 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.conf import settings
21
from django.core.cache import cache
22
from django.core.exceptions import ValidationError
20 23
from django.utils.translation import ugettext as _
21 24

  
25
from authentic2 import app_settings
26

  
22 27

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

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

  
84

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

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

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

  
98
        email = self.cleaned_data.get('email')
99
        if not email or settings.DEBUG:
100
            return
101

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

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

  
118
        for key, limit_data in limits.items():
119
            if limit_data['limit']:
120
                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
    # no ratelimit when debugging is on
49
    settings.DEBUG = True
50
    response = app.get(reverse(view_name))
51
    response.form.set('email', users[i].email)
52
    response = response.form.submit()
53
    assert len(mailoutbox) == ip_limit + 1
54
    settings.DEBUG = False
55

  
56
    # limits are per hour
57
    freezer.tick(60 * 60)
58
    response = app.get(reverse(view_name))
59
    response.form.set('email', simple_user.email)
60
    response = response.form.submit()
61
    assert len(mailoutbox) == ip_limit + 2
0
-