From 966f3afcc87bbe4052c16bf897fac362888892cc Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 21 Dec 2022 10:42:16 +0100
Subject: [PATCH 5/5] provide generic input code logic (#69890)

Suited to both registration & password-change actions.
---
 src/authentic2/forms/registration.py          |   8 +-
 ...stration_code.html => sms_input_code.html} |   2 +-
 src/authentic2/urls.py                        |   4 +-
 src/authentic2/views.py                       |  50 ++++---
 tests/test_password_reset.py                  | 124 ++++++++++++++++++
 tests/test_registration.py                    |  16 +--
 6 files changed, 168 insertions(+), 36 deletions(-)
 rename src/authentic2/templates/registration/{sms_input_registration_code.html => sms_input_code.html} (87%)

diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py
index 0e71cf2d5..eaf2438a2 100644
--- a/src/authentic2/forms/registration.py
+++ b/src/authentic2/forms/registration.py
@@ -195,9 +195,9 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
         return self.cleaned_data
 
 
-class InputRegistrationCodeForm(Form):
-    registration_code = CharField(
-        label=_('Registration code'),
-        help_text=_('The registration code you received by SMS.'),
+class InputSMSCodeForm(Form):
+    sms_code = CharField(
+        label=_('SMS code'),
+        help_text=_('The code you received by SMS.'),
         max_length=settings.SMS_CODE_LENGTH,
     )
diff --git a/src/authentic2/templates/registration/sms_input_registration_code.html b/src/authentic2/templates/registration/sms_input_code.html
similarity index 87%
rename from src/authentic2/templates/registration/sms_input_registration_code.html
rename to src/authentic2/templates/registration/sms_input_code.html
index 5b378acec..2fc102544 100644
--- a/src/authentic2/templates/registration/sms_input_registration_code.html
+++ b/src/authentic2/templates/registration/sms_input_code.html
@@ -7,7 +7,7 @@
 
 {% block content %}
   <form method="post" action=".">
-    <p>{% blocktrans trimmed %}Input your account activation code.{% endblocktrans %}</p>
+    <p>{% blocktrans trimmed %}Input the code you received by SMS.{% endblocktrans %}</p>
     <p>
       {% blocktrans count counter=duration %}
         Your code is valid for the next minute.
diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py
index 94d08ea03..e6c267b55 100644
--- a/src/authentic2/urls.py
+++ b/src/authentic2/urls.py
@@ -116,8 +116,8 @@ urlpatterns = [
     ),
     re_path(
         '^register/input_code/(?P<token>[A-Za-z0-9_ -]+)/$',
-        views.input_registration_code,
-        name='input_registration_code',
+        views.input_sms_code,
+        name='input_sms_code',
     ),
     # Password reset
     re_path(
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 545cbf795..d1c12df48 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -849,7 +849,7 @@ class PasswordResetView(FormView):
         if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not self.code:  # user input is email
             return reverse('password_reset_instructions')
         else:  # user input is phone number
-            return reverse('input_registration_code', kwargs={'token': self.code.url_token})
+            return reverse('input_sms_code', kwargs={'token': self.code.url_token})
 
     def get_template_names(self):
         return [
@@ -1206,7 +1206,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
             messages.warning(
                 self.request,
                 _(
-                    'Something went wrong while trying to send the SMS registration code to you.'
+                    'Something went wrong while trying to send the SMS code to you.'
                     ' Please contact your administrator and try again later.'
                 ),
             )
@@ -1215,7 +1215,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
         self.request.session['registered_phone'] = phone
         return utils_misc.redirect(
             self.request,
-            reverse('input_registration_code', kwargs={'token': code.url_token}),
+            reverse('input_sms_code', kwargs={'token': code.url_token}),
             params={REDIRECT_FIELD_NAME: self.next_url, 'token': code.url_token},
         )
 
@@ -1294,20 +1294,20 @@ class BaseRegistrationView(HomeURLMixin, FormView):
         return context
 
 
-class InputRegistrationCodeView(cbv.ValidateCSRFMixin, FormView):
-    template_name = 'registration/sms_input_registration_code.html'
-    form_class = registration_forms.InputRegistrationCodeForm
+class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
+    template_name = 'registration/sms_input_code.html'
+    form_class = registration_forms.InputSMSCodeForm
     success_url = '/accounts/'
-    title = _('Account activation')
+    title = _('SMS code validation')
 
     def dispatch(self, request, *args, **kwargs):
         token = kwargs.get('token')
         try:
             self.code = models.SMSCode.objects.get(url_token=token)
         except models.SMSCode.DoesNotExist:
-            return HttpResponseBadRequest(_('Invalid account activation request'))
-        if not self.code.sent:
-            return HttpResponseBadRequest(_('Invalid account activation code'))
+            return HttpResponseBadRequest(_('Invalid request'))
+        if not self.code.sent and not self.code.fake:
+            return HttpResponseBadRequest(_('Invalid code'))
         return super().dispatch(request, *args, **kwargs)
 
     def get_context_data(self, **kwargs):
@@ -1324,35 +1324,43 @@ class InputRegistrationCodeView(cbv.ValidateCSRFMixin, FormView):
     @atomic(savepoint=False)
     def form_valid(self, form):
         super().form_valid(form)
-        registration_code = form.cleaned_data.pop('registration_code')
-        if self.code.value != registration_code:
+        sms_code = form.cleaned_data.pop('sms_code')
+        if self.code.value != sms_code or self.code.fake:
             # TODO ratelimit on erroneous code inputs(?)
             # (code expires after 120 seconds)
-            form.add_error('registration_code', _('Wrong registration code.'))
+            form.add_error('sms_code', _('Wrong SMS code.'))
             return self.form_invalid(form)
         if self.code.expires < timezone.now():
-            form.add_error('registration_code', _('The code has expired.'))
+            form.add_error('sms_code', _('The code has expired.'))
             return self.form_invalid(form)
         Lock.lock_identifier(self.code.phone)
         content = {
             # TODO missing ou registration management
             'authentication_method': 'phone',
             'phone': self.code.phone,
+            'user': self.code.user.pk if self.code.user else None,
         }
         # create token to process final account activation and user-defined attributes
         token = models.Token.create(
-            kind='registration',
+            kind=self.code.kind,
             content=content,
             duration=120,
         )
-        return utils_misc.redirect(
-            # TODO next_url management throughout account creation process
-            self.request,
-            reverse('registration_activate', kwargs={'registration_token': token.uuid}),
-        )
+
+        # TODO next_url management throughout account creation process
+        if self.code.kind == models.SMSCode.KIND_REGISTRATION:
+            return utils_misc.redirect(
+                self.request,
+                reverse('registration_activate', kwargs={'registration_token': token.uuid}),
+            )
+        elif self.code.kind == models.SMSCode.KIND_PASSWORD_LOST:
+            return utils_misc.redirect(
+                self.request,
+                reverse('password_reset_confirm', kwargs={'token': token.uuid}),
+            )
 
 
-input_registration_code = InputRegistrationCodeView.as_view()
+input_sms_code = InputSMSCodeView.as_view()
 
 
 class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView):
diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py
index 7002dbb28..802d27389 100644
--- a/tests/test_password_reset.py
+++ b/tests/test_password_reset.py
@@ -13,15 +13,33 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import json
+
 import pytest
+from django.contrib.auth import authenticate
 from django.test.utils import override_settings
 from django.urls import reverse
+from httmock import HTTMock, remember_called, urlmatch
 
+from authentic2.models import SMSCode, Token
 from authentic2.utils.misc import send_password_reset_mail
 
 from . import utils
 
 
+@urlmatch(netloc='foo.whatever.none')
+@remember_called
+def sms_service_mock(url, request):
+    return {
+        'content': {},
+        'headers': {
+            'content-type': 'application/json',
+        },
+        'status_code': 200,
+    }
+
+
 def test_send_password_reset_email(app, simple_user, mailoutbox):
     assert len(mailoutbox) == 0
     with utils.run_on_commit_hooks():
@@ -41,6 +59,112 @@ def test_send_password_reset_email(app, simple_user, mailoutbox):
     utils.assert_event('user.password.reset', user=simple_user, session=app.session)
 
 
+def test_send_password_reset_by_sms_code_improperly_configured(app, nomail_user, settings):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    assert not SMSCode.objects.count()
+    assert not Token.objects.count()
+
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('phone_1', '0123456789')
+    resp = resp.form.submit().follow().maybe_follow()
+    assert 'Something went wrong while trying to send' in resp.pyquery('li.error').text()
+
+
+def test_send_password_reset_by_sms_code(app, nomail_user, settings):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    code_length = settings.SMS_CODE_LENGTH
+    assert not SMSCode.objects.count()
+    assert not Token.objects.count()
+
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('phone_1', '0123456789')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().follow().maybe_follow()
+        body = json.loads(sms_service_mock.call['requests'][0].body)
+    assert body['message'].startswith('Your code is')
+    code = SMSCode.objects.get()
+    assert body['message'][-code_length:] == code.value
+    assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text
+    assert "The code you received by SMS." in resp.text
+    resp.form.set('sms_code', code.value)
+    resp = resp.form.submit().follow()
+    assert Token.objects.count() == 1
+
+    assert authenticate(username='user', password='1234==aA') is None
+    resp.form.set('new_password1', '1234==aA')
+    resp.form.set('new_password2', '1234==aA')
+    resp.form.submit()
+    # verify user is logged
+    assert str(app.session['_auth_user_id']) == str(nomail_user.pk)
+    user = authenticate(username='user', password='1234==aA')
+    assert user == nomail_user
+
+    with override_settings(A2_USER_CAN_RESET_PASSWORD=False):
+        url = reverse('password_reset')
+        app.get(url, status=404)
+
+
+def test_password_reset_empty_form(app, settings):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp = resp.form.submit()
+    assert 'There were errors processing your form.' in resp.pyquery('div.errornotice').text()
+    assert (
+        'Please provide an email address or a mobile phone number.' in resp.pyquery('div.errornotice').text()
+    )
+
+
+def test_password_reset_both_fields_filled_email_precedence(app, simple_user, settings, mailoutbox):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('email', simple_user.email)
+    resp.form.set('phone_1', '0123456789')
+    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 len(mailoutbox) == 1
+    assert not SMSCode.objects.count()
+
+
+def test_send_password_reset_by_sms_code_erroneous_phone_number(app, nomail_user, settings):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    assert not SMSCode.objects.count()
+    assert not Token.objects.count()
+
+    url = reverse('password_reset')
+    resp = app.get(url, status=200)
+    resp.form.set('phone_1', '0111111111')
+    resp = resp.form.submit().follow().maybe_follow()
+    assert 'Something went wrong while trying to send' not in resp.text
+    assert 'error' not in resp.text
+    assert resp.pyquery('title').text() == 'SMS code validation'
+    code = SMSCode.objects.get()
+    assert code.fake
+    resp.form.set('sms_code', 'whatever')
+    resp = resp.form.submit()
+    assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.'
+    # even if the correct value is guessed, the code is still fake & not valid whatsoever
+    resp.form.set('sms_code', code.value)
+    resp = resp.form.submit()
+    assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.'
+    assert not Token.objects.count()
+
+
 def test_reset_by_email(app, simple_user, mailoutbox, settings):
     url = reverse('password_reset')
     resp = app.get(url, status=200)
diff --git a/tests/test_registration.py b/tests/test_registration.py
index b3b6ec966..7b3c5777e 100644
--- a/tests/test_registration.py
+++ b/tests/test_registration.py
@@ -979,10 +979,10 @@ def test_phone_registration_wrong_code(app, db, settings):
     resp.form.set('phone_1', '612345678')
     with HTTMock(sms_service_mock):
         resp = resp.form.submit().follow()
-    resp.form.set('registration_code', 'abc')
+    resp.form.set('sms_code', 'abc')
     resp = resp.form.submit()
     assert not Token.objects.count()
-    assert resp.pyquery('li')[0].text_content() == 'Wrong registration code.'
+    assert resp.pyquery('li')[0].text_content() == 'Wrong SMS code.'
 
 
 def test_phone_registration_expired_code(app, db, settings, freezer):
@@ -994,7 +994,7 @@ def test_phone_registration_expired_code(app, db, settings, freezer):
     with HTTMock(sms_service_mock):
         resp = resp.form.submit().follow()
     code = SMSCode.objects.get()
-    resp.form.set('registration_code', code.value)
+    resp.form.set('sms_code', code.value)
     freezer.move_to(timedelta(hours=1))
     resp = resp.form.submit()
     assert not Token.objects.count()
@@ -1010,7 +1010,7 @@ def test_phone_registration_cancel(app, db, settings, freezer):
     with HTTMock(sms_service_mock):
         resp = resp.form.submit().follow()
     code = SMSCode.objects.get()
-    resp.form.set('registration_code', code.value)
+    resp.form.set('sms_code', code.value)
     resp.form.submit('cancel').follow()
     assert not Token.objects.count()
     assert not SMSCode.objects.count()
@@ -1027,7 +1027,7 @@ def test_phone_registration_improperly_configured(app, db, settings, freezer, ca
     assert not Token.objects.count()
     assert not SMSCode.objects.count()
     assert (
-        "Something went wrong while trying to send the SMS registration code to you"
+        "Something went wrong while trying to send the SMS code to you"
         in resp.pyquery('li.warning')[0].text_content()
     )
     assert caplog.records[0].message == 'settings.SMS_URL is not set'
@@ -1048,7 +1048,7 @@ def test_phone_registration_connection_error(app, db, settings, freezer, caplog)
         mock_send.return_value = mock_response
         resp = resp.form.submit().follow().maybe_follow()
     assert (
-        "Something went wrong while trying to send the SMS registration code to you"
+        "Something went wrong while trying to send the SMS code to you"
         in resp.pyquery('li.warning')[0].text_content()
     )
     assert (
@@ -1073,8 +1073,8 @@ def test_phone_registration(app, db, settings):
     code = SMSCode.objects.get()
     assert body['message'][-code_length:] == code.value
     assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text
-    assert "The registration code you received by SMS." in resp.text
-    resp.form.set('registration_code', code.value)
+    assert "The code you received by SMS." in resp.text
+    resp.form.set('sms_code', code.value)
     resp = resp.form.submit().follow()
     assert Token.objects.count() == 1
 
-- 
2.39.0

