django-mfa3

multi factor authentication for django
git clone https://git.ce9e.org/django-mfa3.git

commit
8dfa12427d678b37c0189ec821515379fb0b7cc1
parent
2610ba11323ad202849483a7b8afba4ffbf4d5e8
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2025-08-13 10:25
add setting MAX_KEYS_PER_ACCOUNT

Diffstat

M mfa/settings.py 9 +++++++++
M mfa/templates/mfa/mfakey_list.html 13 ++++++++++---
M mfa/views.py 14 ++++++++++++++
M tests/tests.py 22 ++++++++++++++++++++++

4 files changed, 55 insertions, 3 deletions


diff --git a/mfa/settings.py b/mfa/settings.py

@@ -17,3 +17,12 @@ TOTP_VALID_WINDOW = getattr(settings, 'MFA_TOTP_VALID_WINDOW', 0)
   17    17 # `user_verification` parameter passed to python-fido2
   18    18 # See https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement
   19    19 FIDO2_USER_VERIFICATION = getattr(settings, 'MFA_FIDO2_USER_VERIFICATION', None)
   -1    20 
   -1    21 # An account might end up having a large number of keys
   -1    22 # if it is shared by multiple users or if lost keys are not
   -1    23 # deleted. Both are potential security issues, so this is
   -1    24 # restricted to a reasonable (but small) number by default.
   -1    25 #
   -1    26 # This setting only applies when adding new keys.
   -1    27 # To allow an arbitrary number of keys, set this to `None`.
   -1    28 MAX_KEYS_PER_ACCOUNT = getattr(settings, 'MFA_MAX_KEYS_PER_ACCOUNT', 3)

diff --git a/mfa/templates/mfa/mfakey_list.html b/mfa/templates/mfa/mfakey_list.html

@@ -18,6 +18,13 @@
   18    18     {% endfor %}
   19    19 </ul>
   20    20 
   21    -1 <a href="{% url 'mfa:create' 'TOTP' %}">Create TOTP key</a>
   22    -1 <a href="{% url 'mfa:create' 'FIDO2' %}">Create FIDO2 key</a>
   23    -1 <a href="{% url 'mfa:create' 'recovery' %}">Create recovery code</a>
   -1    21 {% if max_keys and user.mfakey_set.count >= max_keys %}
   -1    22     <p>
   -1    23         You cannot have more than {{ max_keys }} keys.
   -1    24         Please delete one of your existing keys before adding a new one.
   -1    25     </p>
   -1    26 {% else %}
   -1    27     <a href="{% url 'mfa:create' 'TOTP' %}">Create TOTP key</a>
   -1    28     <a href="{% url 'mfa:create' 'FIDO2' %}">Create FIDO2 key</a>
   -1    29     <a href="{% url 'mfa:create' 'recovery' %}">Create recovery code</a>
   -1    30 {% endif %}

diff --git a/mfa/views.py b/mfa/views.py

@@ -10,6 +10,7 @@ from django.shortcuts import redirect
   10    10 from django.urls import reverse
   11    11 from django.utils.decorators import method_decorator
   12    12 from django.utils.functional import cached_property
   -1    13 from django.utils.text import format_lazy
   13    14 from django.utils.translation import gettext_lazy as _
   14    15 from django.views.generic import DeleteView
   15    16 from django.views.generic import ListView
@@ -49,6 +50,11 @@ class MFAListView(LoginRequiredMixin, ListView):
   49    50     def get_queryset(self):
   50    51         return super().get_queryset().filter(user=self.request.user)
   51    52 
   -1    53     def get_context_data(self, **kwargs):
   -1    54         context = super().get_context_data(**kwargs)
   -1    55         context['max_keys'] = settings.MAX_KEYS_PER_ACCOUNT
   -1    56         return context
   -1    57 
   52    58 
   53    59 class MFADeleteView(LoginRequiredMixin, DeleteView):
   54    60     model = MFAKey
@@ -76,6 +82,14 @@ class MFACreateView(LoginRequiredMixin, MFAFormView):
   76    82         return self.method.register_complete(self.challenge[1], code)
   77    83 
   78    84     def form_valid(self, form):
   -1    85         if settings.MAX_KEYS_PER_ACCOUNT:
   -1    86             count = self.request.user.mfakey_set.count()
   -1    87             if count >= settings.MAX_KEYS_PER_ACCOUNT:
   -1    88                 form.add_error(None, format_lazy(_(
   -1    89                     'You cannot have more than {} keys. Please delete '
   -1    90                     'one of your existing keys before adding a new one.'
   -1    91                 ), settings.MAX_KEYS_PER_ACCOUNT))
   -1    92                 return self.form_invalid(form)
   79    93         MFAKey.objects.create(
   80    94             user=self.request.user,
   81    95             method=self.method.name,

diff --git a/tests/tests.py b/tests/tests.py

@@ -166,6 +166,28 @@ class TOTPCreateViewTest(MFATestCase):
  166   166         })
  167   167         self.assertEqual(MFAKey.objects.count(), 1)
  168   168 
   -1   169     def test_max_keys(self):
   -1   170         for i in range(3):
   -1   171             self.user.mfakey_set.create(
   -1   172                 method='recovery',
   -1   173                 name=f'test-{i}',
   -1   174                 secret='dummy',
   -1   175             )
   -1   176 
   -1   177         self.client.force_login(self.user)
   -1   178 
   -1   179         res = self.client.get('/mfa/create/TOTP/')
   -1   180         self.assertEqual(res.status_code, 200)
   -1   181         totp = pyotp.TOTP(res.context['mfa_data']['secret'])
   -1   182 
   -1   183         with self.settings(MFA_MAX_KEYS_PER_ACCOUNT=3):
   -1   184             res = self.client.post('/mfa/create/TOTP/', {
   -1   185                 'name': 'test',
   -1   186                 'code': totp.now()
   -1   187             })
   -1   188         self.assertEqual(res.status_code, 200)
   -1   189         self.assertEqual(MFAKey.objects.count(), 3)
   -1   190 
  169   191 
  170   192 class FIDO2Test(MFATestCase):
  171   193     # I have no clue how to simulate a FIDO2 authenticator,