Przeglądaj źródła

feat(api): allow anonymous TOTP factor confirmation

Peter Thomassen 2 lat temu
rodzic
commit
54a6692b5e
2 zmienionych plików z 33 dodań i 9 usunięć
  1. 10 2
      api/desecapi/tests/test_totp.py
  2. 23 7
      api/desecapi/views/mfa.py

+ 10 - 2
api/desecapi/tests/test_totp.py

@@ -86,11 +86,14 @@ class TOTPFactorTestCase(DomainOwnerTestCase):
                     response, status.HTTP_400_BAD_REQUEST, {"code": [message]}
                 )
 
-        # Correct code works
+        # Correct code allows activation
         credentials_changed = self.owner.credentials_changed
+        self.client.credentials()
         response = self.client.post(url, {"code": authenticator.at(now)})
         self.assertResponse(
-            response, status.HTTP_200_OK, {"detail": "The code was correct."}
+            response,
+            status.HTTP_200_OK,
+            {"detail": "Your TOTP token has been activated!"},
         )
         self.assertTrue(self.owner.mfa_enabled)
         self.owner.refresh_from_db()
@@ -99,6 +102,11 @@ class TOTPFactorTestCase(DomainOwnerTestCase):
         self.assertTrue(self.owner.mfa_enabled)
         self.assertGreater(self.owner.credentials_changed, credentials_changed)
 
+        # Anonymous verification only allowed for activation
+        response = self.client.post(url, {"code": authenticator.at(now)})
+        self.assertResponse(response, status.HTTP_401_UNAUTHORIZED)
+        self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.plain)
+
         # Graceful validation window
         factor = self.owner.basefactor_set.get().totpfactor
         factor.last_verified_timestep -= 2

+ 23 - 7
api/desecapi/views/mfa.py

@@ -1,6 +1,7 @@
 from rest_framework import status, viewsets
 from rest_framework.decorators import action
-from rest_framework.permissions import IsAuthenticated
+from rest_framework.exceptions import NotAuthenticated
+from rest_framework.permissions import AllowAny, IsAuthenticated
 from rest_framework.response import Response
 
 from desecapi import permissions
@@ -14,15 +15,21 @@ from .base import IdempotentDestroyMixin
 
 
 class TOTPViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
-    permission_classes = (
-        IsAuthenticated,
-        permissions.HasManageTokensPermission,
-    )
     serializer_class = TOTPFactorSerializer
     throttle_scope = "account_management_passive"
 
+    @property
+    def permission_classes(self):
+        if self.action == "verify":
+            return [AllowAny]  # temporary for anonymous activation
+        return [IsAuthenticated, permissions.HasManageTokensPermission]
+
     def get_queryset(self):
-        return self.serializer_class.Meta.model.objects.filter(user=self.request.user)
+        qs = self.serializer_class.Meta.model.objects
+        if self.action == "verify" and self.request.method == "POST":
+            return qs
+        else:
+            return qs.filter(user=self.request.user)
 
     def create(self, request, *args, **kwargs):
         super().create(request, *args, **kwargs)
@@ -36,8 +43,17 @@ class TOTPViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 
     @action(detail=True, methods=["post"])
     def verify(self, request, pk=None):
+        new = self.get_object().last_used is None
+        authenticated = bool(request.user and request.user.is_authenticated)
+        if not new and not authenticated:
+            raise NotAuthenticated
+
         serializer = TOTPCodeSerializer(
             data=request.data, context=self.get_serializer_context()
         )
         serializer.is_valid(raise_exception=True)
-        return Response({"detail": "The code was correct."})
+
+        message = (
+            "Your TOTP token has been activated!" if new else "The code was correct."
+        )
+        return Response({"detail": message})