diff --git a/castellum/templates/mfa/auth_FIDO2.html b/castellum/templates/mfa/auth_FIDO2.html
new file mode 100644
index 0000000000000000000000000000000000000000..925badbe2ad153ef36c5cfe6c8c04afbae043ce7
--- /dev/null
+++ b/castellum/templates/mfa/auth_FIDO2.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% load static i18n %}
+
+{% block title %}{% translate 'Two-factor authentication' %} · {{ block.super }}{% endblock %}
+
+{% block content %}
+
+
{% translate 'Two-factor authentication' %}
+
{% translate 'When you are ready to authenticate with FIDO2, press the button below.' %}
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+
+{% endblock %}
diff --git a/castellum/templates/mfa/auth_TOTP.html b/castellum/templates/mfa/auth_TOTP.html
new file mode 100644
index 0000000000000000000000000000000000000000..93cc050235fc8c1f8383bebbbdffc3ebe239d098
--- /dev/null
+++ b/castellum/templates/mfa/auth_TOTP.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% load static i18n bootstrap4 %}
+
+{% block title %}{% translate 'Two-factor authentication' %} · {{ block.super }}{% endblock %}
+
+{% block content %}
+
+
{% translate 'Two-factor authentication' %}
+
{% translate 'Open your TOTP app to get an authentication code.' %}
+
+
+
+{% endblock %}
diff --git a/castellum/templates/mfa/base.html b/castellum/templates/mfa/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..5397e87cb61a45abe53a8331abf428c220c81422
--- /dev/null
+++ b/castellum/templates/mfa/base.html
@@ -0,0 +1,6 @@
+{% extends "base_with_breadcrumbs.html" %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+
{% translate "Login keys" %}
+{% endblock %}
diff --git a/castellum/templates/mfa/create_FIDO2.html b/castellum/templates/mfa/create_FIDO2.html
new file mode 100644
index 0000000000000000000000000000000000000000..135d17cf335f0ae7ae58ccbd90a5f117a4ff9cdd
--- /dev/null
+++ b/castellum/templates/mfa/create_FIDO2.html
@@ -0,0 +1,25 @@
+{% extends "mfa/base.html" %}
+{% load static i18n bootstrap4%}
+
+{% block title %}{% translate 'Add FIDO2 key' %} · {{ block.super }}{% endblock %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
{% translate 'Add FIDO2 key' %}
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+
+{% endblock %}
diff --git a/castellum/templates/mfa/create_TOTP.html b/castellum/templates/mfa/create_TOTP.html
new file mode 100644
index 0000000000000000000000000000000000000000..04eca9afaac21b1b75edfb2445207be37df8fe82
--- /dev/null
+++ b/castellum/templates/mfa/create_TOTP.html
@@ -0,0 +1,30 @@
+{% extends "mfa/base.html" %}
+{% load static i18n bootstrap4 mfa %}
+
+{% block title %}{% translate 'Add TOTP key' %} · {{ block.super }}{% endblock %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
{% translate 'Add TOTP key' %}
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/castellum/templates/mfa/mfakey_confirm_delete.html b/castellum/templates/mfa/mfakey_confirm_delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..fe1ec7dd02e92a40fbf4154998674a2e49715412
--- /dev/null
+++ b/castellum/templates/mfa/mfakey_confirm_delete.html
@@ -0,0 +1,11 @@
+{% extends "mfa/base.html" %}
+{% load i18n %}
+
+{% block content %}
+
{% blocktrans with name=object.name %}Are you sure you want to delete the key "{{ name }}"?{% endblocktrans %}
+
+{% endblock %}
diff --git a/castellum/templates/mfa/mfakey_list.html b/castellum/templates/mfa/mfakey_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..a638018e159642f1c823ed600feef542ee900e2d
--- /dev/null
+++ b/castellum/templates/mfa/mfakey_list.html
@@ -0,0 +1,37 @@
+{% extends "mfa/base.html" %}
+{% load i18n %}
+
+{% block content %}
+
+
+
+ {% if object_list %}
+ {% translate 'Login keys configured' %}
+ {% else %}
+ {% translate 'No login keys configured' %}
+ {% endif %}
+
+
+
{% translate 'Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to log in.' %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/castellum/urls.py b/castellum/urls.py
index 2ee160875877d87f570efe2b88117b756c0e469b..2b9f94d66cb025f36415a1e65c04befa17e6b6e1 100644
--- a/castellum/urls.py
+++ b/castellum/urls.py
@@ -25,7 +25,6 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.views import LoginView
from django.contrib.auth.views import LogoutView
from django.http import HttpResponse
from django.urls import include
@@ -35,6 +34,8 @@ from django.views.generic import TemplateView
from django.views.i18n import JavaScriptCatalog
from django.views.static import serve
+from mfa.decorators import public as mfa_public
+from mfa.views import LoginView
from stronghold.decorators import public
from castellum.castellum_auth.forms import AuthenticationForm
@@ -85,13 +86,13 @@ urlpatterns = [
template_name="login.html",
authentication_form=AuthenticationForm,
), name='login'),
- path('logout/', LogoutView.as_view(), name='logout'),
- path('favicon.ico', public(RedirectView.as_view(url='/static/images/favicon.ico'))),
+ path('logout/', mfa_public(LogoutView.as_view()), name='logout'),
+ path('favicon.ico', mfa_public(public(RedirectView.as_view(url='/static/images/favicon.ico')))),
path('ping/', dummy, name='ping'),
path('feeds/', FeedsView.as_view(), name='feeds'),
- path('i18n/', set_language, name='set_language'),
- path('jsi18n/', public(JavaScriptCatalog.as_view()), name='javascript-catalog'),
+ path('i18n/', mfa_public(set_language), name='set_language'),
+ path('jsi18n/', mfa_public(public(JavaScriptCatalog.as_view())), name='javascript-catalog'),
path('admin/', admin.site.urls),
path('studies/', include('castellum.studies.urls', namespace='studies')),
@@ -103,6 +104,7 @@ urlpatterns = [
path(
'data-protection/', include('castellum.data_protection.urls', namespace='data_protection')
),
+ path('mfa/', include('mfa.urls', namespace='mfa')),
]
if settings.PROTECTED_MEDIA_SERVER:
diff --git a/docs/example_deployment/settings.py b/docs/example_deployment/settings.py
index 70ac99fbe8ac7ef5b7f34160b02ffbacb033b26d..6dbb1e24a1ef8e7b4cf457d3bdf2e88ab87af222 100644
--- a/docs/example_deployment/settings.py
+++ b/docs/example_deployment/settings.py
@@ -34,6 +34,10 @@ PROTECTED_MEDIA_SERVER = 'uwsgi'
# The example deployment does not contain a mail server, so use dummy instead
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+# See https://github.com/xi/django-mfa3
+MFA_DOMAIN = 'example.com'
+MFA_SITE_TITLE = 'Castellum Demo'
+
# See https://docs.djangoproject.com/en/stable/topics/logging/
ADMINS = [('admin', 'admin@example.com')]
LOGGING = {
diff --git a/package.json b/package.json
index 21c95fb11204e3306e88903c4863f2d9ff54ab6d..2f1cdcb00eab2762e0c43971249c76d39137142d 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"@fortawesome/fontawesome-free": "^5.15.3",
"@ttskch/select2-bootstrap4-theme": "^1.5.2",
"bootstrap": "^4.6.0",
+ "cbor-js": "^0.1.0",
"fullcalendar-scheduler": "^5.8.0",
"jdenticon": "^3.1.0",
"jquery": "^3.6.0",
diff --git a/setup.cfg b/setup.cfg
index e89f5bd0e74126baaccacd73bb61462e4e64c474..438d0d14b65c32b405cff025d5ba198e3a1d4710 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,7 @@ install_requires =
Django == 3.2.5
django-bootstrap4 == 3.0.1
django-ical == 1.8.0
+ django-mfa3 == 0.2.2
django-npm == 1.0.0
django-parler == 2.2.0
django-phonenumber-field == 5.2.0
diff --git a/tests/studies/views/test_diff_view.py b/tests/studies/views/test_diff_view.py
index 8dd46c6a071d19fcd3422091c18977019c906178..a1629544a9f207c938c17d664a8c58842de3e86e 100644
--- a/tests/studies/views/test_diff_view.py
+++ b/tests/studies/views/test_diff_view.py
@@ -15,6 +15,7 @@ EXCLUDED = [
'contacts.',
'contenttypes.',
'geofilters.Geolocation.',
+ 'mfa.MFAKey.',
'pseudonyms.Pseudonym.',
'recruitment.AttributeCategory.',
'recruitment.AttributeCategoryTranslation.',
diff --git a/tests/subjects/views/test_subject_export.py b/tests/subjects/views/test_subject_export.py
index 0990416b58953610b9e3d7cabad4b7495457fea7..f6031bc67969f94feef721ea97f71fded872e77b 100644
--- a/tests/subjects/views/test_subject_export.py
+++ b/tests/subjects/views/test_subject_export.py
@@ -26,6 +26,7 @@ EXCLUDED = [
'contacts.Street.',
'contenttypes.',
'geofilters.Geolocation.',
+ 'mfa.MFAKey.',
'pseudonyms.Domain.',
'pseudonyms.Pseudonym.',
'recruitment.AttributeCategory.',