From 82e3e85c3efed797129e825b8c0fcd166539314d Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Fri, 4 Dec 2020 19:13:43 +0100
Subject: [PATCH] Allow users to provide their email or username for password
 reset process

---
 src/authentic2/app_settings.py                |   3 +
 src/authentic2/forms/passwords.py             |   7 +-
 src/authentic2/journal_event_types.py         |   4 +-
 .../locale/fr/LC_MESSAGES/django.po           | 115 ++++++++++--------
 .../password_reset_instructions.html          |   5 +-
 src/authentic2/utils/__init__.py              |   4 +-
 src/authentic2/views.py                       |  14 ++-
 tests/test_password_reset.py                  |  30 ++++-
 8 files changed, 117 insertions(+), 65 deletions(-)

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 949cdd3f..eeaca575 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -139,6 +139,9 @@ default_settings = dict(
     A2_USER_CAN_RESET_PASSWORD=Setting(
         default=None,
         definition='Allow online reset of passwords'),
+    A2_RESET_PASSWORD_ID_LABEL=Setting(
+        default=None,
+        definition='Alternate ID label for the password reset form'),
     A2_EMAIL_IS_UNIQUE=Setting(
         default=False,
         definition='Email of users must be unique'),
diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py
index 62b769b9..a34d5c79 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,8 +36,8 @@ logger = logging.getLogger(__name__)
 class PasswordResetForm(forms.Form):
     next_url = forms.CharField(widget=forms.HiddenInput, required=False)
 
-    email = ValidatedEmailField(
-        label=_("Email"), max_length=254)
+    email = forms.CharField(
+        label=_("Email or username"), 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/journal_event_types.py b/src/authentic2/journal_event_types.py
index 77391fe5..1454e5ea 100644
--- a/src/authentic2/journal_event_types.py
+++ b/src/authentic2/journal_event_types.py
@@ -154,7 +154,7 @@ class UserRequestPasswordReset(EventTypeDefinition):
     def get_message(cls, event, context):
         email = event.get_data('email')
         if email:
-            return _('password reset request with email "%s"') % email
+            return _('password reset request with email (or username) "%s"') % email
         return super().get_message(event, context)
 
 
@@ -179,7 +179,7 @@ class UserResetPasswordFailure(EventTypeDefinition):
     def get_message(cls, event, context):
         email = event.get_data('email')
         if email:
-            return _('password reset failure with email "%s"') % email
+            return _('password reset failure with email (or username) "%s"') % email
         return super().get_message(event, context)
 
 
diff --git a/src/authentic2/locale/fr/LC_MESSAGES/django.po b/src/authentic2/locale/fr/LC_MESSAGES/django.po
index 47236e4b..e3a8020f 100644
--- a/src/authentic2/locale/fr/LC_MESSAGES/django.po
+++ b/src/authentic2/locale/fr/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Authentic\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-11-23 10:34+0100\n"
+"POT-Creation-Date: 2020-12-04 18:34+0100\n"
 "PO-Revision-Date: 2020-10-13 11:25+0200\n"
 "Last-Translator: Mikaël Ates <mates@entrouvert.com>\n"
 "Language-Team: None\n"
@@ -17,7 +17,7 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=n>1;\n"
 
-#: debian/multitenant/debian_config.py:40 src/authentic2/forms/passwords.py:39
+#: debian/multitenant/debian_config.py:40
 #: src/authentic2/forms/registration.py:40 src/authentic2/manager/forms.py:644
 #: src/authentic2/manager/templates/authentic2/manager/user_create_registration_email_body.txt:4
 #: src/authentic2/saml/models.py:134
@@ -1148,19 +1148,23 @@ msgstr "Les mots de passe ne sont pas identiques."
 msgid "The image is not valid"
 msgstr "L’image n’est pas valide"
 
-#: src/authentic2/forms/passwords.py:95 src/authentic2/forms/passwords.py:108
+#: src/authentic2/forms/passwords.py:40
+msgid "Email or username"
+msgstr "Courriel ou nom d'utilisateur"
+
+#: src/authentic2/forms/passwords.py:96 src/authentic2/forms/passwords.py:109
 msgid "New password"
 msgstr "Nouveau mot de passe"
 
-#: src/authentic2/forms/passwords.py:96 src/authentic2/forms/passwords.py:109
+#: src/authentic2/forms/passwords.py:97 src/authentic2/forms/passwords.py:110
 msgid "New password confirmation"
 msgstr "Confirmation du nouveau mot de passe"
 
-#: src/authentic2/forms/passwords.py:101 src/authentic2/forms/passwords.py:115
+#: src/authentic2/forms/passwords.py:102 src/authentic2/forms/passwords.py:116
 msgid "New password must differ from old password"
 msgstr "Le nouveau mot de passe doit être différent de l’ancien."
 
-#: src/authentic2/forms/passwords.py:107
+#: src/authentic2/forms/passwords.py:108
 msgid "Old password"
 msgstr "Ancien mot de passe"
 
@@ -1390,10 +1394,10 @@ msgid "password reset request"
 msgstr "demande de réinitialisation du mot de passe"
 
 #: src/authentic2/journal_event_types.py:157
-#, python-format
-msgid "password reset request with email \"%s\""
+msgid "password reset request with email (or username) \"%s\""
 msgstr ""
-"demande de réinitialisation du mot de passe pour l’adresse de courriel « %s »"
+"demande de réinitialisation du mot de passe pour l’adresse de courriel (ou le "
+"nom d'utilisateur) « %s »"
 
 #: src/authentic2/journal_event_types.py:163 src/authentic2/models.py:359
 #: src/authentic2/models.py:360
@@ -1406,9 +1410,10 @@ msgstr "échec de réinitialisation du mot de passe"
 
 #: src/authentic2/journal_event_types.py:182
 #, python-format
-msgid "password reset failure with email \"%s\""
+msgid "password reset failure with email (or username) \"%s\""
 msgstr ""
-"échec de réinitialisation du mot de passe pour l’adresse de courriel « %s »"
+"échec de réinitialisation du mot de passe pour l’adresse de courriel (ou le "
+"nom d'utilisateur) « %s »"
 
 #: src/authentic2/journal_event_types.py:188
 msgid "password change"
@@ -4651,7 +4656,7 @@ msgid "Logged out"
 msgstr "Déconnecté"
 
 #: src/authentic2/templates/registration/password_change_done.html:9
-#: src/authentic2/views.py:1302
+#: src/authentic2/views.py:1303
 msgid "Password changed"
 msgstr "Mot de passe modifié"
 
@@ -4692,19 +4697,19 @@ msgid "Password reset instructions"
 msgstr "Instructions de réinitialisation du mot de passe"
 
 #: src/authentic2/templates/registration/password_reset_instructions.html:10
-#, python-format
 msgid ""
 "\n"
-"    If your email address exists in our database, an email has been sent to "
-"%(email)s.\n"
+"    If a user account exists in our database with your email address (or "
+"your\n"
+"    username), an email has been sent to you.\n"
 "    "
 msgstr ""
 "\n"
-"    Si votre adresse électronique est présente dans notre base, un courriel "
-"a été envoyé à %(email)s.\n"
+"    Si un compte utilisateur est présent dans notre base avec votre adresse "
+"électronique (ou votre nom d'utilisateur), un courriel vous a été envoyé.\n"
 "    "
 
-#: src/authentic2/templates/registration/password_reset_instructions.html:15
+#: src/authentic2/templates/registration/password_reset_instructions.html:16
 msgid ""
 "\n"
 "    Follow the instructions in this email in order to choose a new "
@@ -4716,7 +4721,7 @@ msgstr ""
 "de passe.\n"
 "    "
 
-#: src/authentic2/templates/registration/password_reset_instructions.html:21
+#: src/authentic2/templates/registration/password_reset_instructions.html:22
 #: src/authentic2/templates/registration/registration_complete.html:23
 msgid ""
 "\n"
@@ -4729,7 +4734,7 @@ msgstr ""
 "    également être considéré comme un pourriel (spam) : n’oubliez pas\n"
 "    de regarder dans votre dossier « courriers indésirables ».        "
 
-#: src/authentic2/templates/registration/password_reset_instructions.html:27
+#: src/authentic2/templates/registration/password_reset_instructions.html:28
 #, python-format
 msgid ""
 "\n"
@@ -4746,7 +4751,7 @@ msgstr ""
 "    puis recommencez la procédure de réinitialisation du mot de passe.\n"
 "    "
 
-#: src/authentic2/templates/registration/password_reset_instructions.html:35
+#: src/authentic2/templates/registration/password_reset_instructions.html:36
 msgid "Back to login"
 msgstr "Retour à la page de connexion"
 
@@ -4805,7 +4810,7 @@ msgstr ""
 #: src/authentic2/templates/registration/registration_completion_choose.html:5
 #: src/authentic2/templates/registration/registration_completion_form.html:5
 #: src/authentic2/templates/registration/registration_completion_form.html:10
-#: src/authentic2/views.py:800
+#: src/authentic2/views.py:801
 msgid "Registration"
 msgstr "Création d’un compte"
 
@@ -5000,20 +5005,19 @@ msgstr ""
 "Cette page est périmée car vous vous êtes connecté entretemps; nous l’avons "
 "rechargée pour vous."
 
-#: src/authentic2/views.py:645 src/authentic2/views.py:730
+#: src/authentic2/views.py:645 src/authentic2/views.py:731
 msgid "Password Reset"
 msgstr "Réinitialisation du mot de passe"
 
-#: src/authentic2/views.py:681 src/authentic2/views.py:834
-#, python-format
+#: src/authentic2/views.py:682
 msgid ""
-"An email has already been sent to %s. Click \"Validate\" again if you really "
-"want it to be sent again."
+"An email has already been sent to your address. Click \"Validate\" again if "
+"you really want it to be sent again."
 msgstr ""
-"Un courriel a déjà été envoyé à %s. Cliquez sur « Valider » si vous voulez "
-"vraiment qu’il soit réenvoyé."
+"Un courriel a déjà été envoyé à votre adresse. Cliquez sur « Valider » si "
+"vous voulez vraiment qu’il soit réenvoyé."
 
-#: src/authentic2/views.py:692 src/authentic2/views.py:844
+#: src/authentic2/views.py:693 src/authentic2/views.py:845
 msgid ""
 "Multiple emails have already been sent to this address. Further attempts are "
 "blocked, please check your spam folder or try again later."
@@ -5022,7 +5026,7 @@ msgstr ""
 "envois sont bloqués, vous devriez vérifier votre dossier d’indésirables "
 "(spams, pourriels) ou réessayer plus tard."
 
-#: src/authentic2/views.py:701
+#: src/authentic2/views.py:702
 msgid ""
 "Multiple password reset attempts have already been made from this IP "
 "address. No further email will be sent, please check your spam folder or try "
@@ -5033,30 +5037,39 @@ msgstr ""
 "devriez vérifier votre dossier d’indésirables (spams, pourriels) ou "
 "réessayer plus tard."
 
-#: src/authentic2/views.py:743
+#: src/authentic2/views.py:744
 msgid "Password reset token is unknown or expired"
 msgstr "Le jeton de réinitialisation de mot de passe est inconnu ou expiré"
 
-#: src/authentic2/views.py:746
+#: src/authentic2/views.py:747
 msgid "Password reset token is invalid"
 msgstr "Le jeton de réinitialisation de mot de passe est invalide"
 
-#: src/authentic2/views.py:754
+#: src/authentic2/views.py:755
 msgid "User not found"
 msgstr "Utilisateur introuvable"
 
-#: src/authentic2/views.py:763
+#: src/authentic2/views.py:764
 msgid ""
 "It's not possible to reset your password. Please contact an administrator."
 msgstr ""
 "Votre mot de passe n’a pas pu être réinitialisé. Veuillez contacter votre "
 "administrateur."
 
-#: src/authentic2/views.py:770
+#: src/authentic2/views.py:771
 msgid "Enter new password"
 msgstr "Entrez un nouveau mot de passe"
 
-#: src/authentic2/views.py:852
+#: src/authentic2/views.py:835
+#, python-format
+msgid ""
+"An email has already been sent to %s. Click \"Validate\" again if you really "
+"want it to be sent again."
+msgstr ""
+"Un courriel a déjà été envoyé à %s. Cliquez sur « Valider » si vous voulez "
+"vraiment qu’il soit réenvoyé."
+
+#: src/authentic2/views.py:853
 msgid ""
 "Multiple registration attempts have already been made from this IP address. "
 "No further email will be sent, please check your spam folder or try again "
@@ -5067,62 +5080,62 @@ msgstr ""
 "vérifier votre dossier d’indésirables (spams, pourriels) ou réessayer plus "
 "tard."
 
-#: src/authentic2/views.py:921
+#: src/authentic2/views.py:922
 msgid "Your activation key is unknown or expired"
 msgstr "Votre clé d’activation est inconnue ou a expiré"
 
-#: src/authentic2/views.py:924
+#: src/authentic2/views.py:925
 msgid "Activation failed"
 msgstr "Échec à l’activation du compte"
 
-#: src/authentic2/views.py:1186
+#: src/authentic2/views.py:1187
 msgid "Request account deletion"
 msgstr "Demande de suppression de compte"
 
-#: src/authentic2/views.py:1198
+#: src/authentic2/views.py:1199
 msgid ""
 "An account deletion validation email has been sent to your email address."
 msgstr ""
 "Un message pour valider la suppression du compte a été envoyé à votre "
 "adresse électronique."
 
-#: src/authentic2/views.py:1209
+#: src/authentic2/views.py:1210
 msgid "Confirm account deletion"
 msgstr "Confirmation de la suppression du compte"
 
-#: src/authentic2/views.py:1221 src/authentic2/views.py:1235
+#: src/authentic2/views.py:1222 src/authentic2/views.py:1236
 msgid "This account has previously been deleted."
 msgstr "Ce compte a déjà été supprimé."
 
-#: src/authentic2/views.py:1224
+#: src/authentic2/views.py:1225
 msgid "This account is inactive, it cannot be deleted."
 msgstr "Ce compte est désactivé, il ne peut plus être supprimé."
 
-#: src/authentic2/views.py:1227
+#: src/authentic2/views.py:1228
 msgid "The account deletion request is too old, try again"
 msgstr "La demande de suppression de compte est expirée."
 
-#: src/authentic2/views.py:1229
+#: src/authentic2/views.py:1230
 msgid "The account deletion request is invalid, try again"
 msgstr "La demande de suppression de compte n’est pas valide."
 
-#: src/authentic2/views.py:1231
+#: src/authentic2/views.py:1232
 msgid "The account deletion request was not on this site, try again"
 msgstr ""
 "Votre demande de suppression de compte vient d’un autre site que celui-ci."
 
-#: src/authentic2/views.py:1255
+#: src/authentic2/views.py:1256
 msgid "Deletion performed."
 msgstr "Suppression effectuée."
 
-#: src/authentic2/views.py:1280
+#: src/authentic2/views.py:1281
 msgid "Password Change"
 msgstr "Changement de mot de passe"
 
-#: src/authentic2/views.py:1291
+#: src/authentic2/views.py:1292
 msgid "Password change is forbidden"
 msgstr "Changement de mot de passe interdit"
 
-#: src/authentic2/views.py:1336
+#: src/authentic2/views.py:1337
 msgid "Consent Management"
 msgstr "Gestion des autorisations"
diff --git a/src/authentic2/templates/registration/password_reset_instructions.html b/src/authentic2/templates/registration/password_reset_instructions.html
index c6f54394..2ad45210 100644
--- a/src/authentic2/templates/registration/password_reset_instructions.html
+++ b/src/authentic2/templates/registration/password_reset_instructions.html
@@ -7,8 +7,9 @@
 
 {% block content %}
   <p><strong>
-    {% blocktrans with email=request.session.reset_email %}
-    If your email address exists in our database, an email has been sent to {{ email }}.
+    {% blocktrans %}
+    If a user account exists in our database with your email address (or your
+    username), an email has been sent to you.
     {% endblocktrans %}
   </strong></p>
   <p><strong>
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..9fa32479 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -653,6 +653,13 @@ class PasswordResetView(FormView):
             'registration/password_reset_form.html',
         ]
 
+    def get_form(self, **kwargs):
+        """Return an instance of the form to be used in this view."""
+        form = super(PasswordResetView, self).get_form(**kwargs)
+        if app_settings.A2_RESET_PASSWORD_ID_LABEL:
+            form.fields['email'].label = app_settings.A2_RESET_PASSWORD_ID_LABEL
+        return form
+
     def get_form_kwargs(self, **kwargs):
         kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs)
         initial = kwargs.setdefault('initial', {})
@@ -671,15 +678,16 @@ 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):
             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
+                _('An email has already been sent to your address. Click "Validate" again if '
+                  'you really want it to be sent again.')
             )
             return self.form_invalid(form)
         self.request.session[resend_key] = False
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

