Browse Source

refactor(api): move confirmation link business to User model

Peter Thomassen 3 years ago
parent
commit
1bb290d2eb

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

@@ -1,14 +1,11 @@
 import datetime
 
-from django.conf import settings
 from django.core.mail import get_connection, mail_admins
 from django.core.management import BaseCommand
 from django.db import transaction
 from django.db.models import F, Max, OuterRef, Subquery
 from django.db.models.functions import Greatest
-from django.test import RequestFactory
 from django.utils import timezone
-from rest_framework.reverse import reverse
 
 from desecapi import models, serializers, views
 from desecapi.pdns_change_tracker import PDNSChangeTracker
@@ -38,12 +35,6 @@ class Command(BaseCommand):
 
     @classmethod
     def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days):
-        def confirmation_link(domain_name, user):
-            action = models.AuthenticatedRenewDomainBasicUserAction(domain=domain_name, user=user)
-            verification_code = serializers.AuthenticatedRenewDomainBasicUserActionSerializer(action).data['code']
-            request = RequestFactory().generic('', '', secure=True, HTTP_HOST=f'desec.{settings.DESECSTACK_DOMAIN}')
-            return reverse('v1:confirm-renew-domain', request=request, args=[verification_code])
-
         # We act when `renewal_changed` is at least this date (or older)
         inactive_threshold = timezone.localdate() - datetime.timedelta(days=inactive_days)
         # Filter candidates which have the state of interest, at least since the calculated date
@@ -66,8 +57,8 @@ class Command(BaseCommand):
                     domain.renewal_state += 1
                     domain.renewal_changed = timezone.now()
                     domain.save(update_fields=['renewal_state', 'renewal_changed'])
-                links = [{'name': domain, 'confirmation_link': confirmation_link(domain, user)} for domain in domains]
-                user.send_email('renew-domain', context={'domains': links, 'deletion_date': deletion_date})
+                domains = [{'domain': domain} for domain in domains]
+                user.send_confirmation_email('renew-domain', deletion_date=deletion_date, params=domains)
 
     @classmethod
     def delete_domains(cls, inactive_days):

+ 21 - 2
api/desecapi/models.py

@@ -30,6 +30,7 @@ from django.db.models import CharField, F, Manager, Q, Value
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Concat, Length
 from django.template.loader import get_template
+from django.urls import resolve, reverse
 from django.utils import timezone
 from django_prometheus.models import ExportModelOperationsMixin
 from dns import rdataclass, rdatatype
@@ -164,12 +165,12 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
         slow_lane = 'email_slow_lane'
         immediate_lane = 'email_immediate_lane'
         lanes = {
-            'activate': slow_lane,
+            'activate-account': slow_lane,
             'change-email': slow_lane,
             'change-email-confirmation-old-email': fast_lane,
             'password-change-confirmation': fast_lane,
             'reset-password': fast_lane,
-            'delete-user': fast_lane,
+            'delete-account': fast_lane,
             'domain-dyndns': fast_lane,
             'renew-domain': immediate_lane,
         }
@@ -192,6 +193,24 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
         metrics.get('desecapi_messages_queued').labels(reason, self.pk, lanes[reason]).observe(num_queued)
         return num_queued
 
+    def send_confirmation_email(self, reason, recipient=None, params=None, **kwargs):
+        def _generate_link(**kwargs):
+            action = action_serializer.Meta.model(user=self, **kwargs)
+            action_data = action_serializer(action).data
+            return f'https://desec.{settings.DESECSTACK_DOMAIN}' + reverse(view_name, args=[action_data['code']])
+
+        view_name = f'v1:confirm-{reason}'
+        url = reverse(view_name, args=['code'])  # dummy value; parameter is required for reverse URL resolution
+        action_serializer = resolve(url).func.view_class.serializer_class
+        if action_serializer.validity_period is not None:
+            kwargs['link_expiration_hours'] = action_serializer.validity_period // timedelta(hours=1)
+
+        if isinstance(params, list):
+            kwargs['confirmation_link'] = [(_generate_link(**(param or {})), param) for param in params]
+        else:
+            kwargs['confirmation_link'] = (_generate_link(**(params or {})), params)
+        self.send_email(reason, recipient=recipient, context=dict(kwargs, params=params))
+
 
 validate_domain_name = [
     validate_lower,

+ 1 - 1
api/desecapi/templates/emails/activate/content.txt → api/desecapi/templates/emails/activate-account/content.txt

@@ -11,7 +11,7 @@ To create your account and finish the registration, please confirm you
 received this email by clicking on the following link (valid for {{ link_expiration_hours }}
 hours):
 {% endif %}
-{{ confirmation_link }}
+{{ confirmation_link.0 }}
 
 After that, please follow the instructions on the confirmation page.
 If link has already expired, please register again.

+ 0 - 0
api/desecapi/templates/emails/activate/subject.txt → api/desecapi/templates/emails/activate-account/subject.txt


+ 1 - 1
api/desecapi/templates/emails/change-email/content.txt

@@ -13,7 +13,7 @@ As we may need to contact you under this address in the future, you
 need to verify your new email address before we can make the change.
 To do so, please use the following link (valid for {{ link_expiration_hours }} hours):
 
-{{ confirmation_link }}
+{{ confirmation_link.0 }}
 
 After your confirmation, we will perform the change.
 

+ 1 - 1
api/desecapi/templates/emails/delete-user/content.txt → api/desecapi/templates/emails/delete-account/content.txt

@@ -8,7 +8,7 @@ Otherwise, we will delete your account, including all related data.
 Before we do so, we need you to confirm once more that this is what you
 really, really want by clicking the following link (valid for {{ link_expiration_hours }} hours):
 
-{{ confirmation_link }}
+{{ confirmation_link.0 }}
 
 Note that this action is irreversible! We cannot recover your account.
 

+ 0 - 0
api/desecapi/templates/emails/delete-user/subject.txt → api/desecapi/templates/emails/delete-account/subject.txt


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

@@ -1,11 +1,9 @@
 Hi there,
 
 You are the owner of the following domain name(s):
-
-{% for domain in domains %}
-  * {{ domain.name }}
+{% for link in confirmation_link %}
+  * {{ link.1.domain.name }}
 {% endfor %}
-
 We noticed that the DNS information of the above domain(s) have not
 received any updates for more than 6 months.
 
@@ -20,12 +18,10 @@ your account, we will also delete your account on this date.
 
 To retain your domain name (and account), either change a DNS record
 before that date, or click the following link(s):
-
-{% for domain in domains %}
-  * {{ domain.name }}
-    {{ domain.confirmation_link }}
+{% for link in confirmation_link %}
+  * {{ link.1.domain.name }}
+    {{ link.0 }}
 {% endfor %}
-
 In case you have questions, feel free to contact us!
 
 Stay secure,

+ 1 - 1
api/desecapi/templates/emails/reset-password/content.txt

@@ -8,7 +8,7 @@ have a password.
 To ensure that the request is legitimate, we need you to confirm it
 using the following link (valid for {{ link_expiration_hours }} hours):
 
-{{ confirmation_link }}
+{{ confirmation_link.0 }}
 
 After your confirmation, you can provide your new password.
 

+ 10 - 52
api/desecapi/views.py

@@ -30,13 +30,6 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 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:
     def initialize_request(self, request, *args, **kwargs):
         # noinspection PyUnresolvedReferences
@@ -522,14 +515,7 @@ class AccountCreateView(generics.CreateAPIView):
             # send email if needed
             domain = serializer.validated_data.get('domain')
             if domain or activation_required:
-                link, validity_period = generate_confirmation_link(request,
-                                                                   serializers.AuthenticatedActivateUserActionSerializer,
-                                                                   'confirm-activate-account', user=user, domain=domain)
-                user.send_email('activate', context={
-                    'confirmation_link': link,
-                    'link_expiration_hours': validity_period // timedelta(hours=1),
-                    'domain': domain,
-                })
+                user.send_confirmation_email('activate-account', params=dict(domain=domain))
 
         # This request is unauthenticated, so don't expose whether we did anything.
         message = 'Welcome! Please check your mailbox.' if activation_required else 'Welcome!'
@@ -555,15 +541,9 @@ class AccountDeleteView(APIView):
     throttle_scope = 'account_management_active'
 
     def post(self, request, *args, **kwargs):
-        if self.request.user.domains.exists():
+        if request.user.domains.exists():
             return self.response_still_has_domains
-        link, validity_period = generate_confirmation_link(request,
-                                                           serializers.AuthenticatedDeleteUserActionSerializer,
-                                                           'confirm-delete-account', user=self.request.user)
-        request.user.send_email('delete-user', context={
-            'confirmation_link': link,
-            'link_expiration_hours': validity_period // timedelta(hours=1),
-        })
+        request.user.send_confirmation_email('delete-account')
 
         return Response(data={'detail': 'Please check your mailbox for further account deletion instructions.'},
                         status=status.HTTP_202_ACCEPTED)
@@ -605,20 +585,12 @@ class AccountChangeEmailView(generics.GenericAPIView):
     throttle_scope = 'account_management_active'
 
     def post(self, request, *args, **kwargs):
-        # Check password and extract email
+        # Check password and extract `new_email` field
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         new_email = serializer.validated_data['new_email']
-
-        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={
-            'confirmation_link': link,
-            'link_expiration_hours': validity_period // timedelta(hours=1),
-            'old_email': request.user.email,
-            'new_email': new_email,
-        })
+        request.user.send_confirmation_email('change-email', recipient=new_email, old_email=request.user.email,
+                                             params=dict(new_email=new_email))
 
         # At this point, we know that we are talking to the user, so we can tell that we sent an email.
         return Response(data={'detail': 'Please check your mailbox to confirm email address change.'},
@@ -638,23 +610,13 @@ class AccountResetPasswordView(generics.GenericAPIView):
         except models.User.DoesNotExist:
             pass
         else:
-            self.send_reset_token(user, request)
+            user.send_confirmation_email('reset-password')
 
         # This request is unauthenticated, so don't expose whether we did anything.
         return Response(data={'detail': 'Please check your mailbox for further password reset instructions. '
                                         'If you did not receive an email, please contact support.'},
                         status=status.HTTP_202_ACCEPTED)
 
-    @staticmethod
-    def send_reset_token(user, request):
-        link, validity_period = generate_confirmation_link(request,
-                                                           serializers.AuthenticatedResetPasswordUserActionSerializer,
-                                                           'confirm-reset-password', user=user)
-        user.send_email('reset-password', context={
-            'confirmation_link': link,
-            'link_expiration_hours': validity_period // timedelta(hours=1),
-        })
-
 
 class AuthenticatedActionView(generics.GenericAPIView):
     """
@@ -748,13 +710,9 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 
     def _finalize_without_domain(self):
         if not is_password_usable(self.authenticated_action.user.password):
-            AccountResetPasswordView.send_reset_token(self.authenticated_action.user, self.request)
-            return Response({
-                'detail': 'Success! We sent you instructions on how to set your password.'
-            })
-        return Response({
-                'detail': 'Success! Your account has been activated, and you can now log in.'
-            })
+            self.authenticated_action.user.send_confirmation_email('reset-password')
+            return Response({'detail': 'Success! We sent you instructions on how to set your password.'})
+        return Response({'detail': 'Success! Your account has been activated, and you can now log in.'})
 
     def _finalize_with_domain(self, domain):
         if domain.is_locally_registrable: