From 8b441274dba79710d95615c4055f0c65882ae393 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Thu, 17 Dec 2020 11:31:43 +0100
Subject: [PATCH 1/2] Allow users to provide their email or username for
 password reset process

---
 src/authentic2/forms/passwords.py |  5 +++--
 src/authentic2/utils/__init__.py  |  4 ++--
 src/authentic2/views.py           |  3 ++-
 tests/test_password_reset.py      | 30 ++++++++++++++++++++++++++++--
 4 files changed, 35 insertions(+), 7 deletions(-)

diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py
index 62b769b9..36826bc1 100644
--- a/src/authentic2/forms/passwords.py
+++ b/src/authentic2/forms/passwords.py
@@ -19,6 +19,7 @@ from collections import OrderedDict
 
 from django.contrib.auth import forms as auth_forms
 from django.core.exceptions import ValidationError
+from django.db.models import Q
 from django.forms import Form
 from django import forms
 from django.utils.translation import ugettext_lazy as _
@@ -35,7 +36,7 @@ logger = logging.getLogger(__name__)
 class PasswordResetForm(forms.Form):
     next_url = forms.CharField(widget=forms.HiddenInput, required=False)
 
-    email = ValidatedEmailField(
+    email = forms.CharField(
         label=_("Email"), max_length=254)
 
     def save(self):
@@ -45,7 +46,7 @@ class PasswordResetForm(forms.Form):
         """
         email = self.cleaned_data["email"].strip()
         users = get_user_queryset()
-        active_users = users.filter(email__iexact=email, is_active=True)
+        active_users = users.filter(Q(email__iexact=email) | Q(username__iexact=email), is_active=True)
         for user in active_users:
             # we don't set the password to a random string, as some users should not have
             # a password
diff --git a/src/authentic2/utils/__init__.py b/src/authentic2/utils/__init__.py
index a7d17944..a6929d16 100644
--- a/src/authentic2/utils/__init__.py
+++ b/src/authentic2/utils/__init__.py
@@ -804,8 +804,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, content__email=user.email).delete()
-    token = Token.create('pw-reset', {'user': user.pk, 'email': user.email}, duration=lifetime)
+    Token.objects.filter(kind='pw-reset', content__user=user.pk, content__email=user.email, content__username=user.username).delete()
+    token = Token.create('pw-reset', {'user': user.pk, 'email': user.email, 'username': user.username}, 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 8e6ecb9d..aeedfefc 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -671,7 +671,8 @@ class PasswordResetView(FormView):
 
         # 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()
+            Q(content__email=email) | Q(content__username=email),
+            kind='pw-reset', 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):
diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py
index 3681efd5..c0f4900c 100644
--- a/tests/test_password_reset.py
+++ b/tests/test_password_reset.py
@@ -43,7 +43,7 @@ def test_send_password_reset_email(app, simple_user, mailoutbox):
     utils.assert_event('user.password.reset', user=simple_user, session=app.session)
 
 
-def test_view(app, simple_user, mailoutbox, settings):
+def test_view_with_email(app, simple_user, mailoutbox, settings):
     url = reverse('password_reset')
     resp = app.get(url, status=200)
     resp.form.set('email', simple_user.email)
@@ -53,7 +53,33 @@ def test_view(app, simple_user, mailoutbox, settings):
     utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
     assert resp['Location'].endswith('/instructions/')
     resp = resp.follow()
-    assert simple_user.email in resp.text
+    assert '"noreply@example.net"' in resp.text
+    assert 'show only addr' not in resp.text
+    assert len(mailoutbox) == 1
+    url = utils.get_link_from_mail(mailoutbox[0])
+    relative_url = url.split('testserver')[1]
+    resp = app.get(relative_url, status=200)
+    resp.form.set('new_password1', '1234==aA')
+    resp.form.set('new_password2', '1234==aA')
+    resp = resp.form.submit()
+    # verify user is logged
+    assert str(app.session['_auth_user_id']) == str(simple_user.pk)
+
+    with override_settings(A2_USER_CAN_RESET_PASSWORD=False):
+        url = reverse('password_reset')
+        app.get(url, status=404)
+
+
+def test_view_with_username(app, simple_user, mailoutbox, settings):
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('email', simple_user.username)
+    assert len(mailoutbox) == 0
+    settings.DEFAULT_FROM_EMAIL = 'show only addr <noreply@example.net>'
+    resp = resp.form.submit()
+    utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
+    assert resp['Location'].endswith('/instructions/')
+    resp = resp.follow()
     assert '"noreply@example.net"' in resp.text
     assert 'show only addr' not in resp.text
     assert len(mailoutbox) == 1
-- 
2.20.1

