diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a198fd1930..3cde31cd92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,9 +265,6 @@ jobs: DB_USER: postgres DB_PASSWORD: '' E2E_DRIVER: ${{ matrix.browser }} - # with 2FA enabled, *for some reason* this doesn't work on CI -> can't find - # the inputs - TWO_FACTOR_PATCH_ADMIN: 'no' SDK_RELEASE: ${{ steps.sdk-tag.outputs.sdk_tag }} docs: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2756451f2e..ea994e5679 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ the time of writing, such a version has not been released yet. .. todo:: At release time (2.6.0), check if we need to gate this functionality behind a feature flag to prevent issues. +The ``TWO_FACTOR_FORCE_OTP_ADMIN`` and ``TWO_FACTOR_PATCH_ADMIN`` environment variables +are removed. Disabling MFA in the admin is no longer possible. Note that the OIDC +login backends do not require (additional) MFA in the admin and we've added support for +hardware tokens (like the YubiKey) which make MFA less of a nuisance. + 2.5.2 (2024-02-06) ================== diff --git a/docker-compose.yml b/docker-compose.yml index d636dd744c..18645efb85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,8 +70,6 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_LOGLEVEL=DEBUG - OPENFORMS_LOCATION_CLIENT=${OPENFORMS_LOCATION_CLIENT:-openforms.contrib.bag.client.BAGClient} - - TWO_FACTOR_FORCE_OTP_ADMIN=0 - - TWO_FACTOR_PATCH_ADMIN=0 - CORS_ALLOW_ALL_ORIGINS=${CORS_ALLOW_ALL_ORIGINS:-true} - EMAIL_HOST=smtp # Needed for Celery Flower to match the TIME_ZONE configured in the diff --git a/docs/developers/backend/tests.rst b/docs/developers/backend/tests.rst index 034fd21159..0335d54186 100644 --- a/docs/developers/backend/tests.rst +++ b/docs/developers/backend/tests.rst @@ -83,12 +83,7 @@ After installing the dependencies, install the browsers locally: .. code-block:: bash - TWO_FACTOR_PATCH_ADMIN=no python src/manage.py test src --tag=e2e - -.. note:: When the admin is monkeypatched to enable 2FA behaviour, it's been observed - that the end to end tests fail to run/complete properly. Disabling this via your - local settings or the environment variable ``TWO_FACTOR_PATCH_ADMIN=no`` mitigates - this. + python src/manage.py test src --tag=e2e **Configuration** diff --git a/docs/installation/config.rst b/docs/installation/config.rst index 5bb639135e..70f43c62c9 100644 --- a/docs/installation/config.rst +++ b/docs/installation/config.rst @@ -316,12 +316,6 @@ Other settings `upstream documentation `_ for more context. Defaults to ``1``. -* ``TWO_FACTOR_FORCE_OTP_ADMIN``: Enforce 2 Factor Authentication in the admin or not. - Default ``True``. You'll probably want to disable this when using OIDC. - -* ``TWO_FACTOR_PATCH_ADMIN``: Whether to use the 2 Factor Authentication login flow for - the admin or not. Default ``True``. You'll probably want to disable this when using OIDC. - * ``FORMS_EXPORT_REMOVED_AFTER_DAYS``: The number of days after which zip files of exported forms should be deleted. Defaults to 7 days. diff --git a/docs/installation/security.rst b/docs/installation/security.rst index 6f0c627e1e..b9ca1bdd7e 100644 --- a/docs/installation/security.rst +++ b/docs/installation/security.rst @@ -191,17 +191,11 @@ The internal URLs are: Two-factor auth =============== -By default, the admin interface requires two-factor authentication using OTP. We only -encourage disabling this when you are using single-sign-on via OIDC instead of username -+ password authentication. - -The recommended settings are: - -.. code-block:: bash - - TWO_FACTOR_FORCE_OTP_ADMIN=True - TWO_FACTOR_PATCH_ADMIN=True - +The admin interface requires two-factor authentication using OTP (using Microsoft or +Google's Authenticator app) or hardware tokens such as YubiKeys. If you use a single +sign on solution (e.g. Keycloak OIDC, Azure AD OIDC...), it is assumed that the second +factor is enforced on those products and staff users do not need to provide an +additional second factor in Open Forms. .. _installation_config_webserver: diff --git a/dotenv.example b/dotenv.example index a17c0702a2..b1b50496e4 100644 --- a/dotenv.example +++ b/dotenv.example @@ -8,8 +8,6 @@ DB_USER=open-forms DB_PASSWORD="" DB_HOST="" -TWO_FACTOR_PATCH_ADMIN=no -TWO_FACTOR_FORCE_OTP_ADMIN=no # LANGUAGE_CODE=nl #CORS_ALLOW_ALL_ORIGINS=yes # CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000 @@ -32,3 +30,6 @@ TWO_FACTOR_FORCE_OTP_ADMIN=no # Recording Suwinet VCR cassettes # SUWINET_CLIENT_KEY=/path/to/privatekey.pem # SUWINET_BASE_URL=https://url/of/gateway/suwiml + +# Applies to dev settings module only! +DISABLE_2FA=yes diff --git a/requirements/base.in b/requirements/base.in index 8895acfa08..f323dc518b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -15,7 +15,6 @@ html5lib --no-binary lxml lxml O365 # microsoft graph -phonenumbers Pillow # handle images portalocker[redis] psycopg2 # database driver @@ -62,7 +61,7 @@ django-tinymce django-treebeard django-yubin mozilla-django-oidc-db -maykin-django-two-factor-auth[phonenumbers] +maykin-2fa django-timeline-logger django-csp django-csp-reports diff --git a/requirements/base.txt b/requirements/base.txt index 44e5da82db..e8a8357d81 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -14,6 +14,8 @@ ape-pie==0.1.0 # zgw-consumers asgiref==3.5.0 # via django +asn1crypto==1.5.1 + # via webauthn async-timeout==4.0.2 # via redis attrs==20.3.0 @@ -35,6 +37,8 @@ boltons==21.0.0 # glom brotli==1.1.0 # via fonttools +cbor2==5.6.1 + # via webauthn celery==5.2.7 # via # -r requirements/base.in @@ -78,6 +82,7 @@ cryptography==42.0.2 # josepy # mozilla-django-oidc # pyopenssl + # webauthn cssselect2==0.7.0 # via weasyprint defusedxml==0.7.1 @@ -116,13 +121,14 @@ django==3.2.24 # django-solo # django-timeline-logger # django-treebeard + # django-two-factor-auth # djangorestframework # drf-jsonschema-serializer # drf-nested-routers # drf-polymorphic # drf-spectacular # mail-cleaner - # maykin-django-two-factor-auth + # maykin-2fa # mozilla-django-oidc # mozilla-django-oidc-db # sentry-sdk @@ -159,7 +165,7 @@ django-digid-eherkenning==0.10.0 django-filter==23.2 # via -r requirements/base.in django-formtools==2.3 - # via maykin-django-two-factor-auth + # via django-two-factor-auth django-hijack==3.4.1 # via -r requirements/base.in django-ipware==5.0.0 @@ -176,10 +182,10 @@ django-ordered-model==3.6 # via # -r requirements/base.in # django-admin-index -django-otp==1.0.6 - # via maykin-django-two-factor-auth +django-otp==1.3.0 + # via django-two-factor-auth django-phonenumber-field==5.2.0 - # via maykin-django-two-factor-auth + # via django-two-factor-auth django-privates==1.5.0 # via # -r requirements/base.in @@ -211,6 +217,8 @@ django-tinymce==3.6.1 # via -r requirements/base.in django-treebeard==4.7 # via -r requirements/base.in +django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 + # via maykin-2fa django-yubin==2.0.2 # via -r requirements/base.in djangorestframework==3.14.0 @@ -303,7 +311,7 @@ mail-cleaner==1.2.0 # via -r requirements/base.in mail-parser==3.15.0 # via django-yubin -maykin-django-two-factor-auth[phonenumbers]==2.0.4 +maykin-2fa==1.0.0 # via -r requirements/base.in maykin-json-logic-py==0.13.0 # via -r requirements/base.in @@ -331,10 +339,8 @@ packaging==23.1 # via prance pathable==0.4.3 # via jsonschema-spec -phonenumbers==8.12.29 - # via - # -r requirements/base.in - # maykin-django-two-factor-auth +phonenumberslite==8.13.29 + # via django-two-factor-auth pillow==10.2.0 # via # -r requirements/base.in @@ -363,6 +369,7 @@ pyopenssl==24.0.0 # django-simple-certmanager # josepy # maykin-python3-saml + # webauthn # zgw-consumers pyphen==0.10.0 # via weasyprint @@ -396,7 +403,7 @@ pyyaml==6.0.1 # gemma-zds-client # jsonschema-spec qrcode==6.1 - # via maykin-django-two-factor-auth + # via django-two-factor-auth redis==4.5.4 # via # celery-once @@ -494,6 +501,8 @@ wcwidth==0.2.5 # via prompt-toolkit weasyprint==60.1 # via -r requirements/base.in +webauthn==2.0.0 + # via django-two-factor-auth webencodings==0.5.1 # via # bleach diff --git a/requirements/ci.txt b/requirements/ci.txt index f19a6318d2..d039c97de9 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -23,6 +23,11 @@ asgiref==3.5.0 # -c requirements/base.txt # -r requirements/base.txt # django +asn1crypto==1.5.1 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # webauthn async-timeout==4.0.2 # via # -c requirements/base.txt @@ -67,6 +72,11 @@ brotli==1.1.0 # -c requirements/base.txt # -r requirements/base.txt # fonttools +cbor2==5.6.1 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # webauthn celery==5.2.7 # via # -c requirements/base.txt @@ -143,6 +153,7 @@ cryptography==42.0.2 # josepy # mozilla-django-oidc # pyopenssl + # webauthn cssselect==1.1.0 # via pyquery cssselect2==0.7.0 @@ -189,13 +200,14 @@ django==3.2.24 # django-solo # django-timeline-logger # django-treebeard + # django-two-factor-auth # djangorestframework # drf-jsonschema-serializer # drf-nested-routers # drf-polymorphic # drf-spectacular # mail-cleaner - # maykin-django-two-factor-auth + # maykin-2fa # mozilla-django-oidc # mozilla-django-oidc-db # sentry-sdk @@ -264,7 +276,7 @@ django-formtools==2.3 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-hijack==3.4.1 # via # -c requirements/base.txt @@ -294,16 +306,16 @@ django-ordered-model==3.6 # -c requirements/base.txt # -r requirements/base.txt # django-admin-index -django-otp==1.0.6 +django-otp==1.3.0 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-phonenumber-field==5.2.0 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-privates==1.5.0 # via # -c requirements/base.txt @@ -356,6 +368,12 @@ django-treebeard==4.7 # via # -c requirements/base.txt # -r requirements/base.txt +django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # django-two-factor-auth + # maykin-2fa django-webtest==1.9.7 # via -r requirements/test-tools.in django-yubin==2.0.2 @@ -559,11 +577,10 @@ markdown==3.3.4 # via sphinx-markdown-tables markupsafe==2.1.2 # via jinja2 -maykin-django-two-factor-auth[phonenumbers]==2.0.4 +maykin-2fa==1.0.0 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth maykin-json-logic-py==0.13.0 # via # -c requirements/base.txt @@ -638,11 +655,11 @@ pathspec==0.9.0 # via black pep8==1.7.1 # via -r requirements/test-tools.in -phonenumbers==8.12.29 +phonenumberslite==8.13.29 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth pillow==10.2.0 # via # -c requirements/base.txt @@ -715,6 +732,7 @@ pyopenssl==24.0.0 # django-simple-certmanager # josepy # maykin-python3-saml + # webauthn # zgw-consumers pyphen==0.10.0 # via @@ -779,7 +797,7 @@ qrcode==6.1 # via # -c requirements/base.txt # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth recommonmark==0.7.1 # via -r requirements/docs.in redis==4.5.4 @@ -1006,6 +1024,11 @@ weasyprint==60.1 # via # -c requirements/base.txt # -r requirements/base.txt +webauthn==2.0.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # django-two-factor-auth webencodings==0.5.1 # via # -c requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 36d9d579ee..ba4adea3c0 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -26,6 +26,11 @@ asgiref==3.5.0 # -c requirements/ci.txt # -r requirements/ci.txt # django +asn1crypto==1.5.1 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # webauthn async-timeout==4.0.2 # via # -c requirements/ci.txt @@ -81,6 +86,11 @@ build==0.8.0 # via pip-tools bump2version==1.0.0 # via -r requirements/dev.in +cbor2==5.6.1 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # webauthn celery==5.2.7 # via # -c requirements/ci.txt @@ -163,6 +173,7 @@ cryptography==42.0.2 # josepy # mozilla-django-oidc # pyopenssl + # webauthn cssselect==1.1.0 # via # -c requirements/ci.txt @@ -218,13 +229,14 @@ django==3.2.24 # django-solo # django-timeline-logger # django-treebeard + # django-two-factor-auth # djangorestframework # drf-jsonschema-serializer # drf-nested-routers # drf-polymorphic # drf-spectacular # mail-cleaner - # maykin-django-two-factor-auth + # maykin-2fa # mozilla-django-oidc # mozilla-django-oidc-db # sentry-sdk @@ -297,7 +309,7 @@ django-formtools==2.3 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-hijack==3.4.1 # via # -c requirements/ci.txt @@ -329,16 +341,16 @@ django-ordered-model==3.6 # -c requirements/ci.txt # -r requirements/ci.txt # django-admin-index -django-otp==1.0.6 +django-otp==1.3.0 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-phonenumber-field==5.2.0 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-privates==1.5.0 # via # -c requirements/ci.txt @@ -395,6 +407,12 @@ django-treebeard==4.7 # via # -c requirements/ci.txt # -r requirements/ci.txt +django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # django-two-factor-auth + # maykin-2fa django-webtest==1.9.7 # via # -c requirements/ci.txt @@ -640,11 +658,10 @@ markupsafe==2.1.2 # -c requirements/ci.txt # -r requirements/ci.txt # jinja2 -maykin-django-two-factor-auth[phonenumbers]==2.0.4 +maykin-2fa==1.0.0 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth maykin-json-logic-py==0.13.0 # via # -c requirements/ci.txt @@ -736,11 +753,11 @@ pep8==1.7.1 # via # -c requirements/ci.txt # -r requirements/ci.txt -phonenumbers==8.12.29 +phonenumberslite==8.13.29 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth + # django-two-factor-auth pillow==10.2.0 # via # -c requirements/ci.txt @@ -834,6 +851,7 @@ pyopenssl==24.0.0 # django-simple-certmanager # josepy # maykin-python3-saml + # webauthn # zgw-consumers pyphen==0.10.0 # via @@ -904,7 +922,7 @@ qrcode==6.1 # via # -c requirements/ci.txt # -r requirements/ci.txt - # maykin-django-two-factor-auth + # django-two-factor-auth recommonmark==0.7.1 # via # -c requirements/ci.txt @@ -1193,6 +1211,11 @@ weasyprint==60.1 # via # -c requirements/ci.txt # -r requirements/ci.txt +webauthn==2.0.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # django-two-factor-auth webencodings==0.5.1 # via # -c requirements/ci.txt diff --git a/requirements/extensions.txt b/requirements/extensions.txt index 9cb025ae2b..bcd148c67e 100644 --- a/requirements/extensions.txt +++ b/requirements/extensions.txt @@ -19,6 +19,10 @@ asgiref==3.5.0 # via # -r requirements/base.txt # django +asn1crypto==1.5.1 + # via + # -r requirements/base.txt + # webauthn async-timeout==4.0.2 # via # -r requirements/base.txt @@ -51,6 +55,10 @@ brotli==1.1.0 # via # -r requirements/base.txt # fonttools +cbor2==5.6.1 + # via + # -r requirements/base.txt + # webauthn celery==5.2.7 # via # -c requirements/base.in @@ -113,6 +121,7 @@ cryptography==42.0.2 # josepy # mozilla-django-oidc # pyopenssl + # webauthn cssselect2==0.7.0 # via # -r requirements/base.txt @@ -155,13 +164,14 @@ django==3.2.24 # django-solo # django-timeline-logger # django-treebeard + # django-two-factor-auth # djangorestframework # drf-jsonschema-serializer # drf-nested-routers # drf-polymorphic # drf-spectacular # mail-cleaner - # maykin-django-two-factor-auth + # maykin-2fa # mozilla-django-oidc # mozilla-django-oidc-db # open-forms-ext-haalcentraal-hr @@ -230,7 +240,7 @@ django-filter==23.2 django-formtools==2.3 # via # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-hijack==3.4.1 # via # -c requirements/base.in @@ -257,14 +267,14 @@ django-ordered-model==3.6 # -c requirements/base.in # -r requirements/base.txt # django-admin-index -django-otp==1.0.6 +django-otp==1.3.0 # via # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-phonenumber-field==5.2.0 # via # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth django-privates==1.5.0 # via # -c requirements/base.in @@ -313,6 +323,11 @@ django-treebeard==4.7 # via # -c requirements/base.in # -r requirements/base.txt +django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 + # via + # -r requirements/base.txt + # django-two-factor-auth + # maykin-2fa django-yubin==2.0.2 # via # -c requirements/base.in @@ -459,11 +474,10 @@ mail-parser==3.15.0 # via # -r requirements/base.txt # django-yubin -maykin-django-two-factor-auth[phonenumbers]==2.0.4 +maykin-2fa==1.0.0 # via # -c requirements/base.in # -r requirements/base.txt - # maykin-django-two-factor-auth maykin-json-logic-py==0.13.0 # via # -c requirements/base.in @@ -520,11 +534,10 @@ pathable==0.4.3 # via # -r requirements/base.txt # jsonschema-spec -phonenumbers==8.12.29 +phonenumberslite==8.13.29 # via - # -c requirements/base.in # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth pillow==10.2.0 # via # -c requirements/base.in @@ -574,6 +587,7 @@ pyopenssl==24.0.0 # django-simple-certmanager # josepy # maykin-python3-saml + # webauthn # zgw-consumers pyphen==0.10.0 # via @@ -624,7 +638,7 @@ pyyaml==6.0.1 qrcode==6.1 # via # -r requirements/base.txt - # maykin-django-two-factor-auth + # django-two-factor-auth redis==4.5.4 # via # -r requirements/base.txt @@ -776,6 +790,10 @@ weasyprint==60.1 # via # -c requirements/base.in # -r requirements/base.txt +webauthn==2.0.0 + # via + # -r requirements/base.txt + # django-two-factor-auth webencodings==0.5.1 # via # -r requirements/base.txt diff --git a/src/openforms/accounts/signals.py b/src/openforms/accounts/signals.py index be80a78854..92850db53c 100644 --- a/src/openforms/accounts/signals.py +++ b/src/openforms/accounts/signals.py @@ -1,46 +1,38 @@ +from typing import Any + from django.dispatch import receiver +from django.http import HttpRequest -from django_otp import DEVICE_ID_SESSION_KEY -from django_otp.plugins.otp_totp.models import TOTPDevice from hijack.signals import hijack_ended, hijack_started from openforms.logging import logevent - -def _set_session_device(request, device): - request.session[DEVICE_ID_SESSION_KEY] = device.persistent_id +from .models import User @receiver(hijack_started, dispatch_uid="hijack_started.manage_totp_device") -def handle_hijack_start(sender, hijacker, hijacked, request, **kwargs): +def handle_hijack_start( + sender: None, hijacker: User, hijacked: User, request: HttpRequest, **kwargs: Any +): """ - Potentially add a dummy hijack device to ensure two-factor hijacking. + Add an audit trail entry for the hijack action. + + Malicious actors can then not hijack another user and view potentially sensitive + data causing only the hijacked user to show up in the audit log instead of the + hijacker. """ - # add hijack actions to audit log - malicious actors can then not hijack another - # user and view potentially sensitive data causing them to show up in the audit log logevent.hijack_started(hijacker, hijacked) - hijack_device, _ = TOTPDevice.objects.get_or_create( - user=hijacked, - name="hijack_device", - ) - _set_session_device(request, hijack_device) - @receiver(hijack_ended, dispatch_uid="hijack_ended.manage_totp_device") -def handle_hijack_end(sender, hijacker, hijacked, request, **kwargs): +def handle_hijack_end( + sender: None, hijacker: User, hijacked: User, request: HttpRequest, **kwargs: Any +): """ - 1. Remove any dummy OTP devices for the hijacked user. - 2. Restore the original OTP device for the hijacker. + Add an audit trail entry for the hijack action. + + Malicious actors can then not hijack another user and view potentially sensitive + data causing only the hijacked user to show up in the audit log instead of the + hijacker. """ - # add hijack actions to audit log - malicious actors can then not hijack another - # user and view potentially sensitive data causing them to show up in the audit log logevent.hijack_ended(hijacker, hijacked) - - TOTPDevice.objects.filter(user=hijacked, name="hijack_device").delete() - - try: - device = TOTPDevice.objects.get(user=hijacker) - _set_session_device(request, device) - except TOTPDevice.DoesNotExist: - pass diff --git a/src/openforms/accounts/tests/factories.py b/src/openforms/accounts/tests/factories.py index 9818e73601..5b37bc1aef 100644 --- a/src/openforms/accounts/tests/factories.py +++ b/src/openforms/accounts/tests/factories.py @@ -1,13 +1,33 @@ -from django.conf import settings from django.contrib.auth.models import Group, Permission import factory +from django_otp.util import random_hex + + +class TOTPDeviceFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory("openforms.accounts.tests.factories.UserFactory") + key = factory.LazyAttribute(lambda o: random_hex()) + + class Meta: + model = "otp_totp.TOTPDevice" class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: f"user-{n}") password = factory.PostGenerationMethodCall("set_password", "secret") + class Meta: + model = "accounts.User" + + class Params: + with_totp_device = factory.Trait( + device=factory.RelatedFactory( + TOTPDeviceFactory, + "user", + name="default", + ) + ) + @factory.post_generation def user_permissions(self, create, extracted, **kwargs): if not create: @@ -29,28 +49,6 @@ def user_permissions(self, create, extracted, **kwargs): permission = Permission.objects.get(**filters) self.user_permissions.add(permission) - class Meta: - model = "accounts.User" - - @classmethod - def mock_two_factor_flow(cls, user, app): - # Mock the user having gone through the two factor authentication flow - app.set_cookie(settings.SESSION_COOKIE_NAME, "initial") - session = app.session - session["otp_device_id"] = user.staticdevice_set.create().persistent_id - session.save() - app.set_cookie(settings.SESSION_COOKIE_NAME, session.session_key) - - @classmethod - def create(cls, **kwargs): - app = kwargs.pop("app", None) - user = super().create(**kwargs) - - if app: - cls.mock_two_factor_flow(user, app) - - return user - class StaffUserFactory(UserFactory): is_staff = True diff --git a/src/openforms/accounts/tests/test_hijacking.py b/src/openforms/accounts/tests/test_hijacking.py index 661e62c46b..7c0e9be403 100644 --- a/src/openforms/accounts/tests/test_hijacking.py +++ b/src/openforms/accounts/tests/test_hijacking.py @@ -7,16 +7,14 @@ from django.urls import NoReverseMatch, reverse from django_webtest import WebTest +from maykin_2fa.test import get_valid_totp_token from openforms.logging.models import TimelineLogProxy from .factories import StaffUserFactory, SuperUserFactory -@override_settings( - TWO_FACTOR_PATCH_ADMIN=True, - TWO_FACTOR_FORCE_OTP_ADMIN=True, -) +@override_settings(MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS=[]) # enforce MFA class HijackTests(WebTest): csrf_checks = False @@ -27,10 +25,20 @@ def _hijack_user(self, hijacked): ) return response + def _login_with_mfa(self, user): + login_page = self.app.get(reverse("admin:login")) + login_page.form["auth-username"] = user.username + login_page.form["auth-password"] = "secret" + token_page = login_page.form.submit() + + token_page.form["token-otp_token"] = get_valid_totp_token(user) + token_page.form.submit().follow() + def test_can_hijack_and_release_with_2fa(self): - staff_user = StaffUserFactory.create(app=self.app) - superuser = SuperUserFactory.create(app=self.app) + staff_user = StaffUserFactory.create(with_totp_device=True) + superuser = SuperUserFactory.create(with_totp_device=True) admin_dashboard_url = reverse("admin:index") + self._login_with_mfa(superuser) with self.subTest("superuser admin index page"): admin_dashboard = self.app.get(admin_dashboard_url, user=superuser) @@ -74,8 +82,9 @@ def test_can_hijack_and_release_with_2fa(self): ) def test_auditlog_entries_on_hijack_and_release(self): - staff_user = StaffUserFactory.create(app=self.app) - superuser = SuperUserFactory.create(app=self.app) + staff_user = StaffUserFactory.create(with_totp_device=True) + superuser = SuperUserFactory.create(with_totp_device=True) + self._login_with_mfa(superuser) with self.subTest("hijack user"): self.app.get(reverse("admin:index"), user=superuser) @@ -125,16 +134,13 @@ def test_auditlog_entries_on_hijack_and_release(self): ) -@override_settings( - TWO_FACTOR_PATCH_ADMIN=True, - TWO_FACTOR_FORCE_OTP_ADMIN=True, -) +@override_settings(MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS=[]) # enforce MFA class HijackSecurityTests(TestCase): @tag("security-28", "CVE-2024-24771") def test_cannot_hijack_without_second_factor(self): - staff_user = StaffUserFactory.create() - superuser = SuperUserFactory.create() + staff_user = StaffUserFactory.create(with_totp_device=True) + superuser = SuperUserFactory.create(with_totp_device=True) superuser.totpdevice_set.create() self.client.force_login(superuser) diff --git a/src/openforms/accounts/tests/test_user_preferences.py b/src/openforms/accounts/tests/test_user_preferences.py index 53ae3666c8..2a7a802afe 100644 --- a/src/openforms/accounts/tests/test_user_preferences.py +++ b/src/openforms/accounts/tests/test_user_preferences.py @@ -6,10 +6,12 @@ from django_webtest import WebTest from furl import furl +from maykin_2fa.test import disable_admin_mfa from .factories import StaffUserFactory, SuperUserFactory, UserFactory +@disable_admin_mfa() class AccessControlTests(WebTest): admin_url = reverse_lazy("admin:accounts_userpreferences_change") @@ -63,6 +65,7 @@ def test_fiddling_with_urls(self): self.assertEqual(response.status_code, 403) +@disable_admin_mfa() class UserPreferencesTests(WebTest): admin_url = reverse_lazy("admin:accounts_userpreferences_change") diff --git a/src/openforms/admin/urls.py b/src/openforms/admin/urls.py index fd56587ff2..c86ba8d305 100644 --- a/src/openforms/admin/urls.py +++ b/src/openforms/admin/urls.py @@ -4,10 +4,15 @@ from django.urls import include, path from decorator_include import decorator_include +from maykin_2fa import monkeypatch_admin +from maykin_2fa.urls import urlpatterns, webauthn_urlpatterns from mozilla_django_oidc_db.views import AdminLoginFailure from openforms.emails.admin import EmailTestAdminView +# Configure admin +monkeypatch_admin() + urlpatterns = [ path( "password_reset/", @@ -32,5 +37,8 @@ ), ), path("login/failure/", AdminLoginFailure.as_view(), name="admin-oidc-error"), + # Use custom login views for the admin + support hardware tokens + path("", include((urlpatterns, "maykin_2fa"))), + path("", include((webauthn_urlpatterns, "two_factor"))), path("", admin.site.urls), ] diff --git a/src/openforms/analytics_tools/tests/test_admin.py b/src/openforms/analytics_tools/tests/test_admin.py index bb7bde50ce..391cc78a59 100644 --- a/src/openforms/analytics_tools/tests/test_admin.py +++ b/src/openforms/analytics_tools/tests/test_admin.py @@ -1,10 +1,12 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory +@disable_admin_mfa() class AnalyticsConfigAdminTests(WebTest): def test_urls_cannot_have_trailing_slashes(self): superuser = SuperUserFactory.create() diff --git a/src/openforms/appointments/contrib/jcc/tests/test_admin.py b/src/openforms/appointments/contrib/jcc/tests/test_admin.py index dd2487405d..9521d3866e 100644 --- a/src/openforms/appointments/contrib/jcc/tests/test_admin.py +++ b/src/openforms/appointments/contrib/jcc/tests/test_admin.py @@ -6,6 +6,7 @@ import requests import requests_mock from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.appointments.contrib.jcc.models import JccConfig @@ -17,6 +18,7 @@ from .utils import WSDL +@disable_admin_mfa() @disable_logging() class ApointmentConfigAdminTests(WebTest): def setUp(self): diff --git a/src/openforms/appointments/contrib/qmatic/tests/test_admin.py b/src/openforms/appointments/contrib/qmatic/tests/test_admin.py index dde94c8754..dddd005264 100644 --- a/src/openforms/appointments/contrib/qmatic/tests/test_admin.py +++ b/src/openforms/appointments/contrib/qmatic/tests/test_admin.py @@ -4,6 +4,7 @@ import requests import requests_mock from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.appointments.contrib.qmatic.models import QmaticConfig @@ -15,6 +16,7 @@ from .factories import ServiceFactory +@disable_admin_mfa() class QmaticConfigAdminTests(WebTest): def setUp(self): super().setUp() @@ -35,6 +37,7 @@ def test_customer_fields_are_listed(self): self.assertIn(value, CustomerFields) +@disable_admin_mfa() @disable_logging() class ApointmentConfigAdminTests(WebTest): def setUp(self): diff --git a/src/openforms/appointments/tests/test_admin.py b/src/openforms/appointments/tests/test_admin.py index 8d05cfee11..b383f12c19 100644 --- a/src/openforms/appointments/tests/test_admin.py +++ b/src/openforms/appointments/tests/test_admin.py @@ -7,6 +7,7 @@ from django_webtest import WebTest from freezegun import freeze_time +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import ( StaffUserFactory, @@ -48,6 +49,7 @@ class TestPlugin(DemoAppointment): is_demo_plugin = False +@disable_admin_mfa() class AppointmentInfoAdminTests(WebTest): @freeze_time("2021-11-26T17:00:00+01:00") def test_cancel_and_change_links_only_for_superuser(self): @@ -139,6 +141,7 @@ def test_cancel_and_change_links(self): self.assertEqual(len(app2_links), 0) +@disable_admin_mfa() class AppointmentsConfigAdminTests(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/digid/test_admin.py b/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/digid/test_admin.py index aa1d6ddd8c..863138965a 100644 --- a/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/digid/test_admin.py +++ b/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/digid/test_admin.py @@ -4,6 +4,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from digid_eherkenning_oidc_generics.models import OpenIDConnectPublicConfig from openforms.accounts.tests.factories import SuperUserFactory @@ -23,12 +24,13 @@ ) +@disable_admin_mfa() @override_settings(CORS_ALLOW_ALL_ORIGINS=True, IS_HTTPS=True) class DigiDOIDCFormAdminTests(WebTest): def setUp(self): super().setUp() - self.user = SuperUserFactory.create(app=self.app) + self.user = SuperUserFactory.create() self.app.set_user(self.user) def test_digid_oidc_disable_allowed(self): diff --git a/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/eherkenning/test_admin.py b/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/eherkenning/test_admin.py index 0440a705a5..e5b8ae20da 100644 --- a/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/eherkenning/test_admin.py +++ b/src/openforms/authentication/contrib/digid_eherkenning_oidc/tests/eherkenning/test_admin.py @@ -4,6 +4,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from digid_eherkenning_oidc_generics.models import OpenIDConnectEHerkenningConfig from openforms.accounts.tests.factories import SuperUserFactory @@ -23,12 +24,13 @@ ) +@disable_admin_mfa() @override_settings(CORS_ALLOW_ALL_ORIGINS=True, IS_HTTPS=True) class eHerkenningOIDCFormAdminTests(WebTest): def setUp(self): super().setUp() - self.user = SuperUserFactory.create(app=self.app) + self.user = SuperUserFactory.create() self.app.set_user(self.user) def test_eherkenning_oidc_disable_allowed(self): diff --git a/src/openforms/authentication/tests/test_admin.py b/src/openforms/authentication/tests/test_admin.py index dd6c6add74..55eaa57de4 100644 --- a/src/openforms/authentication/tests/test_admin.py +++ b/src/openforms/authentication/tests/test_admin.py @@ -2,15 +2,15 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import UserFactory -from ...tests.utils import disable_2fa from ..constants import AuthAttribute from .factories import AuthInfoFactory, RegistratorInfoFactory -@disable_2fa +@disable_admin_mfa() class AuthInfoAdminValidationTest(WebTest): @classmethod def setUpTestData(cls): @@ -56,7 +56,7 @@ def test_validate_invalid_kvk(self): ) -@disable_2fa +@disable_admin_mfa() class RegistratorInfoAdminValidationTest(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/conf/base.py b/src/openforms/conf/base.py index a922cbb820..d681267c09 100644 --- a/src/openforms/conf/base.py +++ b/src/openforms/conf/base.py @@ -141,11 +141,13 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.postgres", - # Admin auth + # Two-factor authentication in the Django admin, enforced. "django_otp", "django_otp.plugins.otp_static", "django_otp.plugins.otp_totp", "two_factor", + "two_factor.plugins.webauthn", # USB key/hardware token support + "maykin_2fa", # Optional applications. "ordered_model", "django_admin_index", @@ -269,7 +271,7 @@ "hijack.middleware.HijackUserMiddleware", "openforms.middleware.SessionTimeoutMiddleware", "mozilla_django_oidc_db.middleware.SessionRefresh", - "django_otp.middleware.OTPMiddleware", + "maykin_2fa.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "axes.middleware.AxesMiddleware", @@ -657,10 +659,25 @@ ) # -# Maykin fork of DJANGO-TWO-FACTOR-AUTH +# MAYKIN-2FA # -TWO_FACTOR_FORCE_OTP_ADMIN = config("TWO_FACTOR_FORCE_OTP_ADMIN", default=not DEBUG) -TWO_FACTOR_PATCH_ADMIN = config("TWO_FACTOR_PATCH_ADMIN", default=True) +# Uses django-two-factor-auth under the hood, so relevant upstream package settings +# apply too. +# + +# we run the admin site monkeypatch instead. +TWO_FACTOR_PATCH_ADMIN = False +# Relying Party name for WebAuthn (hardware tokens) +TWO_FACTOR_WEBAUTHN_RP_NAME = "Open Formulieren - admin" +# use platform for fingerprint readers etc., or remove the setting to allow any. +# cross-platform would limit the options to devices like phones/yubikeys +TWO_FACTOR_WEBAUTHN_AUTHENTICATOR_ATTACHMENT = "cross-platform" +# add entries from AUTHENTICATION_BACKENDS that already enforce their own two-factor +# auth, avoiding having some set up MFA again in the project. +MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS = [ + "mozilla_django_oidc_db.backends.OIDCAuthenticationBackend", + "openforms.authentication.contrib.org_oidc.backends.OIDCAuthenticationBackend", +] # # CELERY - async task queue diff --git a/src/openforms/conf/ci.py b/src/openforms/conf/ci.py index cbab595958..3612a3d98b 100644 --- a/src/openforms/conf/ci.py +++ b/src/openforms/conf/ci.py @@ -55,9 +55,6 @@ # Django privates SENDFILE_BACKEND = "django_sendfile.backends.development" -# Two factor auth -TWO_FACTOR_FORCE_OTP_ADMIN = False - # THOU SHALT NOT USE NAIVE DATETIMES warnings.filterwarnings( "error", diff --git a/src/openforms/conf/dev.py b/src/openforms/conf/dev.py index 3d12b5c298..f5b26113df 100644 --- a/src/openforms/conf/dev.py +++ b/src/openforms/conf/dev.py @@ -153,6 +153,9 @@ CSP_EXCLUDE_URL_PREFIXES += ("/dev/",) +# None of the authentication backends require two-factor authentication. +if config("DISABLE_2FA", default=True): # pragma: no cover + MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS = AUTHENTICATION_BACKENDS # THOU SHALT NOT USE NAIVE DATETIMES warnings.filterwarnings( diff --git a/src/openforms/conf/local_example.py b/src/openforms/conf/local_example.py index dd874986f8..b5a3a496da 100644 --- a/src/openforms/conf/local_example.py +++ b/src/openforms/conf/local_example.py @@ -40,7 +40,3 @@ # run celery tasks so submissions get processed in dev server # Ceveat emptor: this breaks test isolation and breaks a few tests in the suite # CELERY_TASK_ALWAYS_EAGER = True - -# don't force tokens in dev server -TWO_FACTOR_PATCH_ADMIN = False -TWO_FACTOR_FORCE_OTP_ADMIN = False diff --git a/src/openforms/config/tests/test_admin.py b/src/openforms/config/tests/test_admin.py index e2a6192610..bed4bf97c6 100644 --- a/src/openforms/config/tests/test_admin.py +++ b/src/openforms/config/tests/test_admin.py @@ -3,6 +3,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.config.models import CSPSetting @@ -32,6 +33,7 @@ def test_content_type_link(self): self.assertEqual(link, expected_link) +@disable_admin_mfa() class ColorAdminTests(WebTest): def test_color_changelist(self): RichTextColorFactory.create_batch(9) diff --git a/src/openforms/config/tests/test_admin_themes.py b/src/openforms/config/tests/test_admin_themes.py index c11cf52aef..eba001eaab 100644 --- a/src/openforms/config/tests/test_admin_themes.py +++ b/src/openforms/config/tests/test_admin_themes.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext as _ from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.forms.tests.factories import FormFactory @@ -9,6 +10,7 @@ from .factories import ThemeFactory +@disable_admin_mfa() class ThemePreviewTests(WebTest): def test_can_preview_theme_via_admin_list(self): user = SuperUserFactory.create() diff --git a/src/openforms/config/tests/test_global_configuration.py b/src/openforms/config/tests/test_global_configuration.py index abd040b32f..accdac8e68 100644 --- a/src/openforms/config/tests/test_global_configuration.py +++ b/src/openforms/config/tests/test_global_configuration.py @@ -11,10 +11,11 @@ import clamd from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from webtest import Form as WebTestForm from openforms.accounts.tests.factories import SuperUserFactory -from openforms.tests.utils import NOOP_CACHES, disable_2fa +from openforms.tests.utils import NOOP_CACHES from ..models import GlobalConfiguration @@ -31,7 +32,7 @@ def _ensure_arrayfields(form: WebTestForm, config: GlobalConfiguration | None = form["recipients_email_digest"] = json.dumps(config.recipients_email_digest) -@disable_2fa +@disable_admin_mfa() @override_settings( CACHES=NOOP_CACHES, MEDIA_ROOT=tempfile.mkdtemp(), diff --git a/src/openforms/config/tests/test_theme.py b/src/openforms/config/tests/test_theme.py index cf70ea41e7..7aa11c1588 100644 --- a/src/openforms/config/tests/test_theme.py +++ b/src/openforms/config/tests/test_theme.py @@ -7,18 +7,18 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from webtest import Upload from openforms.accounts.tests.factories import SuperUserFactory from openforms.forms.tests.factories import FormFactory -from openforms.tests.utils import disable_2fa from .factories import ThemeFactory LOGO_FILE = Path(settings.BASE_DIR) / "docs" / "logo.svg" -@disable_2fa +@disable_admin_mfa() @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) class AdminTests(WebTest): @classmethod diff --git a/src/openforms/emails/tests/test_check_email.py b/src/openforms/emails/tests/test_check_email.py index cadefba655..e196daa2c5 100644 --- a/src/openforms/emails/tests/test_check_email.py +++ b/src/openforms/emails/tests/test_check_email.py @@ -14,10 +14,10 @@ from django_webtest import WebTest from django_yubin import settings as yubin_settings +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import StaffUserFactory, UserFactory from openforms.emails.connection_check import LabelValue, check_email_backend -from openforms.tests.utils import disable_2fa from openforms.utils.tests.webtest_base import WebTestPyQueryMixin @@ -167,7 +167,7 @@ def test_login_socket_error(self): self.assertIsInstance(res.exception, socket.error) -@disable_2fa +@disable_admin_mfa() class CheckEmailSettingsAdminViewTest(WebTestPyQueryMixin, WebTest): def setUp(self): super().setUp() @@ -182,25 +182,25 @@ def test_requires_auth(self): self.assertRedirects(response, redirect_url) with self.subTest("user"): - user = UserFactory(app=self.app) + user = UserFactory() self.app.set_user(user) response = self.app.get(url, status=302) # to login self.assertRedirects(response, redirect_url) with self.subTest("staff"): - user = StaffUserFactory(app=self.app) + user = StaffUserFactory() self.app.set_user(user) response = self.app.get(url, status=403) # no perms with self.subTest("staff with permission"): - user = StaffUserFactory(app=self.app) + user = StaffUserFactory() user.user_permissions.add(self.permission) self.app.set_user(user) response = self.app.get(url, status=200) def test_run_check_pass(self): url = reverse("admin_email_test") - user = StaffUserFactory(app=self.app) + user = StaffUserFactory() user.user_permissions.add(self.permission) self.app.set_user(user) response = self.app.get(url, status=200) @@ -238,7 +238,7 @@ def test_run_check_fail(self): ) url = reverse("admin_email_test") - user = StaffUserFactory(app=self.app) + user = StaffUserFactory() user.user_permissions.add(self.permission) self.app.set_user(user) response = self.app.get(url, status=200) diff --git a/src/openforms/fixtures/default_admin_index.json b/src/openforms/fixtures/default_admin_index.json index 10f753d2af..bf369afe3b 100644 --- a/src/openforms/fixtures/default_admin_index.json +++ b/src/openforms/fixtures/default_admin_index.json @@ -43,8 +43,8 @@ "totpdevice" ], [ - "two_factor", - "phonedevice" + "two_factor_webauthn", + "webauthndevice" ] ] } diff --git a/src/openforms/fixtures/default_groups.json b/src/openforms/fixtures/default_groups.json index 7ed9eb9dde..1a7f8ac41e 100644 --- a/src/openforms/fixtures/default_groups.json +++ b/src/openforms/fixtures/default_groups.json @@ -1183,6 +1183,26 @@ "otp_totp", "totpdevice" ], + [ + "add_webauthndevice", + "two_factor_webauthn", + "webauthndevice" + ], + [ + "change_webauthndevice", + "two_factor_webauthn", + "webauthndevice" + ], + [ + "delete_webauthndevice", + "two_factor_webauthn", + "webauthndevice" + ], + [ + "view_webauthndevice", + "two_factor_webauthn", + "webauthndevice" + ], [ "add_submissionpayment", "payments", @@ -1488,26 +1508,6 @@ "timeline_logger", "timelinelog" ], - [ - "add_phonedevice", - "two_factor", - "phonedevice" - ], - [ - "change_phonedevice", - "two_factor", - "phonedevice" - ], - [ - "delete_phonedevice", - "two_factor", - "phonedevice" - ], - [ - "view_phonedevice", - "two_factor", - "phonedevice" - ], [ "add_zgwconfig", "zgw_apis", diff --git a/src/openforms/forms/tests/admin/test_category.py b/src/openforms/forms/tests/admin/test_category.py index b9b863ed37..ced8a28615 100644 --- a/src/openforms/forms/tests/admin/test_category.py +++ b/src/openforms/forms/tests/admin/test_category.py @@ -1,13 +1,13 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.forms.tests.factories import CategoryFactory, FormFactory -from openforms.tests.utils import disable_2fa -@disable_2fa +@disable_admin_mfa() class TestCategoryAdmin(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/forms/tests/admin/test_form.py b/src/openforms/forms/tests/admin/test_form.py index 8398d060f8..b53cdfc245 100644 --- a/src/openforms/forms/tests/admin/test_form.py +++ b/src/openforms/forms/tests/admin/test_form.py @@ -11,12 +11,12 @@ from django.utils.translation import gettext as _ from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory, UserFactory from openforms.config.models import GlobalConfiguration, RichTextColor from openforms.emails.tests.factories import ConfirmationEmailTemplateFactory from openforms.forms.tests.factories import FormLogicFactory -from openforms.tests.utils import disable_2fa from openforms.utils.admin import SubmitActions from ...admin.form import FormAdmin @@ -65,7 +65,7 @@ def _load_async_category_form_lists(self, response, query=None, **kwargs): return response -@disable_2fa +@disable_admin_mfa() class FormAdminImportExportTests(WebTest): @classmethod def setUpTestData(cls): @@ -501,7 +501,7 @@ def test_importing_form_with_form_step_url_and_uuid(self): self.assertEqual(form.name_nl, "Form 000") -@disable_2fa +@disable_admin_mfa() class FormAdminCopyTests(TestCase): def test_form_admin_copy(self): user = UserFactory.create(is_superuser=True, is_staff=True) @@ -570,12 +570,12 @@ def test_copy_form_with_trigger_from_step_in_logic(self): self.assertEqual(copied_logic.trigger_from_step, copied_step) -@disable_2fa +@disable_admin_mfa() class FormAdminActionsTests(FormListAjaxMixin, WebTest): def setUp(self) -> None: super().setUp() self.form = FormFactory.create(internal_name="foo") - self.user = SuperUserFactory.create(app=self.app) + self.user = SuperUserFactory.create() def test_make_copies_action_makes_copy_of_a_form(self): logic = FormLogicFactory.create( @@ -675,7 +675,7 @@ def test_export_no_email_configured(self): ) -@disable_2fa +@disable_admin_mfa() class FormEditTests(WebTest): """ Test admin behaviour when creating or editing forms via the React UI. @@ -973,7 +973,7 @@ def test_rich_text_colors_configuration(self): self.assertRegex(node["color"], r"^#[0-9a-f]{6}$") -@disable_2fa +@disable_admin_mfa() class FormChangeTests(WebTest): @classmethod def setUpTestData(cls): @@ -996,7 +996,7 @@ def setUp(self): self.addCleanup(patcher.stop) -@disable_2fa +@disable_admin_mfa() class FormDeleteTests(FormListAjaxMixin, WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/forms/tests/admin/test_form_definition.py b/src/openforms/forms/tests/admin/test_form_definition.py index 018c3e6c66..99cfed60dd 100644 --- a/src/openforms/forms/tests/admin/test_form_definition.py +++ b/src/openforms/forms/tests/admin/test_form_definition.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.forms.models import FormDefinition @@ -10,10 +11,9 @@ FormFactory, FormStepFactory, ) -from openforms.tests.utils import disable_2fa -@disable_2fa +@disable_admin_mfa() class TestFormDefinitionAdmin(WebTest): def setUp(self) -> None: super().setUp() @@ -24,7 +24,7 @@ def setUp(self) -> None: kwargs={"object_id": self.form.pk}, ) FormStepFactory.create(form=self.form, form_definition=self.form_definition) - self.user = SuperUserFactory.create(app=self.app) + self.user = SuperUserFactory.create() self.app.set_user(self.user) def test_used_in_forms_shown_in_list_response(self): diff --git a/src/openforms/forms/tests/admin/test_form_version.py b/src/openforms/forms/tests/admin/test_form_version.py index 91a52ee6eb..c36668a704 100644 --- a/src/openforms/forms/tests/admin/test_form_version.py +++ b/src/openforms/forms/tests/admin/test_form_version.py @@ -1,12 +1,12 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory -from openforms.tests.utils import disable_2fa -@disable_2fa +@disable_admin_mfa() class FormVersionAdminImportExportTests(WebTest): def setUp(self): self.user = SuperUserFactory.create() diff --git a/src/openforms/forms/tests/admin/test_variables.py b/src/openforms/forms/tests/admin/test_variables.py index c5b0815598..eeac9c04c9 100644 --- a/src/openforms/forms/tests/admin/test_variables.py +++ b/src/openforms/forms/tests/admin/test_variables.py @@ -1,11 +1,13 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import UserFactory from openforms.forms.tests.factories import FormVariableFactory +@disable_admin_mfa() class FormVariableAdminTest(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/forms/tests/admin/test_views.py b/src/openforms/forms/tests/admin/test_views.py index 74f93a5fe8..aafd4ea8f4 100644 --- a/src/openforms/forms/tests/admin/test_views.py +++ b/src/openforms/forms/tests/admin/test_views.py @@ -8,6 +8,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from privates.test import temp_private_root from openforms.accounts.tests.factories import ( @@ -21,6 +22,7 @@ from openforms.utils.urls import build_absolute_uri +@disable_admin_mfa() @override_settings(LANGUAGE_CODE="en") class TestExportFormsView(WebTest): def test_not_staff_cant_access(self): @@ -98,6 +100,7 @@ def test_success_message(self, m): self.assertEqual(messages[0].tags, "success") +@disable_admin_mfa() @temp_private_root() class TestDownloadExportFormView(TestCase): def test_not_logged_in_cant_access(self): @@ -184,6 +187,7 @@ def test_wrong_user_cant_download(self): self.assertEqual(404, response.status_code) +@disable_admin_mfa() @temp_private_root() @override_settings(LANGUAGE_CODE="en") class TestImportView(WebTest): diff --git a/src/openforms/forms/tests/test_form_admin.py b/src/openforms/forms/tests/test_form_admin.py index 0ae9a08629..393efc7b4f 100644 --- a/src/openforms/forms/tests/test_form_admin.py +++ b/src/openforms/forms/tests/test_form_admin.py @@ -2,11 +2,11 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from rest_framework.serializers import Serializer from openforms.accounts.tests.factories import SuperUserFactory from openforms.registrations.registry import Registry -from openforms.tests.utils import disable_2fa from ...registrations.base import BasePlugin from ..models import Form @@ -32,7 +32,7 @@ def get_reference_from_result(self, result) -> str: return "foo" -@disable_2fa +@disable_admin_mfa() class FormAdminTests(FormListAjaxMixin, WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/forms/tests/test_formdefinition_admin.py b/src/openforms/forms/tests/test_formdefinition_admin.py index 7527f06949..3b69396df4 100644 --- a/src/openforms/forms/tests/test_formdefinition_admin.py +++ b/src/openforms/forms/tests/test_formdefinition_admin.py @@ -2,15 +2,15 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import StaffUserFactory, SuperUserFactory -from openforms.tests.utils import disable_2fa from ..models.form_definition import FormDefinition from .factories import FormDefinitionFactory -@disable_2fa +@disable_admin_mfa() class FormDefinitionAdminTests(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/logging/tests/test_admin.py b/src/openforms/logging/tests/test_admin.py index b174b98a75..822aaa6108 100644 --- a/src/openforms/logging/tests/test_admin.py +++ b/src/openforms/logging/tests/test_admin.py @@ -4,6 +4,7 @@ from django.utils import timezone from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import StaffUserFactory, SuperUserFactory from openforms.logging import logevent @@ -13,6 +14,7 @@ from openforms.submissions.tests.factories import SubmissionFactory +@disable_admin_mfa() class AVGAuditLogListViewTests(WebTest): def test_view(self): url = reverse("admin:logging_avgtimelinelogproxy_changelist") diff --git a/src/openforms/multidomain/tests/test_admin.py b/src/openforms/multidomain/tests/test_admin.py index b5da4866d5..b68b5b44fd 100644 --- a/src/openforms/multidomain/tests/test_admin.py +++ b/src/openforms/multidomain/tests/test_admin.py @@ -1,12 +1,14 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from .factories import DomainFactory +@disable_admin_mfa() class MultiDomainAdminTests(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/payments/contrib/ogone/tests/test_admin.py b/src/openforms/payments/contrib/ogone/tests/test_admin.py index 494c3b484a..32dae4e4cd 100644 --- a/src/openforms/payments/contrib/ogone/tests/test_admin.py +++ b/src/openforms/payments/contrib/ogone/tests/test_admin.py @@ -3,10 +3,12 @@ from django_webtest import WebTest from furl import furl +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory +@disable_admin_mfa() class OgoneMerchantAdminTest(WebTest): @override_settings(BASE_URL="https://example.com/foo") def test_add_ogone_merchant(self): diff --git a/src/openforms/prefill/tests/test_admin.py b/src/openforms/prefill/tests/test_admin.py index d8eda64851..9d0b2f5634 100644 --- a/src/openforms/prefill/tests/test_admin.py +++ b/src/openforms/prefill/tests/test_admin.py @@ -3,6 +3,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory from openforms.config.models import GlobalConfiguration @@ -16,6 +17,7 @@ def test_repr(self): self.assertEqual(str(instance), PrefillConfig._meta.verbose_name) + @disable_admin_mfa() @patch( "openforms.plugins.registry.GlobalConfiguration.get_solo", return_value=GlobalConfiguration( diff --git a/src/openforms/registrations/contrib/zgw_apis/tests/test_admin.py b/src/openforms/registrations/contrib/zgw_apis/tests/test_admin.py index 65c9a355ef..1bb0038486 100644 --- a/src/openforms/registrations/contrib/zgw_apis/tests/test_admin.py +++ b/src/openforms/registrations/contrib/zgw_apis/tests/test_admin.py @@ -4,15 +4,15 @@ import requests import requests_mock from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from zgw_consumers.api_models.constants import VertrouwelijkheidsAanduidingen from openforms.accounts.tests.factories import SuperUserFactory -from openforms.tests.utils import disable_2fa from .factories import ZGWApiGroupConfigFactory -@disable_2fa +@disable_admin_mfa() class ZGWApiGroupConfigAdminTests(WebTest): @requests_mock.Mocker() def test_admin_while_services_are_down(self, m): diff --git a/src/openforms/submissions/tests/test_admin.py b/src/openforms/submissions/tests/test_admin.py index f7cc6d9097..c6239978e2 100644 --- a/src/openforms/submissions/tests/test_admin.py +++ b/src/openforms/submissions/tests/test_admin.py @@ -4,6 +4,7 @@ from django_webtest import WebTest from furl import furl +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import UserFactory from openforms.forms.models import FormVariable @@ -15,6 +16,7 @@ from .factories import SubmissionFactory, SubmissionValueVariableFactory +@disable_admin_mfa() class TestSubmissionAdmin(WebTest): @classmethod def setUpTestData(cls): @@ -38,7 +40,7 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.user = UserFactory.create(is_superuser=True, is_staff=True, app=self.app) + self.user = UserFactory.create(is_superuser=True, is_staff=True) def test_displaying_merged_data_formio_formatters(self): response = self.app.get( diff --git a/src/openforms/submissions/tests/test_admin_export.py b/src/openforms/submissions/tests/test_admin_export.py index 7afd88ba46..c9182010e7 100644 --- a/src/openforms/submissions/tests/test_admin_export.py +++ b/src/openforms/submissions/tests/test_admin_export.py @@ -4,6 +4,7 @@ import tablib from django_webtest import WebTest from lxml import etree +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import UserFactory from openforms.forms.tests.factories import FormDefinitionFactory, FormStepFactory @@ -15,6 +16,7 @@ ) +@disable_admin_mfa() class TestSubmissionExportAdmin(WebTest): @classmethod def setUpTestData(cls): @@ -72,7 +74,7 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.user = UserFactory.create(is_superuser=True, is_staff=True, app=self.app) + self.user = UserFactory.create(is_superuser=True, is_staff=True) def test_export_csv_successfully_exports_csv_file(self): response = self.app.get( diff --git a/src/openforms/submissions/tests/test_admin_file_uploads.py b/src/openforms/submissions/tests/test_admin_file_uploads.py index 20acaa0375..3454e1d1de 100644 --- a/src/openforms/submissions/tests/test_admin_file_uploads.py +++ b/src/openforms/submissions/tests/test_admin_file_uploads.py @@ -2,6 +2,7 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from privates.test import temp_private_root from openforms.accounts.tests.factories import SuperUserFactory @@ -9,6 +10,7 @@ from .factories import SubmissionFileAttachmentFactory, TemporaryFileUploadFactory +@disable_admin_mfa() @temp_private_root() class TemporaryFileUploadsAdmin(WebTest): @tag("CVE-2022-36359") @@ -56,6 +58,7 @@ def test_filenames_properly_escaped(self): ) +@disable_admin_mfa() @temp_private_root() class SubmissionAttachmensAdmin(WebTest): @tag("CVE-2022-36359") diff --git a/src/openforms/submissions/tests/test_submission_attachment.py b/src/openforms/submissions/tests/test_submission_attachment.py index 0d04d248eb..55fd23b52e 100644 --- a/src/openforms/submissions/tests/test_submission_attachment.py +++ b/src/openforms/submissions/tests/test_submission_attachment.py @@ -5,6 +5,7 @@ from django.test import TestCase, override_settings, tag from django.urls import reverse +from maykin_2fa.test import disable_admin_mfa from PIL import Image, UnidentifiedImageError from privates.test import temp_private_root from rest_framework.exceptions import ValidationError @@ -13,7 +14,6 @@ from openforms.api.exceptions import RequestEntityTooLarge from openforms.config.models import GlobalConfiguration from openforms.forms.tests.factories import FormStepFactory -from openforms.tests.utils import disable_2fa from ..attachments import ( append_file_num_postfix, @@ -1399,7 +1399,7 @@ def test_attach_upload_validates_file_content_types_default_configuration( validation_error = err_context.exception.get_full_details() self.assertEqual(len(validation_error["my_file"]), 1) - @disable_2fa + @disable_admin_mfa() def test_attachment_retrieve_view_requires_permission(self): attachment = SubmissionFileAttachmentFactory.create() url = reverse( diff --git a/src/openforms/submissions/tests/test_temporary_uploads.py b/src/openforms/submissions/tests/test_temporary_uploads.py index 10aed1e868..ca1a25fbca 100644 --- a/src/openforms/submissions/tests/test_temporary_uploads.py +++ b/src/openforms/submissions/tests/test_temporary_uploads.py @@ -5,6 +5,7 @@ from django.test import RequestFactory, tag from freezegun import freeze_time +from maykin_2fa.test import disable_admin_mfa from privates.test import temp_private_root from rest_framework import status from rest_framework.reverse import reverse @@ -31,7 +32,6 @@ remove_from_session_list, remove_upload_from_session, ) -from openforms.tests.utils import disable_2fa @temp_private_root() @@ -229,7 +229,7 @@ def test_cleanup_unclaimed_temporary_uploaded_files(self): # expect the unclaimed & older uploads to be deleted self.assertEqual(actual, [keep_1, keep_2, keep_3]) - @disable_2fa + @disable_admin_mfa() def test_upload_retrieve_requires_permission(self): upload = TemporaryFileUploadFactory.create() url = reverse( diff --git a/src/openforms/templates/admin/base_site.html b/src/openforms/templates/admin/base_site.html index 6e036cc9ba..cc731ef70d 100644 --- a/src/openforms/templates/admin/base_site.html +++ b/src/openforms/templates/admin/base_site.html @@ -38,9 +38,9 @@

{{ settings.PROJECT_NAME }} {% trans 'Change password' %} / {% endif %} - {% url 'admin:two_factor:profile' as 2fa_profile_url %} - {% if 2fa_profile_url %} - {% trans "Manage two-factor auth" %} / + {% url 'maykin_2fa:account_security' as 2fa_account_security_url %} + {% if 2fa_account_security_url %} + {% trans "Account security" %} / {% endif %} {% trans 'Log out' %} diff --git a/src/openforms/templates/admin/login.html b/src/openforms/templates/admin/login.html deleted file mode 100644 index d408af0468..0000000000 --- a/src/openforms/templates/admin/login.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "two_factor/admin/login.html" %} -{% load solo_tags i18n %} - - -{% block content %} -{{ block.super }} - -{% get_solo 'mozilla_django_oidc_db.OpenIDConnectConfig' as oidc_config %} -{% if oidc_config.enabled %} - - -{% endif %} - -{% endblock %} diff --git a/src/openforms/templates/maykin_2fa/base.html b/src/openforms/templates/maykin_2fa/base.html new file mode 100644 index 0000000000..68fa4301d1 --- /dev/null +++ b/src/openforms/templates/maykin_2fa/base.html @@ -0,0 +1,9 @@ +{% extends "maykin_2fa/base.html" %} + +{# Django 3.2 #} +{% block breadcrumbs %}{% endblock %} + +{# Do not show any version information #} +{% block footer %} + +{% endblock %} diff --git a/src/openforms/templates/maykin_2fa/login.html b/src/openforms/templates/maykin_2fa/login.html new file mode 100644 index 0000000000..51987a801b --- /dev/null +++ b/src/openforms/templates/maykin_2fa/login.html @@ -0,0 +1,23 @@ +{% extends "maykin_2fa/login.html" %} +{% load solo_tags i18n %} + +{% block extra_login_options %} + {% get_solo 'mozilla_django_oidc_db.OpenIDConnectConfig' as oidc_config %} + {% if oidc_config.enabled %} + + + {% endif %} +{% endblock %} + +{% block extra_recovery_options %} +
  • + {% trans 'Contact support to start the account recovery process' %} +
  • +{% endblock extra_recovery_options %} + +{# Do not show any version information #} +{% block footer %} + +{% endblock %} diff --git a/src/openforms/templates/two_factor/admin/login.html b/src/openforms/templates/two_factor/admin/login.html deleted file mode 100644 index afdff9b303..0000000000 --- a/src/openforms/templates/two_factor/admin/login.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "admin/login.html" %} diff --git a/src/openforms/tests/e2e/base.py b/src/openforms/tests/e2e/base.py index 34c44c613c..b0fe176f58 100644 --- a/src/openforms/tests/e2e/base.py +++ b/src/openforms/tests/e2e/base.py @@ -8,6 +8,7 @@ from asgiref.sync import sync_to_async from furl import furl +from maykin_2fa.test import disable_admin_mfa from playwright.async_api import BrowserType, Page, async_playwright from openforms.accounts.tests.factories import SuperUserFactory @@ -49,17 +50,8 @@ async def browser_page(): await browser.close() -# The @disable_2fa decorator doesn't seem to work with these tests, so you msut specify -# the envvar TWO_FACTOR_PATCH_ADMIN=no for the end-to-end tests to work as part of your -# test command. -# -# Presumably this is because Django's doing some sync_to_async/async_to_sync magic and -# the process memory/state gets copied with the monkepatched admin... If that's the -# case, it's yet another reason why this monkeypatching approach in -# maykin-django-two-factor is... questionable. - - @tag("e2e") +@disable_admin_mfa() @override_settings(ALLOWED_HOSTS=["*"]) class E2ETestCase(StaticLiveServerTestCase): async def _admin_login(self, page: Page) -> None: diff --git a/src/openforms/tests/test_session_expiry.py b/src/openforms/tests/test_session_expiry.py index dc67519197..b70a1a5b69 100644 --- a/src/openforms/tests/test_session_expiry.py +++ b/src/openforms/tests/test_session_expiry.py @@ -17,6 +17,7 @@ from django.utils.translation import gettext as _ from freezegun import freeze_time +from maykin_2fa.test import disable_admin_mfa from rest_framework import permissions, status from rest_framework.response import Response from rest_framework.reverse import reverse @@ -27,7 +28,7 @@ from openforms.forms.tests.factories import FormFactory, FormStepFactory from ..accounts.tests.factories import SuperUserFactory -from .utils import NOOP_CACHES, disable_2fa +from .utils import NOOP_CACHES SESSION_CACHES = deepcopy(NOOP_CACHES) SESSION_CACHES["session"] = {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} @@ -189,7 +190,7 @@ def test_session_expiry_header_included(self): ) # 5 minutes * 60s = 300s -@disable_2fa +@disable_admin_mfa() @override_settings( CACHES=SESSION_CACHES, SESSION_CACHE_ALIAS="session", diff --git a/src/openforms/tests/utils.py b/src/openforms/tests/utils.py index bdd990ef91..814dd309b9 100644 --- a/src/openforms/tests/utils.py +++ b/src/openforms/tests/utils.py @@ -9,7 +9,6 @@ from pstats import SortKey from django.conf import settings -from django.test import override_settings NOOP_CACHES = { name: {"BACKEND": "django.core.cache.backends.dummy.DummyCache"} @@ -17,9 +16,6 @@ } -disable_2fa = override_settings(TWO_FACTOR_PATCH_ADMIN=False) - - def can_connect(hostname: str): # adapted from https://stackoverflow.com/a/28752285 hostname, port = hostname.split(":") diff --git a/src/openforms/translations/tests/test_admin_language_decoupling.py b/src/openforms/translations/tests/test_admin_language_decoupling.py index cca915efe0..0714d57db3 100644 --- a/src/openforms/translations/tests/test_admin_language_decoupling.py +++ b/src/openforms/translations/tests/test_admin_language_decoupling.py @@ -1,12 +1,12 @@ from django.urls import reverse from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa from openforms.accounts.tests.factories import SuperUserFactory -from openforms.tests.utils import disable_2fa -@disable_2fa +@disable_admin_mfa() class AdminLanguageTests(WebTest): @classmethod def setUpTestData(cls): diff --git a/src/openforms/utils/django_two_factor_auth.py b/src/openforms/utils/django_two_factor_auth.py index 1e3dc9688b..fa47be8fce 100644 --- a/src/openforms/utils/django_two_factor_auth.py +++ b/src/openforms/utils/django_two_factor_auth.py @@ -1,5 +1,3 @@ -from django.conf import settings - from django_admin_index.utils import ( should_display_dropdown_menu as default_should_display_dropdown_menu, ) @@ -7,13 +5,5 @@ def should_display_dropdown_menu(request) -> bool: default = default_should_display_dropdown_menu(request) - - two_factor_enabled = settings.TWO_FACTOR_PATCH_ADMIN - if not two_factor_enabled: - return default - - # never display the dropdown in two-factor admin views - if request.resolver_match.view_name.startswith("admin:two_factor:"): - return False - + # do not display the dropdown until the user is verified. return default and request.user.is_verified()