diff --git a/Dockerfile b/Dockerfile index dba4c93908fb76013d046fffb39bd18b24b2771e..1fb402d78c0a311db70f80c3571be4d493e6ea4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM alpine:3.13.5 ENV PYTHONUNBUFFERED 1 ENV BUILDPKGS gettext py3-pip py3-wheel -ENV RUNTIMEPKGS uwsgi uwsgi-python uwsgi-router_static python3 py3-psycopg2 py3-pyldap libmagic proj gdal +ENV RUNTIMEPKGS uwsgi uwsgi-python uwsgi-router_static python3 py3-psycopg2 py3-pyldap py3-cryptography libmagic proj gdal RUN adduser -D -g '' uwsgi diff --git a/castellum/settings/default.py b/castellum/settings/default.py index 42427d9cfe2c64518620ac75695aaceb72bba4a2..9bfd47464fc6b2563b91f4b5637af31823fd738d 100644 --- a/castellum/settings/default.py +++ b/castellum/settings/default.py @@ -29,6 +29,7 @@ INSTALLED_APPS = [ 'phonenumber_field', 'parler', 'stronghold', + 'mfa', 'castellum.utils', 'castellum.castellum_auth', @@ -238,6 +239,9 @@ NPM_FILE_PATTERNS = { 'main.css', 'locales/de.js', ], + 'cbor-js': [ + 'cbor.js', + ], } BOOTSTRAP4 = { @@ -247,6 +251,11 @@ BOOTSTRAP4 = { AXES_LOCKOUT_TEMPLATE = 'axes-lockout.html' +# Two factor authentication +# See https://github.com/xi/django-mfa3 +MFA_SITE_TITLE = 'Castellum' +MFA_DOMAIN = None + # Secondary language in emails for readers who do not understand the # primary language diff --git a/castellum/static/images/circle-icons/key.png b/castellum/static/images/circle-icons/key.png new file mode 100644 index 0000000000000000000000000000000000000000..89d0d935cbdd99d169a887549c9a4edc312a091f Binary files /dev/null and b/castellum/static/images/circle-icons/key.png differ diff --git a/castellum/templates/index.html b/castellum/templates/index.html index 0688a043b6bb5ed0ad0e770afbcb47a1b5e8ad97..bdb8a710e52e641ad6b090cc281c96bec3f084e6 100644 --- a/castellum/templates/index.html +++ b/castellum/templates/index.html @@ -56,6 +56,17 @@ {% endif %} +
+
+ + + + +
+
+ {% if user.is_staff or user.is_superuser %}
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.' %}

+ +
+ {% csrf_token %} + {% include 'utils/form_errors.html' with form=form %} + {{ form.code.as_hidden }} + + {% translate 'Use TOTP instead' %} +
+
+{% 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.' %}

+ +
+ {% csrf_token %} + {% include 'utils/form_errors.html' with form=form %} + {% bootstrap_field form.code %} + + {% translate 'Use FIDO2 instead' %} +
+
+{% 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 %} + +{% 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 }} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% include 'utils/form_errors.html' with form=form %} + {% bootstrap_field form.name %} +

{% translate 'You will be prompted to activate the device once you click "create".' %}

+ {{ form.code.as_hidden }} + +
+{% 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 }} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% include 'utils/form_errors.html' with form=form %} + {% bootstrap_field form.name %} + +
+
+

{% translate 'Scan the QR-code with your TOTP app and enter a valid code to finish registration.' %}

+ + + + {% bootstrap_field form.code %} +
+
+ +
+{% 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 %}

+
+ {% csrf_token %} + {% translate 'Cancel' %} + +
+{% 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.' %}

+ +
    + {% for key in object_list %} +
  • +
    + {{ key.name }} + {{ key.method }} +
    + +
  • + {% endfor %} +
+ +
+ {% translate 'Add TOTP key' %} + {% translate 'Add FIDO2 key' %} +
+
+
+{% 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.',