├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CHANGES.md ├── LICENSE ├── README.md ├── mfa ├── __init__.py ├── admin.py ├── apps.py ├── decorators.py ├── forms.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── mail.py ├── methods │ ├── __init__.py │ ├── fido2.py │ ├── recovery.py │ └── totp.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_mfakey_last_code.py │ ├── 0003_alter_mfakey_method.py │ ├── 0004_alter_mfakey_id.py │ └── __init__.py ├── mixins.py ├── models.py ├── settings.py ├── static │ └── mfa │ │ ├── fido2.js │ │ └── vendor │ │ └── webauthn-json.browser-ponyfill.js ├── templates │ └── mfa │ │ ├── auth_FIDO2.html │ │ ├── auth_TOTP.html │ │ ├── auth_recovery.html │ │ ├── create_FIDO2.html │ │ ├── create_TOTP.html │ │ ├── create_recovery.html │ │ ├── login_failed_subject.txt │ │ ├── mfakey_confirm_delete.html │ │ └── mfakey_list.html ├── templatetags │ ├── __init__.py │ └── mfa.py ├── urls.py └── views.py ├── pyproject.toml └── tests ├── __init__.py ├── settings.py ├── templates ├── mfa │ └── login_failed_email.txt └── registration │ └── login.html ├── tests.py └── urls.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | ignore: 13 | - dependency-name: "qrcode" 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | jobs: 3 | lint: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-python@v5 8 | - run: pip install ruff 9 | - name: linters 10 | run: | 11 | ruff check mfa tests 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | include: 17 | - python: '3.10' 18 | django: '4.2' 19 | - python: '3.13' 20 | django: '5.2' 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - run: pip install . coverage tomli "django==${{ matrix.django }}" 27 | - name: tests 28 | run: | 29 | coverage run -m django test --settings tests.settings 30 | coverage report 31 | publish: 32 | needs: [lint, test] 33 | if: startsWith(github.ref, 'refs/tags') 34 | runs-on: ubuntu-latest 35 | permissions: 36 | id-token: write 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | - run: pip install build 41 | - name: build 42 | run: python3 -m build 43 | - name: publish 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .coverage 3 | *.egg-info 4 | build 5 | dist 6 | __pycache__ 7 | htmlcov 8 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | 1.0.0 (2025-05-21) 2 | ------------------ 3 | 4 | - drop support for python 3.9 5 | - drop support for fido2 1.x 6 | - cbor-js is no longer required 7 | - the new javascript dependency webauthn-json is included in the package 8 | - the ` 18 | -------------------------------------------------------------------------------- /mfa/templates/mfa/auth_TOTP.html: -------------------------------------------------------------------------------- 1 |

Two-factor authentication

2 |

Open your TOTP app to get an authentication code.

3 | 4 |
5 | {% csrf_token %} 6 | {% for error in form.non_field_errors %} 7 |

{{ error }}

8 | {% endfor %} 9 | 13 | 14 | Use FIDO2 instead 15 | Use recovery code instead 16 |
17 | -------------------------------------------------------------------------------- /mfa/templates/mfa/auth_recovery.html: -------------------------------------------------------------------------------- 1 |

Two-factor authentication

2 | 3 |

4 | WARNING: 5 | The recovery code will be removed after it has been used. 6 | Make sure to create a new one after login! 7 |

8 | 9 |
10 | {% csrf_token %} 11 | {% for error in form.non_field_errors %} 12 |

{{ error }}

13 | {% endfor %} 14 | 18 | 19 | Use FIDO2 instead 20 | Use TOTP instead 21 |
22 | -------------------------------------------------------------------------------- /mfa/templates/mfa/create_FIDO2.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |

You will be prompted to activate the device once you click "create".

4 | 5 |
6 | {% csrf_token %} 7 | {% for error in form.non_field_errors %} 8 |

{{ error }}

9 | {% endfor %} 10 | 14 | {{ form.code.as_hidden }} 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /mfa/templates/mfa/create_TOTP.html: -------------------------------------------------------------------------------- 1 | {% load mfa %} 2 | 3 |

Scan the code with your TOTP app and enter a valid code to finish registration.

4 | 5 | {{ mfa_data.url|qrcode }} 6 | {{ mfa_data.secret }} 7 | 8 |
9 | {% csrf_token %} 10 | {% for error in form.non_field_errors %} 11 |

{{ error }}

12 | {% endfor %} 13 | 17 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /mfa/templates/mfa/create_recovery.html: -------------------------------------------------------------------------------- 1 | {% load mfa %} 2 | 3 |

A recovery code can be used when you lose access to your other two-factor authentication options. Each recovery code can only be used once.

4 |

Make sure to store it in a safe place! If you lose your login keys and don’t have the recovery codes you will lose access to your account.

5 | 6 |
7 | {% csrf_token %} 8 | {% for error in form.non_field_errors %} 9 |

{{ error }}

10 | {% endfor %} 11 | 15 | 19 | 23 | 24 |
25 | -------------------------------------------------------------------------------- /mfa/templates/mfa/login_failed_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktranslate %}Attempted login to {{ site_name }} using a wrong two-factor authentication code{% endblocktranslate %} 3 | {% endautoescape %} -------------------------------------------------------------------------------- /mfa/templates/mfa/mfakey_confirm_delete.html: -------------------------------------------------------------------------------- 1 |

Are you sure you want to delete the key {{ object.name }}?

2 |
3 | {% csrf_token %} 4 | 5 |
6 | -------------------------------------------------------------------------------- /mfa/templates/mfa/mfakey_list.html: -------------------------------------------------------------------------------- 1 |

{% if object_list %}Login keys configured{% else %}No login keys configured{% endif %}

2 |

Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to log in.

3 | 4 | {% if object_list|length == 1 %} 5 |

6 | WARNING: 7 | You have registered only a single key. If you lose access to that key you won't be able log into your account again. 8 | Create recovery code 9 |

10 | {% endif %} 11 | 12 | 20 | 21 | Create TOTP key 22 | Create FIDO2 key 23 | Create recovery code 24 | -------------------------------------------------------------------------------- /mfa/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xi/django-mfa3/0ef97ebe4f3926550ae83dedf9305f7c62d68c33/mfa/templatetags/__init__.py -------------------------------------------------------------------------------- /mfa/templatetags/mfa.py: -------------------------------------------------------------------------------- 1 | import qrcode 2 | import qrcode.image.svg 3 | from django import template 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter(name='qrcode') 10 | def get_qrcode(url): 11 | img = qrcode.make(url, image_factory=qrcode.image.svg.SvgImage) 12 | s = img.to_string().decode('utf-8') 13 | i = s.find('/delete/', public(MFADeleteView.as_view()), name='delete'), 13 | path('create//', public(MFACreateView.as_view()), name='create'), 14 | path('auth//', MFAAuthView.as_view(), name='auth'), 15 | ] 16 | -------------------------------------------------------------------------------- /mfa/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth import login 4 | from django.contrib.auth import user_login_failed 5 | from django.contrib.auth.mixins import LoginRequiredMixin 6 | from django.contrib.auth.views import LoginView as DjangoLoginView 7 | from django.http import Http404 8 | from django.shortcuts import get_object_or_404 9 | from django.shortcuts import redirect 10 | from django.urls import reverse 11 | from django.utils.decorators import method_decorator 12 | from django.utils.functional import cached_property 13 | from django.utils.translation import gettext_lazy as _ 14 | from django.views.generic import DeleteView 15 | from django.views.generic import ListView 16 | 17 | from . import settings 18 | from .decorators import login_not_required 19 | from .decorators import stronghold_login_not_required 20 | from .forms import MFAAuthForm 21 | from .forms import MFACreateForm 22 | from .mail import send_mail 23 | from .mixins import MFAFormView 24 | from .models import MFAKey 25 | 26 | 27 | class LoginView(DjangoLoginView): 28 | def no_key_exists(self, form): 29 | return super().form_valid(form) 30 | 31 | def form_valid(self, form): 32 | user = form.get_user() 33 | if not user.mfakey_set.exists(): 34 | return self.no_key_exists(form) 35 | 36 | self.request.session['mfa_user'] = { 37 | 'pk': user.pk, 38 | 'backend': user.backend, 39 | } 40 | self.request.session['mfa_success_url'] = self.get_success_url() 41 | for method in settings.METHODS: 42 | if user.mfakey_set.filter(method=method).exists(): 43 | return redirect('mfa:auth', method) 44 | 45 | 46 | class MFAListView(LoginRequiredMixin, ListView): 47 | model = MFAKey 48 | 49 | def get_queryset(self): 50 | return super().get_queryset().filter(user=self.request.user) 51 | 52 | 53 | class MFADeleteView(LoginRequiredMixin, DeleteView): 54 | model = MFAKey 55 | 56 | def get_queryset(self): 57 | return super().get_queryset().filter(user=self.request.user) 58 | 59 | def get_success_url(self): 60 | return reverse('mfa:list') 61 | 62 | 63 | class MFACreateView(LoginRequiredMixin, MFAFormView): 64 | form_class = MFACreateForm 65 | 66 | def get_template_names(self): 67 | return f'mfa/create_{self.method.name}.html' 68 | 69 | def get_success_url(self): 70 | return reverse('mfa:list') 71 | 72 | def begin(self): 73 | return self.method.register_begin(self.request.user) 74 | 75 | def complete(self, code): 76 | return self.method.register_complete(self.challenge[1], code) 77 | 78 | def form_valid(self, form): 79 | MFAKey.objects.create( 80 | user=self.request.user, 81 | method=self.method.name, 82 | name=form.cleaned_data['name'], 83 | secret=form.cleaned_data['secret'], 84 | ) 85 | messages.success(self.request, _('Key was created successfully!')) 86 | return super().form_valid(form) 87 | 88 | 89 | @method_decorator(login_not_required, name='dispatch') 90 | @method_decorator(stronghold_login_not_required, name='dispatch') 91 | class MFAAuthView(MFAFormView): 92 | form_class = MFAAuthForm 93 | 94 | def get_template_names(self): 95 | return f'mfa/auth_{self.method.name}.html' 96 | 97 | def get_success_url(self): 98 | success_url = self.request.session.pop('mfa_success_url') 99 | if self.method.name == 'recovery': 100 | return reverse('mfa:list') 101 | else: 102 | return success_url 103 | 104 | @cached_property 105 | def user(self): 106 | try: 107 | user_data = self.request.session['mfa_user'] 108 | except KeyError as e: 109 | raise Http404 from e 110 | User = get_user_model() 111 | user = get_object_or_404(User, pk=user_data['pk']) 112 | user.backend = user_data['backend'] 113 | return user 114 | 115 | def begin(self): 116 | return self.method.authenticate_begin(self.user) 117 | 118 | def complete(self, code): 119 | return self.method.authenticate_complete( 120 | self.challenge[1], self.user, code, 121 | ) 122 | 123 | def form_invalid(self, form): 124 | user_login_failed.send( 125 | sender=__name__, 126 | credentials={'username': self.user.get_username()}, 127 | request=self.request, 128 | ) 129 | send_mail(self.user, self.method) 130 | return super().form_invalid(form) 131 | 132 | def form_valid(self, form): 133 | login(self.request, self.user) 134 | del self.request.session['mfa_user'] 135 | return super().form_valid(form) 136 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-mfa3" 7 | version = "1.0.0" 8 | description = "multi factor authentication for django" 9 | readme = "README.md" 10 | license = {text = "MIT"} 11 | keywords = ["django", "mfa", "two-factor-authentication", "webauthn", "fido2"] 12 | authors = [ 13 | {name = "Tobias Bengfort", email = "tobias.bengfort@posteo.de"}, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Environment :: Web Environment", 18 | "Framework :: Django", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | ] 25 | dependencies = [ 26 | "pyotp", 27 | "fido2>=2.0,<3.0", 28 | # https://github.com/lincolnloop/python-qrcode/issues/317 29 | "qrcode>=7.1,<7.4", 30 | "django>=3.2", 31 | ] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/xi/django-mfa3" 35 | 36 | [tool.setuptools.packages.find] 37 | include = ["mfa*"] 38 | 39 | [tool.setuptools.package-data] 40 | mfa = [ 41 | "templates/**/*.html", 42 | "templates/**/*.txt", 43 | "static/**/*.js", 44 | "locale/**/*.po", 45 | "locale/**/*.mo", 46 | ] 47 | 48 | [tool.ruff] 49 | exclude = ["migrations"] 50 | 51 | [tool.ruff.lint] 52 | select = ["E", "F", "W", "C9", "I", "Q", "UP", "RUF"] 53 | ignore = ["RUF012"] 54 | 55 | [tool.ruff.lint.flake8-quotes] 56 | inline-quotes = "single" 57 | 58 | [tool.ruff.lint.isort] 59 | force-single-line = true 60 | 61 | [tool.coverage.run] 62 | source = ["mfa"] 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xi/django-mfa3/0ef97ebe4f3926550ae83dedf9305f7c62d68c33/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import django 4 | 5 | DEBUG = True 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': ':memory:', 11 | } 12 | } 13 | 14 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 15 | 16 | MIDDLEWARE = [ 17 | 'django.middleware.common.CommonMiddleware', 18 | 'django.contrib.sessions.middleware.SessionMiddleware', 19 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 20 | 'django.contrib.messages.middleware.MessageMiddleware', 21 | 'mfa.middleware.MFAEnforceMiddleware', 22 | ] 23 | 24 | if django.VERSION >= (5, 1): 25 | MIDDLEWARE.append('django.contrib.auth.middleware.LoginRequiredMiddleware') 26 | 27 | AUTHENTICATION_BACKENDS = [ 28 | 'django.contrib.auth.backends.ModelBackend', 29 | ] 30 | 31 | PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] 32 | 33 | ROOT_URLCONF = 'tests.urls' 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.admin', 41 | 'mfa', 42 | ] 43 | 44 | TEMPLATES = [ 45 | { 46 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 47 | 'DIRS': [Path(__file__).parent / 'templates'], 48 | 'APP_DIRS': True, 49 | 'OPTIONS': { 50 | 'context_processors': [ 51 | 'django.template.context_processors.debug', 52 | 'django.template.context_processors.request', 53 | 'django.contrib.auth.context_processors.auth', 54 | 'django.contrib.messages.context_processors.messages', 55 | ] 56 | }, 57 | } 58 | ] 59 | 60 | SECRET_KEY = 'too-secret-for-test' 61 | SITE_ID = 1 62 | 63 | USE_I18N = False 64 | USE_L10N = False 65 | USE_TZ = False 66 | 67 | LOGIN_URL = '/login/' 68 | LOGIN_REDIRECT_URL = '/' 69 | 70 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 71 | 72 | MFA_DOMAIN = 'localhost' 73 | MFA_SITE_TITLE = 'Tests' 74 | -------------------------------------------------------------------------------- /tests/templates/mfa/login_failed_email.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.username }}, 2 | 3 | We detected an attempt to log in to your account on {{ site_name }} 4 | ({{ domain }}) using a wrong two-factor authentication code. This means someone 5 | managed to enter the correct password, but failed at {{ method }}. 6 | 7 | If this was you and you entered a wrong two-factor authentication code by 8 | accident, you may ignore this email. 9 | 10 | If this was not you, we strongly recommend to change your password. 11 | -------------------------------------------------------------------------------- /tests/templates/registration/login.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 | 4 | {{ form }} 5 | 6 | 7 |
8 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import pyotp 2 | from django.contrib.auth.hashers import make_password 3 | from django.contrib.auth.models import User 4 | from django.core import mail 5 | from django.test import TestCase 6 | from fido2.server import _verify_origin_for_rp 7 | 8 | from mfa.mail import send_mail 9 | from mfa.methods import fido2 10 | from mfa.models import MFAKey 11 | from mfa.templatetags.mfa import get_qrcode 12 | 13 | 14 | class MFATestCase(TestCase): 15 | def setUp(self): 16 | self.user = User.objects.create_user('test', password='password') 17 | 18 | def login(self): 19 | return self.client.post('/login/', { 20 | 'username': 'test', 21 | 'password': 'password', 22 | }) 23 | 24 | def assert_not_logged_in(self): 25 | res = self.client.get('/') 26 | self.assertEqual(res.status_code, 302) 27 | self.assertEqual(res.url, '/login/?next=/') 28 | 29 | 30 | class TOTPAuthViewTest(MFATestCase): 31 | def setUp(self): 32 | super().setUp() 33 | self.key = MFAKey.objects.create( 34 | user=self.user, 35 | method='TOTP', 36 | name='test', 37 | secret=pyotp.random_base32(), 38 | ) 39 | self.totp = pyotp.TOTP(self.key.secret) 40 | 41 | def test_happy_flow(self): 42 | res = self.login() 43 | self.assertEqual(res.status_code, 302) 44 | self.assertEqual(res.url, '/mfa/auth/TOTP/') 45 | 46 | res = self.client.get('/mfa/auth/TOTP/') 47 | self.assertEqual(res.status_code, 200) 48 | 49 | res = self.client.post('/mfa/auth/TOTP/', {'code': self.totp.now()}) 50 | self.assertEqual(res.status_code, 302) 51 | self.assertEqual(res.url, '/') 52 | 53 | res = self.client.get('/') 54 | self.assertEqual(res.status_code, 204) 55 | 56 | def test_invalid_method(self): 57 | self.login() 58 | res = self.client.get('/mfa/auth/INVALID/') 59 | self.assertEqual(res.status_code, 404) 60 | 61 | def test_not_logged_in_before_mfa_auth(self): 62 | self.login() 63 | self.assert_not_logged_in() 64 | 65 | def test_no_auth_without_login(self): 66 | res = self.client.get('/mfa/auth/TOTP/') 67 | self.assertEqual(res.status_code, 404) 68 | 69 | def test_no_auth_without_challenge(self): 70 | self.login() 71 | 72 | res = self.client.post('/mfa/auth/TOTP/', {'code': self.totp.now()}) 73 | self.assertEqual(res.status_code, 404) 74 | self.assert_not_logged_in() 75 | 76 | def test_wrong_code(self): 77 | self.login() 78 | 79 | res = self.client.get('/mfa/auth/TOTP/') 80 | self.assertEqual(res.status_code, 200) 81 | 82 | res = self.client.post('/mfa/auth/TOTP/', {'code': 'invalid'}) 83 | self.assertEqual(res.status_code, 200) 84 | self.assert_not_logged_in() 85 | 86 | 87 | class TOTPCreateViewTest(MFATestCase): 88 | def test_happy_flow(self): 89 | self.client.force_login(self.user) 90 | 91 | res = self.client.get('/mfa/create/TOTP/') 92 | self.assertEqual(res.status_code, 200) 93 | totp = pyotp.TOTP(res.context['mfa_data']['secret']) 94 | 95 | res = self.client.post('/mfa/create/TOTP/', { 96 | 'name': 'test', 97 | 'code': totp.now() 98 | }) 99 | self.assertEqual(res.status_code, 302) 100 | self.assertEqual(res.url, '/mfa/') 101 | 102 | self.assertEqual(MFAKey.objects.count(), 1) 103 | 104 | def test_not_logged_in(self): 105 | res = self.client.get('/mfa/create/TOTP/') 106 | self.assertEqual(res.status_code, 302) 107 | self.assertEqual(res.url, '/login/?next=/mfa/create/TOTP/') 108 | 109 | def test_no_create_without_challenge(self): 110 | self.client.force_login(self.user) 111 | 112 | res = self.client.post('/mfa/create/TOTP/', { 113 | 'name': 'test', 114 | 'code': 'invalid', 115 | }) 116 | self.assertEqual(res.status_code, 404) 117 | self.assertEqual(MFAKey.objects.count(), 0) 118 | 119 | def test_wrong_code(self): 120 | self.client.force_login(self.user) 121 | 122 | res = self.client.get('/mfa/create/TOTP/') 123 | 124 | res = self.client.post('/mfa/create/TOTP/', { 125 | 'name': 'test', 126 | 'code': 'invalid', 127 | }) 128 | self.assertEqual(res.status_code, 200) 129 | self.assertEqual(MFAKey.objects.count(), 0) 130 | 131 | def test_new_challenge_on_get(self): 132 | self.client.force_login(self.user) 133 | 134 | res = self.client.get('/mfa/create/TOTP/') 135 | secret1 = res.context['mfa_data']['secret'] 136 | 137 | res = self.client.get('/mfa/create/TOTP/') 138 | secret2 = res.context['mfa_data']['secret'] 139 | 140 | self.assertNotEqual(secret1, secret2) 141 | 142 | totp = pyotp.TOTP(secret1) 143 | self.client.post('/mfa/create/TOTP/', { 144 | 'name': 'test', 145 | 'code': totp.now() 146 | }) 147 | self.assertEqual(MFAKey.objects.count(), 0) 148 | 149 | def test_keep_challenge_on_validation(self): 150 | self.client.force_login(self.user) 151 | 152 | res = self.client.get('/mfa/create/TOTP/') 153 | secret1 = res.context['mfa_data']['secret'] 154 | 155 | res = self.client.post('/mfa/create/TOTP/', { 156 | 'name': 'test', 157 | 'code': 'invalid', 158 | }) 159 | secret2 = res.context['mfa_data']['secret'] 160 | self.assertEqual(secret1, secret2) 161 | 162 | totp = pyotp.TOTP(secret1) 163 | self.client.post('/mfa/create/TOTP/', { 164 | 'name': 'test', 165 | 'code': totp.now() 166 | }) 167 | self.assertEqual(MFAKey.objects.count(), 1) 168 | 169 | 170 | class FIDO2Test(MFATestCase): 171 | # I have no clue how to simulate a FIDO2 authenticator, 172 | # so these are just some smoke tests. 173 | 174 | def test_login_redirect(self): 175 | self.key = MFAKey.objects.create( 176 | user=self.user, 177 | method='FIDO2', 178 | name='test', 179 | secret='mock', 180 | ) 181 | res = self.login() 182 | self.assertEqual(res.status_code, 302) 183 | self.assertEqual(res.url, '/mfa/auth/FIDO2/') 184 | 185 | def test_create(self): 186 | self.client.force_login(self.user) 187 | res = self.client.get('/mfa/create/FIDO2/') 188 | self.assertEqual(res.status_code, 200) 189 | 190 | def test_origin_https(self): 191 | for domain, value, expected in [ 192 | ('example.com', 'https://example.com', True), 193 | ('example.com', 'http://example.com', False), 194 | ('example.com', 'http://localhost:8000', False), 195 | ('localhost', 'https://example.com', False), 196 | ('localhost', 'http://localhost:8000', True), 197 | ('localhost', 'http://127.0.0.1', False), 198 | ('localhost', 'http://foo.localhost', True), 199 | ('127.0.0.1', 'http://127.0.0.1', False), 200 | ('foo.localhost', 'http://foo.localhost', True), 201 | ]: 202 | with self.subTest(domain=domain, value=value): 203 | verify = _verify_origin_for_rp(domain) 204 | self.assertEqual(verify(value), expected) 205 | 206 | 207 | class RecoveryTest(MFATestCase): 208 | def test_create(self): 209 | self.client.force_login(self.user) 210 | 211 | res = self.client.get('/mfa/create/recovery/') 212 | self.assertEqual(res.status_code, 200) 213 | code = res.context['mfa_data']['code'] 214 | self.assertEqual(len(code), 11) 215 | 216 | res = self.client.post('/mfa/create/recovery/', { 217 | 'name': 'test', 218 | 'code': 'invalid', 219 | }) 220 | self.assertEqual(res.status_code, 200) 221 | 222 | res = self.client.post('/mfa/create/recovery/', { 223 | 'name': 'test', 224 | 'code': code, 225 | }) 226 | self.assertEqual(res.status_code, 302) 227 | self.assertEqual(res.url, '/mfa/') 228 | 229 | self.assertEqual(MFAKey.objects.count(), 1) 230 | 231 | def test_authenticate(self): 232 | MFAKey.objects.create( 233 | user=self.user, 234 | method='FIDO2', 235 | name='test', 236 | secret='mock', 237 | ) 238 | MFAKey.objects.create( 239 | user=self.user, 240 | method='recovery', 241 | name='recovery', 242 | secret=make_password('123456'), 243 | ) 244 | 245 | res = self.login() 246 | self.assertEqual(res.status_code, 302) 247 | 248 | res = self.client.get('/mfa/auth/recovery/') 249 | self.assertEqual(res.status_code, 200) 250 | 251 | res = self.client.post('/mfa/auth/recovery/', {'code': 'invalid'}) 252 | self.assertEqual(res.status_code, 200) 253 | 254 | res = self.client.post('/mfa/auth/recovery/', {'code': '123456'}) 255 | self.assertEqual(res.status_code, 302) 256 | self.assertEqual(res.url, '/mfa/') 257 | 258 | res = self.client.get('/') 259 | self.assertEqual(res.status_code, 204) 260 | 261 | self.assertEqual(MFAKey.objects.count(), 1) 262 | 263 | 264 | class MFAEnforceMiddlewareTest(MFATestCase): 265 | def test_redirect(self): 266 | self.login() 267 | res = self.client.get('/') 268 | self.assertEqual(res.status_code, 302) 269 | self.assertEqual(res.url, '/mfa/') 270 | 271 | def test_public(self): 272 | self.login() 273 | res = self.client.post('/logout/') 274 | self.assertEqual(res.status_code, 302) 275 | self.assertEqual(res.url, '/') 276 | 277 | 278 | class PatchAdminTest(TestCase): 279 | def test_root(self): 280 | res = self.client.get('/admin/') 281 | self.assertEqual(res.status_code, 302) 282 | self.assertEqual(res.url, '/admin/login/?next=/admin/') 283 | 284 | res = self.client.get(res.url) 285 | self.assertEqual(res.status_code, 302) 286 | self.assertEqual(res.url, '/login/?next=/admin/') 287 | 288 | def test_app(self): 289 | res = self.client.get('/admin/mfa/') 290 | self.assertEqual(res.status_code, 302) 291 | self.assertEqual(res.url, '/admin/login/?next=/admin/mfa/') 292 | 293 | res = self.client.get(res.url) 294 | self.assertEqual(res.status_code, 302) 295 | self.assertEqual(res.url, '/login/?next=/admin/mfa/') 296 | 297 | def test_login(self): 298 | res = self.client.get('/admin/login/') 299 | self.assertEqual(res.status_code, 302) 300 | self.assertEqual(res.url, '/login/?next=/admin/') 301 | 302 | 303 | class ListViewTest(MFATestCase): 304 | def test_list_view(self): 305 | self.client.force_login(self.user) 306 | MFAKey.objects.create( 307 | user=self.user, 308 | method='recovery', 309 | name='recovery', 310 | secret=make_password('123456'), 311 | ) 312 | res = self.client.get('/mfa/') 313 | self.assertEqual(res.status_code, 200) 314 | self.assertEqual(res.content.count(b'
  • '), 1) 315 | 316 | 317 | class DeleteViewTest(MFATestCase): 318 | def test_delete_view(self): 319 | self.client.force_login(self.user) 320 | key = MFAKey.objects.create( 321 | user=self.user, 322 | method='recovery', 323 | name='recovery', 324 | secret=make_password('123456'), 325 | ) 326 | res = self.client.post(f'/mfa/{key.pk}/delete/') 327 | self.assertEqual(res.status_code, 302) 328 | self.assertEqual(res.url, '/mfa/') 329 | self.assertEqual(MFAKey.objects.filter(pk=key.pk).count(), 0) 330 | 331 | 332 | class QRCodeTest(TestCase): 333 | def test_is_svg(self): 334 | code = get_qrcode('some_data') 335 | self.assertTrue(code.startswith('')) 337 | 338 | 339 | class MailTest(TestCase): 340 | def setUp(self): 341 | self.user = User.objects.create_user( 342 | 'test', password='password', email='test@example.com' 343 | ) 344 | 345 | def test_send_mail(self): 346 | count = send_mail(self.user, fido2) 347 | self.assertEqual(count, 1) 348 | 349 | message = mail.outbox[0] 350 | self.assertEqual(message.to, ['test@example.com']) 351 | self.assertEqual(message.subject, 'Attempted login to Tests using a wrong two-factor authentication code') # noqa 352 | self.assertEqual(message.body, """Dear test, 353 | 354 | We detected an attempt to log in to your account on Tests 355 | (localhost) using a wrong two-factor authentication code. This means someone 356 | managed to enter the correct password, but failed at FIDO2. 357 | 358 | If this was you and you entered a wrong two-factor authentication code by 359 | accident, you may ignore this email. 360 | 361 | If this was not you, we strongly recommend to change your password. 362 | """) 363 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.decorators import login_required 3 | from django.contrib.auth.views import LogoutView 4 | from django.http import HttpResponse 5 | from django.urls import include 6 | from django.urls import path 7 | 8 | from mfa.decorators import public 9 | from mfa.views import LoginView 10 | 11 | 12 | def dummy(request): 13 | return HttpResponse(status=204) 14 | 15 | 16 | urlpatterns = [ 17 | path('', login_required(dummy)), 18 | path('login/', LoginView.as_view()), 19 | path('logout/', public(LogoutView.as_view(next_page='/'))), 20 | path('admin/', admin.site.urls), 21 | path('mfa/', include('mfa.urls', namespace='mfa')), 22 | ] 23 | --------------------------------------------------------------------------------