From 356a5a673505b6698e9534b103f64e180d561796 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 22 Apr 2020 16:40:55 +0200 Subject: [PATCH 4/4] views: warn user before generating new token (#41792) --- src/authentic2/app_settings.py | 3 +++ src/authentic2/utils/__init__.py | 4 ++-- src/authentic2/views.py | 39 +++++++++++++++++++++++++++++--- tests/settings.py | 2 ++ tests/test_attribute_kinds.py | 3 ++- tests/test_views.py | 21 +++++++++++++++++ 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index c700a9e6..f73eb6ed 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -320,6 +320,9 @@ default_settings = dict( A2_EMAILS_ADDRESS_RATELIMIT=Setting( default='3/d', definition='Maximum rate of emails sent to the same email address.'), + A2_TOKEN_EXISTS_WARNING=Setting( + default=True, + definition='If an active token exists, warn user before generating a new one.') ) app_settings = AppSettings(default_settings) diff --git a/src/authentic2/utils/__init__.py b/src/authentic2/utils/__init__.py index a7463c54..44fe4f2b 100644 --- a/src/authentic2/utils/__init__.py +++ b/src/authentic2/utils/__init__.py @@ -801,8 +801,8 @@ def build_reset_password_url(user, request=None, next_url=None, set_random_passw user.save() lifetime = settings.PASSWORD_RESET_TIMEOUT_DAYS * 3600 * 24 # invalidate any token associated with this user - Token.objects.filter(kind='pw-reset', content__user=user.pk).delete() - token = Token.create('pw-reset', {'user': user.pk}, duration=lifetime) + Token.objects.filter(kind='pw-reset', content__user=user.pk, content__email=user.email).delete() + token = Token.create('pw-reset', {'user': user.pk, 'email': user.email}, duration=lifetime) reset_url = make_url( 'password_reset_confirm', kwargs={'token': token.uuid_b64url}, diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 07392080..678576d6 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -33,7 +33,7 @@ from django import http, shortcuts from django.core import signing from django.core.exceptions import ValidationError from django.contrib import messages -from django.utils import six +from django.utils import six, timezone from django.utils.translation import ugettext as _ from django.urls import reverse from django.contrib.auth import logout as auth_logout @@ -659,6 +659,23 @@ class PasswordResetView(FormView): return ctx def form_valid(self, form): + email = form.cleaned_data['email'] + + # if an email has already been sent, warn once before allowing resend + token = models.Token.objects.filter( + kind='pw-reset', content__email=email, expires__gt=timezone.now() + ).exists() + resend_key = 'pw-reset-allow-resend' + if app_settings.A2_TOKEN_EXISTS_WARNING and token and not self.request.session.get(resend_key): + self.request.session[resend_key] = True + form.add_error( + 'email', + _('An email has already been sent to %s. Click "Validate" again if ' + 'you really want it to be sent again.') % email + ) + return self.form_invalid(form) + self.request.session[resend_key] = False + if is_ratelimited(self.request, key='post:email', group='pw-reset-email', rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT, increment=True): form.add_error( @@ -677,7 +694,7 @@ class PasswordResetView(FormView): return self.form_invalid(form) form.save() - self.request.session['reset_email'] = form.cleaned_data['email'] + self.request.session['reset_email'] = email return super(PasswordResetView, self).form_valid(form) password_reset = PasswordResetView.as_view() @@ -791,6 +808,23 @@ class BaseRegistrationView(FormView): return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) def form_valid(self, form): + email = form.cleaned_data.pop('email') + + # if an email has already been sent, warn once before allowing resend + token = models.Token.objects.filter( + kind='registration', content__email=email, expires__gt=timezone.now() + ).exists() + resend_key = 'registration-allow-resend' + if app_settings.A2_TOKEN_EXISTS_WARNING and token and not self.request.session.get(resend_key): + self.request.session[resend_key] = True + form.add_error( + 'email', + _('An email has already been sent to %s. Click "Validate" again if ' + 'you really want it to be sent again.') % email + ) + return self.form_invalid(form) + self.request.session[resend_key] = False + if is_ratelimited(self.request, key='post:email', group='registration-email', rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT, increment=True): form.add_error( @@ -808,7 +842,6 @@ class BaseRegistrationView(FormView): ) 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..c897b552 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -48,3 +48,5 @@ A2_VALIDATE_EMAIL_DOMAIN = False TEMPLATES[0]['DIRS'].append('tests/templates') SITE_BASE_URL = 'http://localhost' + +A2_TOKEN_EXISTS_WARNING = False diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py index 5e8a88df..c5f5aaaf 100644 --- a/tests/test_attribute_kinds.py +++ b/tests/test_attribute_kinds.py @@ -187,7 +187,8 @@ def test_fr_postcode(db, app, admin, mailoutbox): qs.delete() -def test_phone_number(db, app, admin, mailoutbox): +def test_phone_number(db, app, admin, mailoutbox, settings): + settings.A2_EMAILS_ADDRESS_RATELIMIT = None def register_john(): response = app.get('/accounts/register/') diff --git a/tests/test_views.py b/tests/test_views.py index 37bc03e8..70a396ea 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -203,3 +203,24 @@ def test_views_email_ratelimit(app, db, simple_user, settings, mailoutbox, freez response.form.set('email', simple_user.email) response = response.form.submit() assert len(mailoutbox) == 12 + + +@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset']) +def test_views_email_token_resend(app, simple_user, settings, mailoutbox, view_name): + settings.A2_TOKEN_EXISTS_WARNING = True + + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert len(mailoutbox) == 1 + + # warn user token has already been sent + response = app.get(reverse(view_name)) + response.form.set('email', simple_user.email) + response = response.form.submit() + assert 'email has already been sent' in response.text + assert len(mailoutbox) == 1 + + # validating again anyway works + response = response.form.submit() + assert len(mailoutbox) == 2 -- 2.20.1