0001-forms-rate-limit-registration-and-password-reset-ema.patch
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 |
- |