├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
13 | {% for key in object_list %}
14 |
15 | {{ key.name }} ({{ key.method }})
16 | Delete
17 |
18 | {% endfor %}
19 |
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 |
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 |
--------------------------------------------------------------------------------