From 07c839974f4ea17223e1027975d870dbcbbd5749 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Tue, 17 Jan 2023 17:26:41 +0100
Subject: [PATCH 5/5] auth_fc: account verification view (#73148)

---
 src/authentic2_auth_fc/urls.py  |  6 +++
 src/authentic2_auth_fc/views.py | 35 ++++++++++++++
 tests/auth_fc/test_auth_fc.py   | 84 +++++++++++++++++++++++++++++++++
 3 files changed, 125 insertions(+)

diff --git a/src/authentic2_auth_fc/urls.py b/src/authentic2_auth_fc/urls.py
index 746989270..7206dc126 100644
--- a/src/authentic2_auth_fc/urls.py
+++ b/src/authentic2_auth_fc/urls.py
@@ -14,6 +14,7 @@
 # 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 django.contrib.auth.decorators import login_required
 from django.urls import include, path, re_path
 
 from . import views
@@ -21,6 +22,11 @@ from . import views
 fcpatterns = [
     path('callback/', views.login_or_link, name='fc-login-or-link'),
     path('callback_logout/', views.logout, name='fc-logout'),
+    re_path(
+        r'^verify/(?P<token>[A-Za-z0-9_ -]+)/$',
+        login_required(views.FcEmailVerificationView.as_view()),
+        name='fc-verification',
+    ),
 ]
 
 urlpatterns = [
diff --git a/src/authentic2_auth_fc/views.py b/src/authentic2_auth_fc/views.py
index 74accce93..9f020bb5b 100644
--- a/src/authentic2_auth_fc/views.py
+++ b/src/authentic2_auth_fc/views.py
@@ -32,6 +32,7 @@ from django.template.loader import render_to_string
 from django.template.response import TemplateResponse
 from django.urls import reverse
 from django.utils.http import urlencode
+from django.utils.timezone import now
 from django.utils.translation import gettext as _
 from django.views.generic import FormView, View
 from requests_oauthlib import OAuth2Session
@@ -673,3 +674,37 @@ class LogoutReturnView(View):
 
 
 logout = LogoutReturnView.as_view()
+
+
+class FcEmailVerificationView(View):
+    def get(self, request, *args, **kwargs):
+        token_value = kwargs.get('token')
+        try:
+            token = models.FcEmailVerificationToken.objects.get(value=token_value)
+        except models.FcEmailVerificationToken.DoesNotExist:
+            messages.error(
+                request,
+                _('Invalid account verification request.'),
+            )
+            return utils_misc.redirect(request, 'account_management')
+
+        if token.user != request.user:
+            messages.error(
+                request,
+                _('Invalid account verification request.'),
+            )
+            return utils_misc.redirect(request, 'account_management')
+
+        if token.expires < now():
+            messages.error(
+                request,
+                _('Your account verification request has expired.'),
+            )
+            return utils_misc.redirect(request, 'account_management')
+
+        token.user.set_email_verified(True, source='fc')
+        messages.info(
+            request,
+            _('Your account is now verified.'),
+        )
+        return utils_misc.redirect(request, 'account_management')
diff --git a/tests/auth_fc/test_auth_fc.py b/tests/auth_fc/test_auth_fc.py
index a7a56ff8f..a9e75aeea 100644
--- a/tests/auth_fc/test_auth_fc.py
+++ b/tests/auth_fc/test_auth_fc.py
@@ -112,6 +112,7 @@ def test_create(settings, app, franceconnect, hooks, service, mailoutbox):
 
     assert User.objects.count() == 0
     assert Event.objects.which_references(service).count() == 0
+    assert models.FcEmailVerificationToken.objects.count() == 0
     response = franceconnect.handle_authorization(app, response.location, status=302)
     assert 'fc-state' not in app.cookies
     assert User.objects.count() == 1
@@ -125,10 +126,14 @@ def test_create(settings, app, franceconnect, hooks, service, mailoutbox):
     # check registration email
     assert len(mailoutbox) == 1
     assert mailoutbox[0].subject == 'Account creation using FranceConnect'
+    assert models.FcEmailVerificationToken.objects.count() == 1
+    token = models.FcEmailVerificationToken.objects.get()
+    assert len(str(token.value)) == 36
     for body in (mailoutbox[0].body, mailoutbox[0].alternatives[0][0]):
         assert 'Hi Ÿuñe Frédérique,' in body
         assert 'You have just created an account using FranceConnect.' in body
         assert 'You can complete your account validation' in body
+        assert f'https://testserver/fc/verify/{token.value}/' in body
 
     assert user.verified_attributes.first_name == 'Ÿuñe'
     assert user.verified_attributes.last_name == 'Frédérique'
@@ -140,6 +145,13 @@ def test_create(settings, app, franceconnect, hooks, service, mailoutbox):
     assert last_name_value.last_verified_on
     assert first_name_value.verification_sources == ['fc']
     assert last_name_value.verification_sources == ['fc']
+
+    assert not user.email_verified
+    resp = app.get(f'https://testserver/fc/verify/{token.value}/').follow()
+    assert resp.pyquery('li.info').text() == 'Your account is now verified.'
+    user.refresh_from_db()
+    assert user.email_verified
+
     assert path(response.location) == '/idp/'
     assert hooks.event[1]['kwargs']['name'] == 'login'
     assert hooks.event[1]['kwargs']['service'] == service
@@ -170,6 +182,78 @@ def test_create(settings, app, franceconnect, hooks, service, mailoutbox):
     assert 'Your account link to FranceConnect has been deleted' in response
 
 
+def test_email_verification_anonymous_user(app, simple_user):
+    token = models.FcEmailVerificationToken.create(user=simple_user)
+    token.sent = True
+    token.save()
+
+    resp = app.get(f'https://testserver/fc/verify/{token.value}/')
+    assert resp.location == f'/login/?next=/fc/verify/{token.value}/'
+    simple_user.refresh_from_db()
+    assert not simple_user.email_verified
+
+    resp = resp.follow()
+    resp.form.set('username', simple_user.username)
+    resp.form.set('password', simple_user.username)
+    resp = resp.form.submit(name='login-password-submit')
+    assert resp.location == f'/fc/verify/{token.value}/'
+    resp = resp.follow()
+    assert resp.location == '/accounts/'
+    resp = resp.follow()
+    assert resp.pyquery('li.info').text() == 'Your account is now verified.'
+    simple_user.refresh_from_db()
+    assert simple_user.email_verified
+
+
+def test_email_verification_wrong_link(settings, app, franceconnect, hooks, service):
+    set_service(app, service)
+    response = app.get('/login/?next=/idp/')
+    response = response.click(href='callback')
+    franceconnect.handle_authorization(app, response.location, status=302)
+    user = User.objects.get()
+
+    # user is logged yet clicks on a wrong link
+    resp = app.get('https://testserver/fc/verify/01234567-aaaa-bbbb-cccc-abcdabdcabdc/').follow()
+    assert resp.pyquery('li.error').text() == 'Invalid account verification request.'
+    user.refresh_from_db()
+    assert not user.email_verified
+
+
+def test_email_verification_expired(settings, app, franceconnect, hooks, service, freezer):
+    set_service(app, service)
+    response = app.get('/login/?next=/idp/')
+    response = response.click(href='callback')
+    response = franceconnect.handle_authorization(app, response.location, status=302)
+    user = User.objects.get()
+    token = models.FcEmailVerificationToken.objects.get()
+    assert not user.email_verified
+
+    freezer.move_to(datetime.timedelta(hours=50))  # too late by two hours
+    resp = app.get(f'https://testserver/fc/verify/{token.value}/').follow()
+    assert not resp.pyquery('li.info')
+    assert resp.pyquery('li.error').text() == 'Your account verification request has expired.'
+    user.refresh_from_db()
+    assert not user.email_verified
+
+
+def test_email_verification_wrong_user(settings, app, franceconnect, user_ou1, hooks, service, mailoutbox):
+    set_service(app, service)
+    response = app.get('/login/?next=/idp/')
+    response = response.click(href='callback')
+    response = franceconnect.handle_authorization(app, response.location, status=302)
+    user = User.objects.get(ou=get_default_ou())
+    token = models.FcEmailVerificationToken.objects.get()
+    token.user = user_ou1
+    token.save()
+
+    assert not user.email_verified
+    resp = app.get(f'https://testserver/fc/verify/{token.value}/').follow()
+    assert not resp.pyquery('li.info')
+    assert resp.pyquery('li.error').text() == 'Invalid account verification request.'
+    user.refresh_from_db()
+    assert not user.email_verified
+
+
 def test_create_no_unicode_collision(settings, app, franceconnect, hooks, service):
     settings.A2_EMAIL_IS_UNIQUE = True
     set_service(app, service)
-- 
2.39.0

