Repository: pretix/pretix

Issue:
Preventing user enumeration
It is currently possible to enumerate emails of team members using the password reset feature. The view should return in both cases (email known/unknown) the same message, to prevent this risk.

**Side effects:**
- No "There was an error sending the mail" exception for the user anymore. This information could be logged.
- No "We've already sent you an email in the last 24 hours." displayed to the user anymore.  
- Two new messages for the user. For example:  
"We have sent you an e-mail containing further instructions, if you own a valid account." (no redis instance available)
"We have sent you an e-mail containing further instructions, if you own a valid account. You can reset the password once every 24 hours. " (redis instance available)

If there are no objections, I would start implementing the described behaviour.
Preventing user enumeration
It is currently possible to enumerate emails of team members using the password reset feature. The view should return in both cases (email known/unknown) the same message, to prevent this risk.

**Side effects:**
- No "There was an error sending the mail" exception for the user anymore. This information could be logged.
- No "We've already sent you an email in the last 24 hours." displayed to the user anymore.  
- Two new messages for the user. For example:  
"We have sent you an e-mail containing further instructions, if you own a valid account." (no redis instance available)
"We have sent you an e-mail containing further instructions, if you own a valid account. You can reset the password once every 24 hours. " (redis instance available)

If there are no objections, I would start implementing the described behaviour.


Discussion:

Resolving patch:
diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py
--- a/src/pretix/base/forms/auth.py
+++ b/src/pretix/base/forms/auth.py
@@ -180,12 +180,4 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
     def clean_email(self):
-        email = self.cleaned_data['email']
-        try:
-            self.cleaned_data['user'] = User.objects.get(email=email)
-            return email
-        except User.DoesNotExist:
-            raise forms.ValidationError(
-                _("We are unable to find a user matching the data you provided."),
-                code='unknown_user'
-            )
+        return self.cleaned_data['email']
diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py
--- a/src/pretix/control/views/auth.py
+++ b/src/pretix/control/views/auth.py
@@ -174,6 +174,10 @@ def invite(request, token):
     return render(request, 'pretixcontrol/auth/invite.html', ctx)
 
 
+class RepeatedResetDenied(Exception):
+    pass
+
+
 class Forgot(TemplateView):
     template_name = 'pretixcontrol/auth/forgot.html'
 
@@ -189,27 +193,43 @@ def get(self, request, *args, **kwargs):
 
     def post(self, request, *args, **kwargs):
         if self.form.is_valid():
-            user = self.form.cleaned_data['user']
-
-            if settings.HAS_REDIS:
-                from django_redis import get_redis_connection
-                rc = get_redis_connection("redis")
-                if rc.exists('pretix_pwreset_%s' % (user.id)):
-                    user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
-                    messages.error(request, _('We already sent you an email in the last 24 hours.'))
-                    return redirect('control:auth.forgot')
-                else:
-                    rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
+            email = self.form.cleaned_data['email']
+
+            has_redis = settings.HAS_REDIS
 
             try:
-                user.send_password_reset()
+                user = User.objects.get(email=email)
+
+                if has_redis:
+                    from django_redis import get_redis_connection
+                    rc = get_redis_connection("redis")
+                    if rc.exists('pretix_pwreset_%s' % (user.id)):
+                        user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
+                        raise RepeatedResetDenied()
+                    else:
+                        rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
+
+            except User.DoesNotExist:
+                logger.warning('Password reset for unregistered e-mail \"' + email + '\"requested.')
+
             except SendMailException:
-                messages.error(request, _('There was an error sending the mail. Please try again later.'))
-                return self.get(request, *args, **kwargs)
+                logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
+
+            except RepeatedResetDenied:
+                pass
+
+            else:
+                user.send_password_reset()
+                user.log_action('pretix.control.auth.user.forgot_password.mail_sent')
+
+            finally:
+                if has_redis:
+                    messages.info(request, _('If the adress is registred to valid account, then we have sent you an e-mail containing further instructions. '
+                                             'Please note that we will send at most one email every 24 hours.'))
+                else:
+                    messages.info(request, _('If the adress is registred to valid account, then we have sent you an e-mail containing further instructions.'))
 
-            user.log_action('pretix.control.auth.user.forgot_password.mail_sent')
-            messages.success(request, _('We sent you an e-mail containing further instructions.'))
-            return redirect('control:auth.forgot')
+                return redirect('control:auth.forgot')
         else:
             return self.get(request, *args, **kwargs)
 


Test patch:
diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py
--- a/src/tests/control/test_auth.py
+++ b/src/tests/control/test_auth.py
@@ -301,16 +301,38 @@ def test_u2f_valid(self):
         m.undo()
 
 
+class FakeRedis(object):
+    def get_redis_connection(self, connection_string):
+        return self
+
+    def __init__(self):
+        self.storage = {}
+
+    def exists(self, rkey):
+        print(rkey in self.storage)
+        return rkey in self.storage
+
+    def setex(self, rkey, value, expiration):
+        self.storage[rkey] = value
+
+    def execute(self):
+        pass
+
+
+@pytest.mark.usefixtures("class_monkeypatch")
 class PasswordRecoveryFormTest(TestCase):
     def setUp(self):
         super().setUp()
         self.user = User.objects.create_user('demo@demo.dummy', 'demo')
 
     def test_unknown(self):
+        djmail.outbox = []
+
         response = self.client.post('/control/forgot', {
             'email': 'dummy@dummy.dummy',
         })
-        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.status_code, 302)
+        assert len(djmail.outbox) == 0
 
     def test_email_sent(self):
         djmail.outbox = []
@@ -323,6 +345,33 @@ def test_email_sent(self):
         assert len(djmail.outbox) == 1
         assert djmail.outbox[0].to == [self.user.email]
         assert "recover?id=%d&token=" % self.user.id in djmail.outbox[0].body
+        assert self.user.all_logentries[0].action_type == 'pretix.control.auth.user.forgot_password.mail_sent'
+
+    @override_settings(HAS_REDIS=True)
+    def test_email_reset_twice_redis(self):
+        fake_redis = FakeRedis()
+        m = self.monkeypatch
+        m.setattr('django_redis.get_redis_connection', fake_redis.get_redis_connection, raising=False)
+
+        djmail.outbox = []
+
+        response = self.client.post('/control/forgot', {
+            'email': 'demo@demo.dummy',
+        })
+        self.assertEqual(response.status_code, 302)
+
+        assert len(djmail.outbox) == 1
+        assert djmail.outbox[0].to == [self.user.email]
+        assert "recover?id=%d&token=" % self.user.id in djmail.outbox[0].body
+        assert self.user.all_logentries[0].action_type == 'pretix.control.auth.user.forgot_password.mail_sent'
+
+        response = self.client.post('/control/forgot', {
+            'email': 'demo@demo.dummy',
+        })
+        self.assertEqual(response.status_code, 302)
+
+        assert len(djmail.outbox) == 1
+        assert self.user.all_logentries[0].action_type == 'pretix.control.auth.user.forgot_password.denied.repeated'
 
     def test_recovery_unknown_user(self):
         response = self.client.get('/control/forgot/recover?id=0&token=foo')