Ver código fonte

feat(api): remove expiration from domain renewal code, closes #422

Links are still invalidated when significant domain attributes change,
such as the domain owner, or the renewal timestamp.  Using the link
thus renders it invalid.
Peter Thomassen 4 anos atrás
pai
commit
3f7c991e67

+ 0 - 2
api/desecapi/management/commands/scavenge-unused.py

@@ -1,6 +1,4 @@
 import datetime
 import datetime
-from functools import reduce
-import operator
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.mail import get_connection, mail_admins
 from django.core.mail import get_connection, mail_admins

+ 0 - 2
api/desecapi/models.py

@@ -166,8 +166,6 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
             raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')
             raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')
 
 
         context = context or {}
         context = context or {}
-        context.setdefault('link_expiration_hours',
-                           settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE // timedelta(hours=1))
         content = get_template(f'emails/{reason}/content.txt').render(context)
         content = get_template(f'emails/{reason}/content.txt').render(context)
         content += f'\nSupport Reference: user_id = {self.pk}\n'
         content += f'\nSupport Reference: user_id = {self.pk}\n'
         footer = get_template('emails/footer.txt').render()
         footer = get_template('emails/footer.txt').render()

+ 19 - 9
api/desecapi/serializers.py

@@ -638,6 +638,7 @@ class CustomFieldNameUniqueValidator(UniqueValidator):
 
 
 class AuthenticatedActionSerializer(serializers.ModelSerializer):
 class AuthenticatedActionSerializer(serializers.ModelSerializer):
     state = serializers.CharField()  # serializer read-write, but model read-only field
     state = serializers.CharField()  # serializer read-write, but model read-only field
+    validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
 
 
     class Meta:
     class Meta:
         model = models.AuthenticatedAction
         model = models.AuthenticatedAction
@@ -650,11 +651,10 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
         return urlsafe_b64encode(payload_enc).decode()
         return urlsafe_b64encode(payload_enc).decode()
 
 
     @classmethod
     @classmethod
-    def _unpack_code(cls, code):
+    def _unpack_code(cls, code, *, ttl):
         try:
         try:
             payload_enc = urlsafe_b64decode(code.encode())
             payload_enc = urlsafe_b64decode(code.encode())
-            payload = crypto.decrypt(payload_enc, context='desecapi.serializers.AuthenticatedActionSerializer',
-                                     ttl=settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE.total_seconds())
+            payload = crypto.decrypt(payload_enc, context='desecapi.serializers.AuthenticatedActionSerializer', ttl=ttl)
             return json.loads(payload.decode())
             return json.loads(payload.decode())
         except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
         except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
             raise ValueError
             raise ValueError
@@ -668,16 +668,25 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         data = data.copy()  # avoid side effect from .pop
         data = data.copy()  # avoid side effect from .pop
+
+        # calculate code TTL
+        validity_period = self.context.get('validity_period', self.validity_period)
+        try:
+            ttl = validity_period.total_seconds()
+        except AttributeError:
+            ttl = None  # infinite
+
+        # decode from single string
         try:
         try:
-            # decode from single string
-            unpacked_data = self._unpack_code(self.context['code'])
+            unpacked_data = self._unpack_code(self.context['code'], ttl=ttl)
         except KeyError:
         except KeyError:
             raise serializers.ValidationError({'code': ['This field is required.']})
             raise serializers.ValidationError({'code': ['This field is required.']})
         except ValueError:
         except ValueError:
-            validity = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
-            raise serializers.ValidationError({
-                'code': [f'This code is invalid, most likely because it expired (validity: {validity}).']
-            })
+            if ttl is None:
+                msg = 'This code is invalid.'
+            else:
+                msg = f'This code is invalid, possibly because it expired (validity: {validity_period}).'
+            raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg, 'code': 'invalid_code'})
 
 
         # add extra fields added by the user
         # add extra fields added by the user
         unpacked_data.update(**data)
         unpacked_data.update(**data)
@@ -758,6 +767,7 @@ class AuthenticatedDomainBasicUserActionSerializer(AuthenticatedBasicUserActionS
 
 
 
 
 class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer):
 class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer):
+    validity_period = None
 
 
     class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
         model = models.AuthenticatedRenewDomainBasicUserAction
         model = models.AuthenticatedRenewDomainBasicUserAction

+ 1 - 1
api/desecapi/templates/emails/renew-domain/content.txt

@@ -19,7 +19,7 @@ The above domain name(s) are scheduled for deletion on
 your account, we will also delete your account on this date.
 your account, we will also delete your account on this date.
 
 
 To retain your domain name (and account), either change a DNS record
 To retain your domain name (and account), either change a DNS record
-before that date, or click the following link(s) (valid for {{ link_expiration_hours }} hours):
+before that date, or click the following link(s):
 
 
 {% for domain in domains %}
 {% for domain in domains %}
   * {{ domain.name }}
   * {{ domain.name }}

+ 12 - 6
api/desecapi/tests/test_user_management.py

@@ -384,7 +384,7 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
     def assertVerificationFailureExpiredCodeResponse(self, response):
     def assertVerificationFailureExpiredCodeResponse(self, response):
         return self.assertContains(
         return self.assertContains(
             response=response,
             response=response,
-            text="This code is invalid, most likely because it expired (validity: ",
+            text="This code is invalid, possibly because it expired (validity: ",
             status_code=status.HTTP_400_BAD_REQUEST
             status_code=status.HTTP_400_BAD_REQUEST
         )
         )
 
 
@@ -427,7 +427,7 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
         if tampered_domain is not None:
         if tampered_domain is not None:
             path = urlparse(confirmation_link).path
             path = urlparse(confirmation_link).path
             code = resolve(path).kwargs.get('code')
             code = resolve(path).kwargs.get('code')
-            data = AuthenticatedActionSerializer._unpack_code(code)
+            data = AuthenticatedActionSerializer._unpack_code(code, ttl=None)
             data['domain'] = tampered_domain
             data['domain'] = tampered_domain
             tampered_code = AuthenticatedActionSerializer._pack_code(data)
             tampered_code = AuthenticatedActionSerializer._pack_code(data)
             confirmation_link = confirmation_link.replace(code, tampered_code)
             confirmation_link = confirmation_link.replace(code, tampered_code)
@@ -959,9 +959,12 @@ class RenewTestCase(UserManagementTestCase, DomainOwnerTestCase):
         pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
         pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
         confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
         confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
         self.assertConfirmationLinkRedirect(confirmation_link)
         self.assertConfirmationLinkRedirect(confirmation_link)
-        self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+
+        # Use link after 14 days
+        with mock.patch('time.time', return_value=time.time() + 86400*14):
+            self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+            self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
         self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
         self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
-        self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
 
 
         # Check that other domains aren't affected
         # Check that other domains aren't affected
         self.assertGreater(len(self.my_domains), 1)
         self.assertGreater(len(self.my_domains), 1)
@@ -984,9 +987,12 @@ class RenewTestCase(UserManagementTestCase, DomainOwnerTestCase):
         pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
         pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
         confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
         confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
         self.assertConfirmationLinkRedirect(confirmation_link)
         self.assertConfirmationLinkRedirect(confirmation_link)
-        self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+
+        # Use link after 6 days
+        with mock.patch('time.time', return_value=time.time() + 86400*6):
+            self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+            self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
         self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
         self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
-        self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
 
 
         # Check that other domains aren't affected
         # Check that other domains aren't affected
         self.assertGreater(len(self.my_domains), 1)
         self.assertGreater(len(self.my_domains), 1)

+ 33 - 13
api/desecapi/views.py

@@ -1,5 +1,6 @@
 import base64
 import base64
 import binascii
 import binascii
+from datetime import timedelta
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import user_logged_in
 from django.contrib.auth import user_logged_in
@@ -27,6 +28,13 @@ from desecapi.permissions import IsDomainOwner, IsOwner, IsVPNClient, WithinDoma
 from desecapi.renderers import PlainTextRenderer
 from desecapi.renderers import PlainTextRenderer
 
 
 
 
+def generate_confirmation_link(request, action_serializer, viewname, **kwargs):
+    action = action_serializer.Meta.model(**kwargs)
+    action_data = action_serializer(action).data
+    confirmation_link = reverse(viewname, request=request, args=[action_data['code']])
+    return confirmation_link, action_serializer.validity_period
+
+
 class EmptyPayloadMixin:
 class EmptyPayloadMixin:
     def initialize_request(self, request, *args, **kwargs):
     def initialize_request(self, request, *args, **kwargs):
         request = super().initialize_request(request, *args, **kwargs)
         request = super().initialize_request(request, *args, **kwargs)
@@ -447,10 +455,12 @@ class AccountCreateView(generics.CreateAPIView):
             # send email if needed
             # send email if needed
             domain = serializer.validated_data.get('domain')
             domain = serializer.validated_data.get('domain')
             if domain or activation_required:
             if domain or activation_required:
-                action = models.AuthenticatedActivateUserAction(user=user, domain=domain)
-                verification_code = serializers.AuthenticatedActivateUserActionSerializer(action).data['code']
+                link, validity_period = generate_confirmation_link(request,
+                                                                   serializers.AuthenticatedActivateUserActionSerializer,
+                                                                   'confirm-activate-account', user=user, domain=domain)
                 user.send_email('activate-with-domain' if domain else 'activate', context={
                 user.send_email('activate-with-domain' if domain else 'activate', context={
-                    'confirmation_link': reverse('confirm-activate-account', request=request, args=[verification_code]),
+                    'confirmation_link': link,
+                    'link_expiration_hours': validity_period // timedelta(hours=1),
                     'domain': domain,
                     'domain': domain,
                 })
                 })
 
 
@@ -480,10 +490,12 @@ class AccountDeleteView(generics.GenericAPIView):
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         if self.request.user.domains.exists():
         if self.request.user.domains.exists():
             return self.response_still_has_domains
             return self.response_still_has_domains
-        action = models.AuthenticatedDeleteUserAction(user=self.request.user)
-        verification_code = serializers.AuthenticatedDeleteUserActionSerializer(action).data['code']
+        link, validity_period = generate_confirmation_link(request,
+                                                           serializers.AuthenticatedDeleteUserActionSerializer,
+                                                           'confirm-delete-account', user=self.request.user)
         request.user.send_email('delete-user', context={
         request.user.send_email('delete-user', context={
-            'confirmation_link': reverse('confirm-delete-account', request=request, args=[verification_code])
+            'confirmation_link': link,
+            'link_expiration_hours': validity_period // timedelta(hours=1),
         })
         })
 
 
         return Response(data={'detail': 'Please check your mailbox for further account deletion instructions.'},
         return Response(data={'detail': 'Please check your mailbox for further account deletion instructions.'},
@@ -530,10 +542,12 @@ class AccountChangeEmailView(generics.GenericAPIView):
         serializer.is_valid(raise_exception=True)
         serializer.is_valid(raise_exception=True)
         new_email = serializer.validated_data['new_email']
         new_email = serializer.validated_data['new_email']
 
 
-        action = models.AuthenticatedChangeEmailUserAction(user=request.user, new_email=new_email)
-        verification_code = serializers.AuthenticatedChangeEmailUserActionSerializer(action).data['code']
+        link, validity_period = generate_confirmation_link(request,
+                                                           serializers.AuthenticatedChangeEmailUserActionSerializer,
+                                                           'confirm-change-email', user=request.user, new_email=new_email)
         request.user.send_email('change-email', recipient=new_email, context={
         request.user.send_email('change-email', recipient=new_email, context={
-            'confirmation_link': reverse('confirm-change-email', request=request, args=[verification_code]),
+            'confirmation_link': link,
+            'link_expiration_hours': validity_period // timedelta(hours=1),
             'old_email': request.user.email,
             'old_email': request.user.email,
             'new_email': new_email,
             'new_email': new_email,
         })
         })
@@ -565,10 +579,12 @@ class AccountResetPasswordView(generics.GenericAPIView):
 
 
     @staticmethod
     @staticmethod
     def send_reset_token(user, request):
     def send_reset_token(user, request):
-        action = models.AuthenticatedResetPasswordUserAction(user=user)
-        verification_code = serializers.AuthenticatedResetPasswordUserActionSerializer(action).data['code']
+        link, validity_period = generate_confirmation_link(request,
+                                                           serializers.AuthenticatedResetPasswordUserActionSerializer,
+                                                           'confirm-reset-password', user=user)
         user.send_email('reset-password', context={
         user.send_email('reset-password', context={
-            'confirmation_link': reverse('confirm-reset-password', request=request, args=[verification_code])
+            'confirmation_link': link,
+            'link_expiration_hours': validity_period // timedelta(hours=1),
         })
         })
 
 
 
 
@@ -588,7 +604,11 @@ class AuthenticatedActionView(generics.GenericAPIView):
         return 'account_management_passive' if self.request.method in SAFE_METHODS else 'account_management_active'
         return 'account_management_passive' if self.request.method in SAFE_METHODS else 'account_management_active'
 
 
     def get_serializer_context(self):
     def get_serializer_context(self):
-        return {**super().get_serializer_context(), 'code': self.kwargs['code']}
+        return {
+            **super().get_serializer_context(),
+            'code': self.kwargs['code'],
+            'validity_period': self.get_serializer_class().validity_period,
+        }
 
 
     def perform_authentication(self, request):
     def perform_authentication(self, request):
         # Delay authentication until request.auth or request.user is first accessed.
         # Delay authentication until request.auth or request.user is first accessed.