Переглянути джерело

feat(api): allow creating TOTP secrets

Peter Thomassen 2 роки тому
батько
коміт
cfef6cc1bc

+ 93 - 0
api/desecapi/migrations/0028_authenticatedcreatetotpfactoruseraction_basefactor_and_more.py

@@ -0,0 +1,93 @@
+# Generated by Django 4.1 on 2022-08-23 22:23
+
+import desecapi.models.mfa
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("desecapi", "0027_user_credentials_changed"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="AuthenticatedCreateTOTPFactorUserAction",
+            fields=[
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("name", models.CharField(blank=True, max_length=64)),
+            ],
+            options={
+                "managed": False,
+            },
+            bases=("desecapi.authenticateduseraction",),
+        ),
+        migrations.CreateModel(
+            name="BaseFactor",
+            fields=[
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("last_used", models.DateTimeField(blank=True, null=True)),
+                ("name", models.CharField(blank=True, default="", max_length=64)),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+        ),
+        migrations.CreateModel(
+            name="TOTPFactor",
+            fields=[
+                (
+                    "basefactor_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.basefactor",
+                    ),
+                ),
+                (
+                    "secret",
+                    models.BinaryField(
+                        default=desecapi.models.mfa.TOTPFactor._secret_default,
+                        max_length=32,
+                    ),
+                ),
+                ("last_verified_timestep", models.PositiveIntegerField(default=0)),
+            ],
+            bases=("desecapi.basefactor",),
+        ),
+        migrations.AddConstraint(
+            model_name="basefactor",
+            constraint=models.UniqueConstraint(
+                fields=("user", "name"), name="unique_user_name"
+            ),
+        ),
+    ]

+ 1 - 0
api/desecapi/models/__init__.py

@@ -3,6 +3,7 @@ from .base import validate_domain_name, validate_lower, validate_upper
 from .captcha import Captcha
 from .domains import Domain
 from .donation import Donation
+from .mfa import BaseFactor, TOTPFactor
 from .records import (
     RR,
     RRset,

+ 13 - 0
api/desecapi/models/authenticated_actions.py

@@ -7,6 +7,7 @@ from django.db import models
 from django.utils import timezone
 
 from .domains import Domain
+from .mfa import TOTPFactor
 
 
 class AuthenticatedAction(models.Model):
@@ -184,6 +185,18 @@ class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
         self.user.change_email(self.new_email)
 
 
+class AuthenticatedCreateTOTPFactorUserAction(AuthenticatedUserAction):
+    name = models.CharField(blank=True, max_length=64)
+
+    class Meta:
+        managed = False
+
+    def _act(self):
+        factor = TOTPFactor(user=self.user, name=self.name)
+        factor.save()
+        return factor
+
+
 class AuthenticatedNoopUserAction(AuthenticatedBasicUserAction):
     class Meta:
         managed = False

+ 41 - 0
api/desecapi/models/mfa.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import base64
+import secrets
+import uuid
+
+from django.db import models, transaction
+from django.utils import timezone
+
+
+class BaseFactor(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    user = models.ForeignKey("User", on_delete=models.CASCADE)
+    created = models.DateTimeField(auto_now_add=True)
+    last_used = models.DateTimeField(null=True, blank=True)
+    name = models.CharField(blank=True, default="", max_length=64)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(fields=["user", "name"], name="unique_user_name"),
+        ]
+
+    @transaction.atomic()
+    def delete(self):
+        if self.last_used is not None:
+            self.user.credentials_changed = timezone.now()
+            self.user.save()
+        return super().delete()
+
+
+class TOTPFactor(BaseFactor):
+    @staticmethod
+    def _secret_default():
+        return secrets.token_bytes(32)
+
+    secret = models.BinaryField(max_length=32, default=_secret_default.__func__)
+    last_verified_timestep = models.PositiveIntegerField(default=0)
+
+    @property
+    def base32_secret(self):
+        return base64.b32encode(self.secret).rstrip(b"=").decode("ascii")

+ 1 - 0
api/desecapi/models/users.py

@@ -120,6 +120,7 @@ class User(ExportModelOperationsMixin("User"), AbstractBaseUser):
             "change-email-confirmation-old-email": fast_lane,
             "change-outreach-preference": slow_lane,
             "confirm-account": slow_lane,
+            "create-totp": fast_lane,
             "password-change-confirmation": fast_lane,
             "reset-password": fast_lane,
             "delete-account": fast_lane,

+ 2 - 0
api/desecapi/serializers/__init__.py

@@ -4,6 +4,7 @@ from .authenticated_actions import (
     AuthenticatedChangeEmailUserActionSerializer,
     AuthenticatedChangeOutreachPreferenceUserActionSerializer,
     AuthenticatedConfirmAccountUserActionSerializer,
+    AuthenticatedCreateTOTPFactorUserActionSerializer,
     AuthenticatedDeleteUserActionSerializer,
     AuthenticatedRenewDomainBasicUserActionSerializer,
     AuthenticatedResetPasswordUserActionSerializer,
@@ -11,6 +12,7 @@ from .authenticated_actions import (
 from .captcha import CaptchaSerializer, CaptchaSolutionSerializer
 from .domains import DomainSerializer
 from .donation import DonationSerializer
+from .mfa import TOTPFactorSerializer
 from .records import RRsetSerializer
 from .tokens import TokenDomainPolicySerializer, TokenSerializer
 from .users import (

+ 11 - 0
api/desecapi/serializers/authenticated_actions.py

@@ -235,6 +235,17 @@ class AuthenticatedConfirmAccountUserActionSerializer(
         )  # confirmation happens during authentication, so nothing left to do
 
 
+class AuthenticatedCreateTOTPFactorUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
+    reason = "create-totp"
+    validity_period = timedelta(hours=1)
+
+    class Meta(AuthenticatedBasicUserActionSerializer.Meta):
+        model = models.AuthenticatedCreateTOTPFactorUserAction
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("name",)
+
+
 class AuthenticatedResetPasswordUserActionSerializer(
     AuthenticatedBasicUserActionSerializer
 ):

+ 40 - 0
api/desecapi/serializers/mfa.py

@@ -0,0 +1,40 @@
+from rest_framework import serializers
+from rest_framework.validators import UniqueTogetherValidator
+
+from desecapi.models import BaseFactor, TOTPFactor
+
+
+class TOTPFactorSerializer(serializers.ModelSerializer):
+    user = serializers.HiddenField(default=serializers.CurrentUserDefault())
+
+    class Meta:
+        model = TOTPFactor
+        fields = ("id", "created", "last_used", "name", "secret", "user")
+        read_only_fields = ("id", "created", "last_used", "secret", "user")
+        extra_kwargs = {
+            # needed for uniqueness, https://github.com/encode/django-rest-framework/issues/7489
+            "name": {"default": ""}
+        }
+        validators = [
+            UniqueTogetherValidator(
+                queryset=BaseFactor.objects.all(),
+                fields=["user", "name"],
+                message="An authentication factor with this name already exists.",
+            )
+        ]
+
+    def __init__(self, *args, include_secret=False, **kwargs):
+        self.include_secret = include_secret
+        return super().__init__(*args, **kwargs)
+
+    def get_fields(self):
+        fields = super().get_fields()
+        if not self.include_secret:
+            fields.pop("secret")
+        return fields
+
+    def to_representation(self, instance):
+        ret = super().to_representation(instance)
+        if "secret" in ret:
+            ret["secret"] = instance.base32_secret
+        return ret

+ 14 - 0
api/desecapi/templates/emails/create-totp/content.txt

@@ -0,0 +1,14 @@
+{% extends "emails/content.txt" %}
+{% block content %}{% load action_extras %}Hi,
+
+We received a request to create a TOTP token for your deSEC account.
+For security reasons, we need you to confirm this request using the
+following link (valid for {% action_link_expiration_minutes action_serializer %} minutes):
+
+{% action_link action_serializer %}
+
+The setup process will continue after your confirmation.
+
+Stay secure,
+The deSEC Team
+{% endblock %}

+ 1 - 0
api/desecapi/templates/emails/create-totp/subject.txt

@@ -0,0 +1 @@
+[deSEC] Confirmation required: Create TOTP token

+ 5 - 0
api/desecapi/templatetags/action_extras.py

@@ -23,3 +23,8 @@ def action_link(action_serializer, idx=None):
 @register.simple_tag
 def action_link_expiration_hours(action_serializer):
     return action_serializer.validity_period // timedelta(hours=1)
+
+
+@register.simple_tag
+def action_link_expiration_minutes(action_serializer):
+    return action_serializer.validity_period // timedelta(minutes=1)

+ 49 - 0
api/desecapi/tests/test_totp.py

@@ -0,0 +1,49 @@
+from rest_framework import status
+
+from desecapi.tests.base import DomainOwnerTestCase
+
+
+class TOTPFactorTestCase(DomainOwnerTestCase):
+    def setUp(self):
+        super().setUp()
+        # Make the token a log-in token
+        self.token.perm_manage_tokens = True
+        self.token.save()
+
+    def test_workflow(self):
+        # Request setting up TOTP factor
+        self.client.post(self.reverse("v1:totp-list"))
+
+        # Factor is not yet created
+        self.assertFalse(self.owner.basefactor_set.exists())
+
+        # Retrieve confirmation link
+        confirmation_link = self.assertEmailSent(
+            subject_contains="deSEC",
+            body_contains="request to create a TOTP token",
+            recipient=[self.owner.email],
+            pattern=r"following link[^:]*:\s+([^\s]*)",
+        )
+        self.assertConfirmationLinkRedirect(confirmation_link)
+
+        # Redeem confirmation link
+        response = self.client.post(confirmation_link)
+        self.assertResponse(response, status.HTTP_200_OK)
+        totp = response.data
+        self.assertEqual(totp.keys(), {"id", "created", "last_used", "name", "secret"})
+        self.assertEqual(totp["name"], "")
+        self.assertIsNone(totp["last_used"])
+        self.assertRegex(totp["secret"], r"^[A-Z0-9]{52}$")  # 32 bytes make 52 chars
+        self.assertEqual(
+            self.owner.basefactor_set.get().totpfactor.last_verified_timestep, 0
+        )
+
+        # Can't fetch the secret
+        response = self.client.get(self.reverse("v1:totp-detail", pk=totp["id"]))
+        self.assertEqual(
+            response.data, {k: v for k, v in totp.items() if k != "secret"}
+        )
+
+        # Ensure that MFA is not active yet
+        response = self.client.get(self.reverse("v1:domain-list"))
+        self.assertEqual(len(response.data), 2)

+ 9 - 0
api/desecapi/urls/version_1.py

@@ -11,6 +11,9 @@ tokendomainpolicies_router.register(
     r"", views.TokenDomainPolicyViewSet, basename="token_domain_policies"
 )
 
+totp_router = SimpleRouter()
+totp_router.register(r"", views.TOTPViewSet, basename="totp")
+
 auth_urls = [
     # User management
     path("", views.AccountCreateView.as_view(), name="register"),
@@ -39,6 +42,7 @@ auth_urls = [
         "tokens/<uuid:token_id>/policies/domain/",
         include(tokendomainpolicies_router.urls),
     ),
+    path("totp/", include(totp_router.urls)),
 ]
 
 domains_router = SimpleRouter()
@@ -98,6 +102,11 @@ api_urls = [
         views.AuthenticatedConfirmAccountUserActionView.as_view(),
         name="confirm-confirm-account",
     ),
+    path(
+        "v/create-totp/<code>/",
+        views.AuthenticatedCreateTOTPFactorUserActionView.as_view(),
+        name="confirm-create-totp",
+    ),
     path(
         "v/reset-password/<code>/",
         views.AuthenticatedResetPasswordUserActionView.as_view(),

+ 2 - 0
api/desecapi/views/__init__.py

@@ -4,6 +4,7 @@ from .authenticated_actions import (
     AuthenticatedChangeEmailUserActionView,
     AuthenticatedChangeOutreachPreferenceUserActionView,
     AuthenticatedConfirmAccountUserActionView,
+    AuthenticatedCreateTOTPFactorUserActionView,
     AuthenticatedDeleteUserActionView,
     AuthenticatedRenewDomainBasicUserActionView,
     AuthenticatedResetPasswordUserActionView,
@@ -12,6 +13,7 @@ from .base import IdempotentDestroyMixin, Root
 from .captcha import CaptchaView
 from .donation import DonationList
 from .dyndns import DynDNS12UpdateView
+from .mfa import TOTPViewSet
 from .records import RRsetDetail, RRsetList
 from .tokens import TokenDomainPolicyViewSet, TokenPoliciesRoot, TokenViewSet
 from .users import (

+ 10 - 0
api/desecapi/views/authenticated_actions.py

@@ -206,6 +206,16 @@ class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
         return Response({"detail": "Success! Your account status has been confirmed."})
 
 
+class AuthenticatedCreateTOTPFactorUserActionView(AuthenticatedActionView):
+    html_url = "/confirm/create-totp/{code}/"
+    serializer_class = serializers.AuthenticatedCreateTOTPFactorUserActionSerializer
+
+    def post(self, request, *args, **kwargs):
+        factor = self.authenticated_action.act()
+        serializer = serializers.TOTPFactorSerializer(factor, include_secret=True)
+        return Response(serializer.data)
+
+
 class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
     html_url = "/confirm/reset-password/{code}/"
     serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer

+ 33 - 0
api/desecapi/views/mfa.py

@@ -0,0 +1,33 @@
+from rest_framework import status, viewsets
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from desecapi import permissions
+from desecapi.serializers import (
+    AuthenticatedCreateTOTPFactorUserActionSerializer,
+    TOTPFactorSerializer,
+)
+
+from .base import IdempotentDestroyMixin
+
+
+class TOTPViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
+    permission_classes = (
+        IsAuthenticated,
+        permissions.HasManageTokensPermission,
+    )
+    serializer_class = TOTPFactorSerializer
+    throttle_scope = "account_management_passive"
+
+    def get_queryset(self):
+        return self.serializer_class.Meta.model.objects.filter(user=self.request.user)
+
+    def create(self, request, *args, **kwargs):
+        super().create(request, *args, **kwargs)
+        message = "This operation requires manual confirmation. Please check your mailbox for instructions!"
+        return Response(data={"detail": message}, status=status.HTTP_202_ACCEPTED)
+
+    def perform_create(self, serializer):
+        AuthenticatedCreateTOTPFactorUserActionSerializer.build_and_save(
+            user=self.request.user, name=serializer.validated_data.get("name", "")
+        )

+ 10 - 0
docs/auth/account.rst

@@ -169,6 +169,16 @@ header to the token's secret value, prefixed with ``Token``::
         --header "Authorization: Token i-T3b1h_OI-H9ab8tRS98stGtURe"
 
 
+2-Factor Authentication
+```````````````````````
+
+2-Factor Authentication can be set up through the web interface.
+
+The underlying API keeps evolving as more factors like FIDO2 are
+getting added, and endpoints are subject to change without notice.
+A description will be added once the interface is final.
+
+
 .. _retrieve-account-information:
 
 Retrieve Account Information

+ 2 - 0
docs/endpoint-reference.rst

@@ -48,6 +48,8 @@ for :ref:`managing users <manage-account>` and :ref:`tokens <manage-tokens>`.
 |                                                      +------------+---------------------------------------------+
 |                                                      | ``DELETE`` | Delete a token domain policy                |
 +------------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/totp/``                                 |            | 2FA-related, interface subject to change    |
++------------------------------------------------------+------------+---------------------------------------------+
 | ...\ ``/captcha/``                                   | ``POST``   | Obtain captcha                              |
 +------------------------------------------------------+------------+---------------------------------------------+
 | ...\ ``/v/activate-account/{code}/``                 | ``POST``   | Confirm email address for new account       |