From 2cbe8fbff59aad548c3976e8cd6fc48b6461302a Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 8 Jun 2022 10:29:21 +0200
Subject: [PATCH] custom_user: set email verification sources (#66053)

---
 src/authentic2/csv_import.py                  |  2 +-
 .../0032_user_email_verified_sources.py       | 24 ++++++++++++++
 src/authentic2/custom_user/models.py          | 30 ++++++++++++------
 src/authentic2/forms/registration.py          |  2 +-
 src/authentic2/views.py                       |  4 +--
 src/authentic2_auth_oidc/backends.py          |  2 +-
 tests/test_custom_user.py                     | 31 +++++++++++++++++++
 7 files changed, 81 insertions(+), 14 deletions(-)
 create mode 100644 src/authentic2/custom_user/migrations/0032_user_email_verified_sources.py

diff --git a/src/authentic2/csv_import.py b/src/authentic2/csv_import.py
index 8e20342e0..28624d105 100644
--- a/src/authentic2/csv_import.py
+++ b/src/authentic2/csv_import.py
@@ -712,7 +712,7 @@ class UserCsvImporter:
                 if getattr(user, cell.header.name) != cell.value:
                     setattr(user, cell.header.name, cell.value)
                     if cell.header.name == 'email' and cell.header.verified:
-                        user.set_email_verified(True)
+                        user.set_email_verified(True, source='csv')
                     cell.action = 'updated'
                     continue
             cell.action = 'nothing'
diff --git a/src/authentic2/custom_user/migrations/0032_user_email_verified_sources.py b/src/authentic2/custom_user/migrations/0032_user_email_verified_sources.py
new file mode 100644
index 000000000..064c39002
--- /dev/null
+++ b/src/authentic2/custom_user/migrations/0032_user_email_verified_sources.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2.26 on 2022-06-08 12:33
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('custom_user', '0031_profile_email'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='email_verified_sources',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.CharField(max_length=63),
+                default=list,
+                size=None,
+                verbose_name='email verification sources',
+            ),
+        ),
+    ]
diff --git a/src/authentic2/custom_user/models.py b/src/authentic2/custom_user/models.py
index aac78a30d..2c98bafa0 100644
--- a/src/authentic2/custom_user/models.py
+++ b/src/authentic2/custom_user/models.py
@@ -20,6 +20,7 @@ import datetime
 import os
 import uuid
 
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
 from django.core.mail import send_mail
 from django.db import models, transaction
@@ -168,6 +169,11 @@ class User(AbstractBaseUser, PermissionMixin):
     email_verified_date = models.DateTimeField(
         default=None, blank=True, null=True, verbose_name=_('email verified date')
     )
+    email_verified_sources = ArrayField(
+        verbose_name=_('email verification sources'),
+        base_field=models.CharField(max_length=63),
+        default=list,
+    )
     is_staff = models.BooleanField(
         _('staff status'),
         default=False,
@@ -449,16 +455,22 @@ class User(AbstractBaseUser, PermissionMixin):
     def get_absolute_url(self):
         return reverse('a2-manager-user-detail', kwargs={'pk': self.pk})
 
-    def set_email_verified(self, value):
-        if isinstance(value, datetime.datetime):
-            self.email_verified = True
-            self.email_verified_date = value
-        elif bool(value):
-            self.email_verified = True
-            self.email_verified_date = timezone.now()
+    def set_email_verified(self, value, source=None):
+        if bool(value):
+            if isinstance(value, datetime.datetime):
+                self.email_verified = True
+                self.email_verified_date = value
+            else:
+                self.email_verified = True
+                self.email_verified_date = timezone.now()
+            if source and source not in self.email_verified_sources:
+                self.email_verified_sources.append(source)
         else:
-            self.email_verified = False
-            self.email_verified_date = None
+            if source and source in self.email_verified_sources:
+                self.email_verified_sources.remove(source)
+            if not source or not self.email_verified_sources:
+                self.email_verified = False
+                self.email_verified_date = None
 
 
 class DeletedUser(models.Model):
diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py
index a51ad1be9..bf02055a7 100644
--- a/src/authentic2/forms/registration.py
+++ b/src/authentic2/forms/registration.py
@@ -138,7 +138,7 @@ class RegistrationCompletionFormNoPassword(profile_forms.BaseUserForm):
             return BaseUserManager.normalize_email(email)
 
     def save(self, commit=True):
-        self.instance.set_email_verified(True)
+        self.instance.set_email_verified(True, source='registration')
         self.instance.is_active = True
         user = super().save(commit=commit)
         if commit and app_settings.A2_REGISTRATION_GROUPS:
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index e8492170b..cdb8f3fa5 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -293,7 +293,7 @@ class EmailChangeVerifyView(TemplateView):
                     raise ValidationError(_('This email is already used by another account.'))
                 old_email = user.email
                 user.email = email
-                user.set_email_verified(True)
+                user.set_email_verified(True, source='user')
                 user.save()
                 messages.info(
                     request, _('your request for changing your email for {0} is successful').format(email)
@@ -1041,7 +1041,7 @@ class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView):
 
     def form_valid(self, form):
         # Changing password by mail validate the email
-        form.user.set_email_verified(True)
+        form.user.set_email_verified(True, source='user')
         form.save()
         hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, form=form)
         logger.info('password reset for user %s with token %r', self.user, self.token.uuid)
diff --git a/src/authentic2_auth_oidc/backends.py b/src/authentic2_auth_oidc/backends.py
index fa02e6f42..06d8787f2 100644
--- a/src/authentic2_auth_oidc/backends.py
+++ b/src/authentic2_auth_oidc/backends.py
@@ -349,7 +349,7 @@ class OIDCBackend(ModelBackend):
                 logger.info('auth_oidc: set user %s attribute %s to value %s', user, attribute, value)
                 setattr(user, attribute, value)
                 if attribute == 'email':
-                    user.set_email_verified(verified)
+                    user.set_email_verified(verified, source='oidc')
                 save_user = True
 
         if user.ou != user_ou:
diff --git a/tests/test_custom_user.py b/tests/test_custom_user.py
index d85e75aeb..b43adb068 100644
--- a/tests/test_custom_user.py
+++ b/tests/test_custom_user.py
@@ -165,3 +165,34 @@ def test_service_profile_type(db):
     ServiceProfileType.objects.create(service=service, profile_type=pft)
     assert list(service.profile_types.all()) == [pft]
     assert list(pft.services.all()) == [service]
+
+
+def test_user_email_verified(app, simple_user, superuser_or_admin):
+    simple_user.set_email_verified(True, source='tests')
+    simple_user.save()
+    user = User.objects.get(id=simple_user.id)
+    assert user.email_verified
+    assert user.email_verified_sources == ['tests']
+
+    simple_user.set_email_verified(True, source='other')
+    simple_user.save()
+    user = User.objects.get(id=simple_user.id)
+    assert user.email_verified
+    assert user.email_verified_sources == ['tests', 'other']
+
+    simple_user.set_email_verified(False, source='tests')
+    simple_user.save()
+    user = User.objects.get(id=simple_user.id)
+    assert user.email_verified
+    assert user.email_verified_sources == ['other']
+
+    simple_user.set_email_verified(True, source='other')
+    simple_user.save()
+    user = User.objects.get(id=simple_user.id)
+    assert user.email_verified
+    assert user.email_verified_sources == ['other']
+
+    simple_user.set_email_verified(False, source='other')
+    user = User.objects.get(id=simple_user.id)
+    assert not user.email_verified
+    assert user.email_verified_sources == []
-- 
2.36.1

