From afe353ac52249ca4b4a2099b0c7fb6da9adbb7aa Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 12 Oct 2022 11:48:20 +0200
Subject: [PATCH 10/10] views: make sms code trigger a standard registration
 finalization (#69223)

---
 src/authentic2/views.py    |  38 ++++++++---
 tests/settings.py          |  21 ++++++
 tests/test_registration.py | 135 ++++++++++++++++++++++++++++++++++++-
 3 files changed, 185 insertions(+), 9 deletions(-)

diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 8676feead..26829ab03 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -1307,13 +1307,24 @@ class RegistrationCompletionView(CreateView):
         request.token = self.token
 
         self.authentication_method = self.token.get('authentication_method', 'email')
-        self.email = self.token['email']
-        Lock.lock_email(self.email)
         if 'ou' in self.token:
             self.ou = OU.objects.get(pk=self.token['ou'])
         else:
             self.ou = get_default_ou()
-        self.users = User.objects.filter(email__iexact=self.email).order_by('date_joined')
+
+        if self.token.get('email', None):
+            self.email = self.token['email']
+            qs_filter = {'email__iexact': self.email}
+            Lock.lock_email(self.email)
+        elif self.token.get('phone', None):
+            self.phone = self.token['phone']
+            qs_filter = {'phone': self.phone}
+            Lock.lock_identifier(self.phone)
+        else:
+            messages.warning(request, _('Activation failed'))
+            return utils_misc.redirect(request, 'registration_register')
+
+        self.users = User.objects.filter(**qs_filter).order_by('date_joined')
         if self.ou:
             self.users = self.users.filter(ou=self.ou)
         self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE
@@ -1340,8 +1351,10 @@ class RegistrationCompletionView(CreateView):
         if app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT:
             help_texts['username'] = app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT
         required = list(app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + list(required_fields)
-        if 'email' in fields:
-            fields.remove('email')
+        # identifier fields don't belong here
+        for field in ('email', 'phone'):
+            if field in fields:
+                fields.remove(field)
         for field in self.token.get('skip_fields') or []:
             if field in fields:
                 fields.remove(field)
@@ -1385,7 +1398,12 @@ class RegistrationCompletionView(CreateView):
         else:
             ou = get_default_ou()
 
-        attributes = {'email': self.email, 'ou': ou}
+        attributes = {'ou': ou}
+        if hasattr(self, 'email'):
+            attributes['email'] = self.email
+        if hasattr(self, 'phone'):
+            attributes['phone'] = self.phone
+
         for key in self.token:
             if key in app_settings.A2_PRE_REGISTRATION_FIELDS:
                 attributes[key] = self.token[key]
@@ -1409,7 +1427,7 @@ class RegistrationCompletionView(CreateView):
             kwargs['instance'] = User.objects.get(id=self.token.get('user_id'))
         else:
             init_kwargs = {}
-            for key in ('email', 'first_name', 'last_name', 'ou'):
+            for key in ('email', 'first_name', 'last_name', 'ou', 'phone'):
                 if key in attributes:
                     init_kwargs[key] = attributes[key]
             kwargs['instance'] = get_user_model()(**init_kwargs)
@@ -1425,7 +1443,10 @@ class RegistrationCompletionView(CreateView):
         ctx = super().get_context_data(**kwargs)
         ctx['token'] = self.token
         ctx['users'] = self.users
-        ctx['email'] = self.email
+        if hasattr(self, 'email'):
+            ctx['email'] = self.email
+        if hasattr(self, 'phone'):
+            ctx['phone'] = self.phone
         ctx['email_is_unique'] = self.email_is_unique
         ctx['create'] = 'create' in self.request.GET
         return ctx
@@ -1544,6 +1565,7 @@ class RegistrationCompletionView(CreateView):
         return utils_misc.redirect(request, self.get_success_url())
 
     def send_registration_success_email(self, user):
+        # user may not have a registered email by then
         if not user.email:
             return
 
diff --git a/tests/settings.py b/tests/settings.py
index 89e63fa3c..c9a456fdd 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -46,6 +46,27 @@ ALLOWED_HOSTS = ALLOWED_HOSTS + [  # pylint: disable=used-before-assignment
     'cache2.example.com',
 ]
 
+KNOWN_SERVICES = {
+    'wcs': {
+        'default': {
+            'title': 'test',
+            'url': 'http://example.org',
+            'secret': 'chrono',
+            'orig': 'chrono',
+            'backoffice-menu-url': 'http://example.org/manage/',
+        }
+    },
+    'passerelle': {
+        'default': {
+            'title': 'test',
+            'url': 'https://foo.whatever.none',
+            'secret': 'passerelle',
+            'orig': 'passerelle',
+            'backoffice-menu-url': 'http://foo.whatever.none/manage/',
+        }
+    },
+}
+
 A2_AUTH_KERBEROS_ENABLED = False
 
 A2_VALIDATE_EMAIL_DOMAIN = False
diff --git a/tests/test_registration.py b/tests/test_registration.py
index 4167d6dc8..d15b3060c 100644
--- a/tests/test_registration.py
+++ b/tests/test_registration.py
@@ -14,19 +14,24 @@
 # 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/>.
 
-from datetime import date
+import json
+from datetime import date, timedelta
 from unittest import mock
 from urllib.parse import urlparse
 
+import mock
+import requests
 from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
 from django.urls import reverse
 from django.utils.http import urlquote
+from httmock import HTTMock, remember_called, urlmatch
 
 from authentic2 import models
 from authentic2.a2_rbac.utils import get_default_ou
 from authentic2.apps.journal.models import Event
 from authentic2.forms.profile import modelform_factory
 from authentic2.forms.registration import RegistrationCompletionForm
+from authentic2.models import SMSCode, Token
 from authentic2.utils import misc as utils_misc
 from authentic2.validators import EmailValidator
 
@@ -951,3 +956,131 @@ def test_registration_completion(db, app, mailoutbox):
     resp = resp.form.submit()
 
     assert 'This password is not strong enough' in resp.text
+
+
+@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_phone_registration_wrong_code(app, db, settings):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().follow()
+    resp.form.set('registration_code', 'abc')
+    resp = resp.form.submit()
+    assert not Token.objects.count()
+    assert resp.pyquery('li')[0].text_content() == 'Wrong registration code.'
+
+
+def test_phone_registration_expired_code(app, db, settings, freezer):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().follow()
+    code = SMSCode.objects.get()
+    resp.form.set('registration_code', code.value)
+    freezer.move_to(timedelta(hours=1))
+    resp = resp.form.submit()
+    assert not Token.objects.count()
+    assert resp.pyquery('li')[0].text_content() == 'The code has expired.'
+
+
+def test_phone_registration_cancel(app, db, settings, freezer):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().follow()
+    code = SMSCode.objects.get()
+    resp.form.set('registration_code', code.value)
+    resp.form.submit('cancel').follow()
+    assert not Token.objects.count()
+    assert not SMSCode.objects.count()
+
+
+def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = ''
+
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().follow().maybe_follow()
+    assert not Token.objects.count()
+    assert not SMSCode.objects.count()
+    assert (
+        "Something went wrong while trying to send the SMS registration code to you"
+        in resp.pyquery('li.warning')[0].text_content()
+    )
+    assert caplog.records[0].message == 'settings.SMS_URL is not set'
+
+
+def test_phone_registration_connection_error(app, db, settings, freezer, caplog):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+    settings.SMS_URL = 'https://foo.whatever.none/'
+
+    def mocked_requests_connection_error(*args, **kwargs):
+        raise requests.ConnectionError('unreachable')
+
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with mock.patch('authentic2.utils.misc.Requests.send') as mock_send:
+        mock_send.side_effect = mocked_requests_connection_error
+        mock_response = mock.Mock(status_code=200)
+        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"
+        in resp.pyquery('li.warning')[0].text_content()
+    )
+    assert (
+        caplog.records[0].message
+        == 'sms registration to +33612345678 using https://foo.whatever.none/ failed: unreachable'
+    )
+
+
+def test_phone_registration(app, db, 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()
+    resp = app.get(reverse('registration_register'))
+    resp.form.set('phone_1', '612345678')
+    with HTTMock(sms_service_mock):
+        resp = resp.form.submit().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
+    resp.form.set('registration_code', code.value)
+    resp = resp.form.submit().follow()
+    assert Token.objects.count() == 1
+
+    resp.form.set('password1', 'Password0')
+    resp.form.set('password2', 'Password0')
+    resp.form.set('first_name', 'John')
+    resp.form.set('last_name', 'Doe')
+    resp = resp.form.submit().follow()
+    assert "You have just created an account" in resp.text
+
+    user = User.objects.get(first_name='John', last_name='Doe')
+    assert user.phone == '+33612345678'
-- 
2.37.2

