diff --git a/api/desecapi/authentication.py b/api/desecapi/authentication.py index 6c16e0822..8b71de3cb 100644 --- a/api/desecapi/authentication.py +++ b/api/desecapi/authentication.py @@ -1,4 +1,5 @@ import base64 +from datetime import datetime, timezone from ipaddress import ip_address from django.contrib.auth.hashers import PBKDF2PasswordHasher @@ -161,11 +162,14 @@ def authenticate(self, request): def authenticate_credentials(self, context): serializer = AuthenticatedBasicUserActionSerializer(data={}, context=context) serializer.is_valid(raise_exception=True) - user = serializer.validated_data['user'] + + email_verified = datetime.fromtimestamp(serializer.timestamp, timezone.utc) + user.email_verified = max(user.email_verified or email_verified, email_verified) + user.save() + if user.is_active == False: # don't catch None here! raise exceptions.AuthenticationFailed('User inactive.') - return user, None diff --git a/api/desecapi/migrations/0020_user_email_verified.py b/api/desecapi/migrations/0020_user_email_verified.py new file mode 100644 index 000000000..fedee780e --- /dev/null +++ b/api/desecapi/migrations/0020_user_email_verified.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.1 on 2022-01-14 13:39 + +import datetime + +from django.db import migrations, models +from django.db.models import F, Q + + +def forwards_func(apps, schema_editor): + User = apps.get_model("desecapi", "User") + db_alias = schema_editor.connection.alias + filter = Q(is_active=True) | ~Q(last_login__isnull=True) + filter_kwargs = dict(created__date__gte=datetime.date(2019, 10, 23)) + User.objects.using(db_alias).filter(filter, **filter_kwargs).update(email_verified=F('created')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('desecapi', '0019_alter_user_is_active'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email_verified', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunPython(forwards_func, migrations.RunPython.noop), + ] diff --git a/api/desecapi/models.py b/api/desecapi/models.py index 4ab6e4ec6..0b5e6a34d 100644 --- a/api/desecapi/models.py +++ b/api/desecapi/models.py @@ -98,6 +98,7 @@ def _limit_domains_default(): verbose_name='email address', unique=True, ) + email_verified = models.DateTimeField(null=True, blank=True) is_active = models.BooleanField(default=True, null=True) is_admin = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) diff --git a/api/desecapi/tests/test_user_management.py b/api/desecapi/tests/test_user_management.py index bcf06d652..857612ea6 100644 --- a/api/desecapi/tests/test_user_management.py +++ b/api/desecapi/tests/test_user_management.py @@ -926,6 +926,14 @@ def test_action_code_confusion(self): self.assertVerificationFailureInvalidCodeResponse(self.client.verify(reset_password_link, data={'new_password': 'dummy'})) + def test_action_code_updates_email_verified(self): + email_verified = User.objects.get(email=self.email).email_verified + with mock.patch('time.time', return_value=time.time() + 1): + self.assertResetPasswordSuccessResponse(self.reset_password(self.email)) + confirmation_link = self.assertResetPasswordEmail(self.email) + self.client.verify(confirmation_link) # even without payload + self.assertGreaterEqual((User.objects.get(email=self.email).email_verified - email_verified).total_seconds(), 1) + class RenewTestCase(UserManagementTestCase, DomainOwnerTestCase): DYN = False