From 51d9e65115b36a20d759aa13006bf87839c27e11 Mon Sep 17 00:00:00 2001 From: Paul J Stevens Date: Thu, 5 Feb 2026 11:26:52 +0100 Subject: [PATCH 1/2] bump required version of django-otp and update middleware fixes: #261 --- CHANGES | 5 +++++ setup.py | 2 +- src/wagtail_2fa/middleware.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index e2ef646..7dc216c 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,8 @@ +1.8.0 (2026-02-05) +================== + - bump required version of django-otp and update middleware + + 1.7.1 (2025-07-24) ================= - Update python release workflow to use Python 3.11 diff --git a/setup.py b/setup.py index 2568b21..5958983 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ install_requires = [ "Django>=3.2", "Wagtail>=4.1", - "django-otp>=0.8.1", + "django-otp>=1.7.0", "six>=1.14.0", "qrcode>=6.1", ] diff --git a/src/wagtail_2fa/middleware.py b/src/wagtail_2fa/middleware.py index f2c8890..9773a78 100644 --- a/src/wagtail_2fa/middleware.py +++ b/src/wagtail_2fa/middleware.py @@ -36,7 +36,7 @@ def __call__(self, request): def process_request(self, request): if request.user: request.user = SimpleLazyObject( - partial(self._verify_user, request, request.user) + partial(self._verify_user_sync, request, request.user) ) user = request.user if self._require_verified_user(request): From 659c9ea10ba329b1263530f45bff7aa8e0fadd0c Mon Sep 17 00:00:00 2001 From: Paul J Stevens Date: Mon, 2 Mar 2026 16:21:25 +0100 Subject: [PATCH 2/2] update test matrix test with python 3.13 and 3.14, django-5.2, wagtail-7.0 --- .github/workflows/python-tox.yml | 2 +- sandbox/sandbox/apps/user/admin.py | 40 ++++---- sandbox/sandbox/settings.py | 143 +++++++++++++---------------- setup.py | 20 ++-- tests/conftest.py | 18 ++-- tests/test_hooks.py | 7 +- tests/test_middleware.py | 42 ++++++--- tests/test_mixins.py | 9 +- tox.ini | 25 ++--- 9 files changed, 152 insertions(+), 154 deletions(-) diff --git a/.github/workflows/python-tox.yml b/.github/workflows/python-tox.yml index 3406317..9398cda 100644 --- a/.github/workflows/python-tox.yml +++ b/.github/workflows/python-tox.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/sandbox/sandbox/apps/user/admin.py b/sandbox/sandbox/apps/user/admin.py index 02378cd..9834a73 100644 --- a/sandbox/sandbox/apps/user/admin.py +++ b/sandbox/sandbox/apps/user/admin.py @@ -12,31 +12,29 @@ class UserAdmin(BaseUserAdmin): # The fields to be used in displaying the User model. # These override the definitions on the base UserAdmin # that reference specific fields on auth.User. - list_display = ['email'] - list_filter = ['is_superuser'] + list_display = ["email"] + list_filter = ["is_superuser"] fieldsets = ( - (None, { - 'fields': ['email', 'password'] - }), - ('Personal info', { - 'fields': ['first_name', 'last_name'] - }), - ('Permissions', { - 'fields': [ - 'is_active', 'is_staff', 'is_superuser', - 'groups', 'user_permissions' - ] - }), + (None, {"fields": ["email", "password"]}), + ("Personal info", {"fields": ["first_name", "last_name"]}), + ( + "Permissions", + { + "fields": [ + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ] + }, + ), ) - # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin # overrides get_fieldsets to use this attribute when creating a user. add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ['email', 'password1', 'password2'] - }), + (None, {"classes": ("wide",), "fields": ["email", "password1", "password2"]}), ) - search_fields = ['first_name', 'last_name', 'email'] - ordering = ['email'] + search_fields = ["first_name", "last_name", "email"] + ordering = ["email"] filter_horizontal = [] diff --git a/sandbox/sandbox/settings.py b/sandbox/sandbox/settings.py index 158a57b..5ce9f87 100644 --- a/sandbox/sandbox/settings.py +++ b/sandbox/sandbox/settings.py @@ -21,10 +21,10 @@ DEBUG = True # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'VnFOCFWNnyeYRsyjerSCvxOiKf0fvH57ajVBZIG8ie4xlSTLAzRkj7aYYEqJAKpR' +SECRET_KEY = "VnFOCFWNnyeYRsyjerSCvxOiKf0fvH57ajVBZIG8ie4xlSTLAzRkj7aYYEqJAKpR" -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ @@ -33,90 +33,79 @@ # Application definition INSTALLED_APPS = [ - - 'wagtail.contrib.forms', - 'wagtail.contrib.redirects', - 'wagtail.embeds', - 'wagtail.sites', - 'wagtail.users', - 'wagtail.snippets', - 'wagtail.documents', - 'wagtail.images', - 'wagtail.search', - 'wagtail.admin', - 'wagtail', - # 'wagtail_modeladmin', # if Wagtail >=5.1; Don't repeat if it's there already - 'wagtail.contrib.modeladmin', # if Wagtail <5.1; Don't repeat if it's there already - 'wagtail.contrib.styleguide', - - 'modelcluster', - 'taggit', - 'debug_toolbar', - - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'wagtail_2fa', - 'django_otp', - 'django_otp.plugins.otp_totp', - - 'sandbox.apps.home', - 'sandbox.apps.user', - + "wagtail.contrib.forms", + "wagtail.contrib.redirects", + "wagtail.embeds", + "wagtail.sites", + "wagtail.users", + "wagtail.snippets", + "wagtail.documents", + "wagtail.images", + "wagtail.search", + "wagtail.admin", + "wagtail", + "wagtail.contrib.styleguide", + "modelcluster", + "taggit", + "debug_toolbar", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "wagtail_2fa", + "django_otp", + "django_otp.plugins.otp_totp", + "sandbox.apps.home", + "sandbox.apps.user", ] MIDDLEWARE = [ - 'debug_toolbar.middleware.DebugToolbarMiddleware', - - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - - 'wagtail_2fa.middleware.VerifyUserPermissionsMiddleware', - - 'wagtail.contrib.redirects.middleware.RedirectMiddleware', + "debug_toolbar.middleware.DebugToolbarMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "wagtail_2fa.middleware.VerifyUserPermissionsMiddleware", + "wagtail.contrib.redirects.middleware.RedirectMiddleware", ] -ROOT_URLCONF = 'sandbox.urls' +ROOT_URLCONF = "sandbox.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(PROJECT_DIR, 'templates'), + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.join(PROJECT_DIR, "templates"), ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'sandbox.wsgi.application' +WSGI_APPLICATION = "sandbox.wsgi.application" -AUTH_USER_MODEL = 'user.User' +AUTH_USER_MODEL = "user.User" # Database # https://docs.djangoproject.com/en/1.11/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "db.sqlite3", } } @@ -124,9 +113,9 @@ # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -139,19 +128,19 @@ # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] STATICFILES_DIRS = [ - os.path.join(PROJECT_DIR, 'static'), + os.path.join(PROJECT_DIR, "static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, 'static') -STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_URL = "/static/" -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') -MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +MEDIA_URL = "/media/" # Wagtail settings @@ -161,11 +150,11 @@ # Base URL to use when referring to full URLs within the Wagtail admin backend - # e.g. in notification emails. Don't include '/admin' or a trailing slash -WAGTAILADMIN_BASE_URL = 'http://example.com' +WAGTAILADMIN_BASE_URL = "http://example.com" -INTERNAL_IPS = ['127.0.0.1'] +INTERNAL_IPS = ["127.0.0.1"] WAGTAIL_2FA_REQUIRED = True -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/setup.py b/setup.py index 5958983..3d900b7 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ from setuptools import find_packages, setup install_requires = [ - "Django>=3.2", - "Wagtail>=4.1", + "Django>=5.2", + "Wagtail>=7.0", "django-otp>=1.7.0", "six>=1.14.0", "qrcode>=6.1", @@ -17,14 +17,14 @@ tests_require = [ "coverage==5.5", - "pytest==7.2.2", - "pytest-cov==2.12.1", - "pytest-django==4.4.0", + "pytest>=7.2.2", + "pytest-cov>=2.12.1", + "pytest-django>=4.4.0", # Linting - "flake8==3.9.2", # 3.7.9 - "isort==5.12.0", - "flake8-blind-except==0.2.0", - "flake8-debugger==4.0.0", + "flake8>=3.9.2", # 3.7.9 + "isort>=5.12.0", + "flake8-blind-except>=0.2.0", + "flake8-debugger>=4.0.0", ] with open("README.rst") as fh: @@ -46,7 +46,7 @@ "docs": docs_require, "test": tests_require, }, - python_requires=">=3.8", + python_requires=">=3.10", use_scm_version=True, entry_points={}, package_dir={"": "src"}, diff --git a/tests/conftest.py b/tests/conftest.py index c902c29..ea99830 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from unittest import mock from django.conf import settings @@ -29,8 +30,6 @@ def pytest_configure(): "wagtail.search", "wagtail.admin", "wagtail", - # "wagtail_modeladmin", # if Wagtail >=5.1; Don't repeat if it's there already - "wagtail.contrib.modeladmin", # if Wagtail <5.1; Don't repeat if it's there already "modelcluster", "taggit", "django.contrib.admin", @@ -99,18 +98,25 @@ def user(django_user_model): @pytest.fixture -def verified_user(django_user_model, rf): +def otpmiddleware(): + from django_otp.middleware import OTPMiddleware + + get_response = mock.MagicMock() + return OTPMiddleware(get_response) + + +@pytest.fixture +def verified_user(django_user_model, rf, otpmiddleware): """Create a user and verify it using the OTP middleware. Add a device to complete the verification for the user.""" - from django_otp.middleware import OTPMiddleware as _OTPMiddleware from django_otp.plugins.otp_totp.models import TOTPDevice user = django_user_model.objects.create(username="verified-user") device = TOTPDevice.objects.create(user=user, confirmed=True) request = rf.get("/foo/") request.user = user - middleware = _OTPMiddleware() - user = middleware._verify_user(request, user) + middleware = otpmiddleware + user = middleware._verify_user_sync(request, user) user.otp_device = device return user diff --git a/tests/test_hooks.py b/tests/test_hooks.py index e8fa265..3f8a654 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,18 +1,17 @@ from django.test import override_settings from django_otp import user_has_device -from django_otp.middleware import OTPMiddleware as _OTPMiddleware from wagtail.admin.menu import MenuItem from wagtail_2fa.wagtail_hooks import remove_menu_if_unverified class TestHooks: - def test_remove_menu_if_unverified(self, user, rf): + def test_remove_menu_if_unverified(self, user, rf, otpmiddleware): with override_settings(WAGTAIL_2FA_REQUIRED=True): request = rf.get("/cms/") request.user = user - middleware = _OTPMiddleware() - user = middleware._verify_user(request, user) + middleware = otpmiddleware + user = middleware._verify_user_sync(request, user) assert not user_has_device(user) assert user.is_authenticated diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 2d1faf5..96d89bb 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,3 +1,4 @@ +from unittest import mock import pytest from django.contrib.auth.models import Permission from django.test import override_settings @@ -5,8 +6,7 @@ from django_otp import login as otp_login from django_otp.plugins.otp_totp.models import TOTPDevice -from wagtail_2fa.middleware import ( - VerifyUserMiddleware, VerifyUserPermissionsMiddleware) +from wagtail_2fa.middleware import VerifyUserMiddleware, VerifyUserPermissionsMiddleware def test_verified_request(rf, superuser): @@ -15,7 +15,8 @@ def test_verified_request(rf, superuser): device = TOTPDevice.objects.create(user=superuser, confirmed=True) otp_login(request, device) - middleware = VerifyUserMiddleware() + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) response = middleware.process_request(request) assert response is None @@ -25,7 +26,8 @@ def test_superuser_force_mfa_auth(rf, superuser): request.user = superuser TOTPDevice.objects.create(user=superuser, confirmed=True) - middleware = VerifyUserMiddleware(lambda x: x) + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): response = middleware(request) assert response.url == "%s?next=/admin/" % reverse("wagtail_2fa_auth") @@ -34,7 +36,8 @@ def test_superuser_force_mfa_auth(rf, superuser): def test_superuser_require_register_device(rf, superuser): request = rf.get("/admin/") request.user = superuser - middleware = VerifyUserMiddleware(lambda x: x) + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): response = middleware(request) assert response.url == "%s?next=/admin/" % reverse("wagtail_2fa_device_new") @@ -46,7 +49,8 @@ def test_superuser_dont_require_register_device(rf, superuser, settings): request = rf.get("/admin/") request.user = superuser - middleware = VerifyUserMiddleware(lambda x: x) + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) response = middleware.process_request(request) assert response is None @@ -62,7 +66,8 @@ def test_adding_new_device_requires_verification_when_user_has_device( request = rf.get(url_new_device) request.user = superuser - middleware = VerifyUserMiddleware(lambda x: x) + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): response = middleware(request) @@ -93,7 +98,8 @@ def test_always_require_verification_when_user_has_device(rf, user, settings): request = rf.get("/admin/") request.user = user - middleware = VerifyUserMiddleware(lambda x: x) + get_response = mock.MagicMock() + middleware = VerifyUserMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): response = middleware(request) @@ -108,7 +114,9 @@ def test_enable_2fa_permission_does_require_verification(self, rf, staff_user): request = rf.get("/admin/") request.user = user_no_2fa - middleware = VerifyUserPermissionsMiddleware(lambda x: x) + + get_response = mock.MagicMock() + middleware = VerifyUserPermissionsMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): result = middleware._require_verified_user(request) @@ -122,7 +130,9 @@ def test_no_enable_2fa_permission_no_device_does_not_require_verification( request = rf.get("/admin/") request.user = user_2fa - middleware = VerifyUserPermissionsMiddleware(lambda x: x) + + get_response = mock.MagicMock() + middleware = VerifyUserPermissionsMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): result = middleware._require_verified_user(request) @@ -137,7 +147,9 @@ def test_no_enable_2fa_permission_with_device_does_require_verification( request = rf.get("/admin/") request.user = user_2fa - middleware = VerifyUserPermissionsMiddleware(lambda x: x) + + get_response = mock.MagicMock() + middleware = VerifyUserPermissionsMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): result = middleware._require_verified_user(request) @@ -153,7 +165,9 @@ def test_process_request_enable_2fa_permission_sets_attribute_on_user_to_true( request = rf.get("/admin/") request.user = user_no_2fa - middleware = VerifyUserPermissionsMiddleware(lambda x: x) + + get_response = mock.MagicMock() + middleware = VerifyUserPermissionsMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): middleware.process_request(request) @@ -167,7 +181,9 @@ def test_process_no_request_enable_2fa_permission_sets_attribute_on_user_to_fals request = rf.get("/admin/") request.user = user_2fa - middleware = VerifyUserPermissionsMiddleware(lambda x: x) + + get_response = mock.MagicMock() + middleware = VerifyUserPermissionsMiddleware(get_response) with override_settings(WAGTAIL_2FA_REQUIRED=True): middleware.process_request(request) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index db4ee9a..444068c 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -3,7 +3,6 @@ from django.test import override_settings from django.urls import reverse from django_otp import user_has_device -from django_otp.middleware import OTPMiddleware as _OTPMiddleware from django_otp.plugins.otp_totp.models import TOTPDevice from wagtail_2fa.mixins import OtpRequiredMixin @@ -50,12 +49,14 @@ def test_user_allowed_with_verified_user_returns_true(self, rf, verified_user): result = mixin.user_allowed(user) assert result is True - def test_user_allowed_when_no_device_and_if_configured_returns_true(self, rf, user): + def test_user_allowed_when_no_device_and_if_configured_returns_true( + self, rf, user, otpmiddleware + ): with override_settings(WAGTAIL_2FA_REQUIRED=True): request = rf.get("/admin/") request.user = user - middleware = _OTPMiddleware() - user = middleware._verify_user(request, user) + middleware = otpmiddleware + user = middleware._verify_user_sync(request, user) assert not user_has_device(user) assert user.is_authenticated diff --git a/tox.ini b/tox.ini index 2c5cfb8..53f6768 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,22 @@ [tox] envlist = - python{3.8,3.9,3.10}-django{3.2,4.1}-wagtail{4.1,4.2,5.0,5.1} - python3.11-django4.1-wagtail{4.1,4.2,5.0,5.1} - python3.11-django4.2-wagtail{5.0,5.1} + python{3.13,3.14}-django{5.2}-wagtail{7.0} [gh-actions] python = - 3.8: python3.8 - 3.9: python3.9 - 3.10: python3.10 - 3.11: python3.11 + 3.13: python3.13 + 3.14: python3.14 [testenv] commands = coverage run --parallel -m pytest {posargs} -vvv basepython = - python3.8: python3.8 - python3.9: python3.9 - python3.10: python3.10 - python3.11: python3.11 + python3.13: python3.13 + python3.14: python3.14 deps = - django3.2: Django>=3.2,<4.0 - django4.1: Django>=4.1,<4.2 - django4.2: Django>=4.2,<4.3 - wagtail4.1: wagtail>=4.1,<4.2 - wagtail4.2: wagtail>=4.2,<5.0 - wagtail5.0: wagtail>=5.0,<5.1 - wagtail5.1: wagtail>=5.1,<5.2 + django5.2: Django>=5.2,<6.0 + wagtail7.0: wagtail>=7.0,<7.1 extras = test