From 773744d04b163ae096a3c37a36d3ae2a006fdfec Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:06:26 -0700 Subject: [PATCH] Enhancement: include Django admin with 2FA (#14270) --- src/paperless/tests/test_admin_auth.py | 68 ++++++++++++++++++++++++++ src/paperless/urls.py | 3 ++ 2 files changed, 71 insertions(+) create mode 100644 src/paperless/tests/test_admin_auth.py diff --git a/src/paperless/tests/test_admin_auth.py b/src/paperless/tests/test_admin_auth.py new file mode 100644 index 000000000..434b82990 --- /dev/null +++ b/src/paperless/tests/test_admin_auth.py @@ -0,0 +1,68 @@ +import time + +from allauth.mfa import app_settings as mfa_settings +from allauth.mfa.totp.internal import auth as totp_auth +from django.test import TestCase +from django.urls import reverse + +from paperless_testing.factories import UserFactory + + +class TestAdminAuth(TestCase): + def test_admin_login_redirects_to_allauth(self): + user = UserFactory(staff=True, password="testpassword") + admin_url = reverse("admin:index") + login_url = reverse("admin:login") + expected_url = f"{reverse('account_login')}?next={admin_url}" + + response = self.client.get(login_url, {"next": admin_url}) + self.assertRedirects(response, expected_url) + + response = self.client.post( + login_url, + {"username": user.username, "password": "testpassword", "next": admin_url}, + ) + self.assertRedirects(response, expected_url) + self.assertNotIn("_auth_user_id", self.client.session) + + def test_admin_access_requires_totp_for_enrolled_staff(self): + user = UserFactory(staff=True, password="testpassword") + secret = totp_auth.generate_totp_secret() + totp_auth.TOTP.activate(user, secret) + admin_url = reverse("admin:index") + mfa_url = reverse("mfa_authenticate") + + response = self.client.post( + reverse("account_login"), + {"login": user.username, "password": "testpassword", "next": admin_url}, + ) + self.assertRedirects(response, mfa_url) + self.assertNotIn("_auth_user_id", self.client.session) + self.assertRedirects( + self.client.get(admin_url), + f"{reverse('admin:login')}?next={admin_url}", + fetch_redirect_response=False, + ) + + response = self.client.post(mfa_url, {"code": "invalid"}) + self.assertEqual(response.status_code, 200) + self.assertNotIn("_auth_user_id", self.client.session) + + code = totp_auth.format_hotp_value( + totp_auth.hotp_value(secret, int(time.time()) // mfa_settings.TOTP_PERIOD), + ) + response = self.client.post(mfa_url, {"code": code}) + self.assertRedirects(response, admin_url) + self.assertEqual(self.client.session["_auth_user_id"], str(user.pk)) + + def test_staff_without_totp_can_still_log_in(self): + user = UserFactory(staff=True, password="testpassword") + admin_url = reverse("admin:index") + + response = self.client.post( + reverse("account_login"), + {"login": user.username, "password": "testpassword", "next": admin_url}, + ) + + self.assertRedirects(response, admin_url) + self.assertEqual(self.client.session["_auth_user_id"], str(user.pk)) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 0c02d172b..a5c1b8c45 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -1,4 +1,5 @@ from allauth.account import views as allauth_account_views +from allauth.account.decorators import secure_admin_login from allauth.mfa.base import views as allauth_mfa_views from allauth.socialaccount import views as allauth_social_account_views from allauth.urls import build_provider_urlpatterns @@ -68,6 +69,8 @@ from paperless_mail.views import MailRuleViewSet from paperless_mail.views import OauthCallbackView from paperless_mail.views import ProcessedMailViewSet +admin.site.login = secure_admin_login(admin.site.login) + api_router = DefaultRouter() api_router.register(r"correspondents", CorrespondentViewSet) api_router.register(r"document_types", DocumentTypeViewSet)