Просмотр исходного кода

feat(api): redirect to frontend before performing authenticated action

Peter Thomassen 5 лет назад
Родитель
Сommit
1b686913cb
2 измененных файлов с 37 добавлено и 21 удалено
  1. 5 1
      api/desecapi/tests/test_user_management.py
  2. 32 20
      api/desecapi/views.py

+ 5 - 1
api/desecapi/tests/test_user_management.py

@@ -663,7 +663,11 @@ class HasUserAccountTestCase(UserManagementTestCase):
     def test_reset_password_via_get(self):
         confirmation_link = self._start_reset_password()
         response = self.client.verify(confirmation_link)
-        self.assertResponse(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+        self.assertResponse(response, status.HTTP_406_NOT_ACCEPTABLE)
+
+        confirmation_link = self._start_reset_password()
+        response = self.client.verify(confirmation_link, HTTP_ACCEPT='text/html')
+        self.assertResponse(response, status.HTTP_302_FOUND)
 
     def test_reset_password_validation_unknown_user(self):
         confirmation_link = self._start_reset_password()

+ 32 - 20
api/desecapi/views.py

@@ -11,8 +11,8 @@ from django.template.loader import get_template
 from rest_framework import generics
 from rest_framework import mixins
 from rest_framework import status
-from rest_framework.authentication import get_authorization_header, BaseAuthentication
-from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
+from rest_framework.authentication import get_authorization_header
+from rest_framework.exceptions import (NotAcceptable, NotFound, PermissionDenied, ValidationError)
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
 from rest_framework.response import Response
@@ -518,6 +518,8 @@ class AuthenticatedActionView(generics.GenericAPIView):
     """
     authentication_classes = (auth.AuthenticatedActionAuthentication,)
     authentication_exception = ValidationError
+    html_url = None
+    renderer_classes = [JSONRenderer, StaticHTMLRenderer]
 
     def perform_authentication(self, request):
         # Delay authentication until request.auth or request.user is first accessed.
@@ -525,6 +527,19 @@ class AuthenticatedActionView(generics.GenericAPIView):
         pass
 
     def get(self, request, *args, **kwargs):
+        is_redirect = (request.accepted_renderer.format == 'html') and self.html_url
+
+        # For POST-type actions, only allow GET for the purpose of returning a frontend redirect to a browser
+        if 'post' in self.http_method_names:
+            if not is_redirect:
+                raise NotAcceptable
+
+        # Redirect browsers to frontend if available
+        if is_redirect:
+            # Careful: This can generally lead to an open redirect if values contain slashes!
+            # However, it cannot happen for Django view kwargs.
+            return redirect(self.html_url.format(**kwargs))
+
         return self.take_action()
 
     def post(self, request, *args, **kwargs):
@@ -539,8 +554,8 @@ class AuthenticatedActionView(generics.GenericAPIView):
 
 
 class AuthenticatedActivateUserActionView(AuthenticatedActionView):
+    html_url = '/confirm/activate-account/{code}/'
     http_method_names = ['get']
-    renderer_classes = [JSONRenderer, StaticHTMLRenderer]
     serializer_class = serializers.AuthenticatedActivateUserActionSerializer
 
     def finalize(self):
@@ -548,10 +563,7 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
             return self._finalize_without_domain()
         else:
             domain = self._create_domain()
-            if domain.is_locally_registrable:
-                return self._finalize_with_local_public_suffix_domain(domain)
-            else:
-                return self._finalize_with_domain()
+            return self._finalize_with_domain(domain)
 
     def _create_domain(self):
         action = self.request.auth
@@ -577,26 +589,24 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
                 'detail': 'Success! Please log in at {}.'.format(self.request.build_absolute_uri(reverse('v1:login')))
             })
 
-    def _finalize_with_local_public_suffix_domain(self, domain):
-        # TODO the following line raises Domain.DoesNotExist under unknown conditions
-        PDNSChangeTracker.track(lambda: DomainList.auto_delegate(domain))
-        token = models.Token.objects.create(user=domain.owner, name='dyndns')
-        if self.request.accepted_renderer.format == 'html':
-            return redirect(f'/dynsetup/{domain.name}/#{token.plain}')
-        else:
+    def _finalize_with_domain(self, domain):
+        if domain.is_locally_registrable:
+            # TODO the following line raises Domain.DoesNotExist under unknown conditions
+            PDNSChangeTracker.track(lambda: DomainList.auto_delegate(domain))
+            token = models.Token.objects.create(user=domain.owner, name='dyndns')
             return Response({
                 'detail': 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
                           'client). This password is different from your account password for security reasons.',
                 **serializers.TokenSerializer(token, include_plain=True).data,
             })
-
-    def _finalize_with_domain(self):
-        return Response({
-            'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.'
-        })
+        else:
+            return Response({
+                'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.'
+            })
 
 
 class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
+    html_url = '/confirm/change-email/{code}/'
     http_method_names = ['get']
     serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
 
@@ -607,7 +617,8 @@ class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
 
 
 class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
-    http_method_names = ['post']
+    html_url = '/confirm/reset-password/{code}/'
+    http_method_names = ['get', 'post']  # GET is for redirect only
     serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
 
     def finalize(self):
@@ -615,6 +626,7 @@ class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
 
 
 class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
+    html_url = '/confirm/delete-account/{code}/'
     http_method_names = ['get']
     serializer_class = serializers.AuthenticatedDeleteUserActionSerializer