from __future__ import annotations from urllib.parse import parse_qs, urlparse import pyotp from django.contrib.auth.models import AnonymousUser, Permission from django.contrib.sessions.middleware import SessionMiddleware from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings from django.urls import reverse from django.views import View from django.conf import settings from labs.admin.sudo import ( SESSION_KEY_SUDO_IP, SESSION_KEY_SUDO_UNTIL, SESSION_KEY_SUDO_USER_ID, grant_sudo, is_sudo_active, ) from labs.admin.otp import TOTPService from labs.admin.views.base import AdminBaseViewMixin, PermissionRequiredAdminMixin from labs.admin.views.otp import OTPVerifyView, StaffRequiredMixin from labs.models import AdminOTPDevice, User def _attach_session(request) -> None: middleware = SessionMiddleware(lambda _request: HttpResponse()) middleware.process_request(request) request.session.save() def _build_request( factory: RequestFactory, *, method: str, path: str, user, data: dict | None = None, remote_addr: str = "127.0.0.1", ): if method.upper() == "POST": request = factory.post(path, data=data or {}) else: request = factory.get(path) request.user = user request.META["REMOTE_ADDR"] = remote_addr _attach_session(request) return request class _DummyAdminView(AdminBaseViewMixin, View): def get(self, request, *args, **kwargs) -> HttpResponse: return HttpResponse("ok") class _DummyPermissionView(PermissionRequiredAdminMixin, View): permission_required = "auth.view_group" def get(self, request, *args, **kwargs) -> HttpResponse: return HttpResponse("ok") class _DummyStaffView(StaffRequiredMixin, View): def get(self, request, *args, **kwargs) -> HttpResponse: return HttpResponse("ok") class AdminLoginRedirectTests(SimpleTestCase): def setUp(self) -> None: super().setUp() self.factory = RequestFactory() def assert_login_redirect(self, path: str, view) -> None: request = self.factory.get(path) request.user = AnonymousUser() response = view.as_view()(request) self.assertEqual(response.status_code, 302) parsed = urlparse(response["Location"]) self.assertEqual(parsed.path, settings.LOGIN_URL) self.assertEqual(parse_qs(parsed.query)["redirect_uri"][0], path) def test_admin_redirects_unauthenticated_to_login(self) -> None: self.assert_login_redirect("/admin/", _DummyAdminView) def test_otp_view_redirects_unauthenticated_to_login(self) -> None: self.assert_login_redirect("/admin/otp/setup/", _DummyStaffView) @override_settings( CACHES={ "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, } ) class AdminSecurityTests(TestCase): def setUp(self) -> None: super().setUp() self.factory = RequestFactory() def test_admin_denies_authenticated_non_staff(self) -> None: user = User.objects.create_user( email="user@example.com", password="password", is_staff=False, ) request = _build_request(self.factory, method="GET", path="/admin/", user=user) with self.assertRaises(PermissionDenied): _DummyAdminView.as_view()(request) def test_admin_redirects_staff_without_otp_to_setup(self) -> None: user = User.objects.create_user( email="staff@example.com", password="password", is_staff=True, ) request = _build_request(self.factory, method="GET", path="/admin/", user=user) response = _DummyAdminView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], reverse("labs_admin:otp_setup")) def test_admin_redirects_staff_with_unconfirmed_otp_to_setup(self) -> None: user = User.objects.create_user( email="staff2@example.com", password="password", is_staff=True, ) AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=False, ) request = _build_request(self.factory, method="GET", path="/admin/", user=user) response = _DummyAdminView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], reverse("labs_admin:otp_setup")) def test_admin_redirects_staff_without_sudo_to_verify(self) -> None: user = User.objects.create_user( email="staff3@example.com", password="password", is_staff=True, ) AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=True, ) request = _build_request(self.factory, method="GET", path="/admin/", user=user) response = _DummyAdminView.as_view()(request) self.assertEqual(response.status_code, 302) parsed = urlparse(response["Location"]) self.assertEqual(parsed.path, reverse("labs_admin:otp_verify")) self.assertEqual(parse_qs(parsed.query)["next"][0], "/admin/") def test_admin_allows_staff_with_confirmed_otp_and_sudo(self) -> None: user = User.objects.create_user( email="staff4@example.com", password="password", is_staff=True, ) AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=True, ) request = _build_request(self.factory, method="GET", path="/admin/", user=user) grant_sudo(request) response = _DummyAdminView.as_view()(request) self.assertEqual(response.status_code, 200) def test_permission_required_denies_without_permission(self) -> None: user = User.objects.create_user( email="staff5@example.com", password="password", is_staff=True, ) AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=True, ) request = _build_request( self.factory, method="GET", path="/admin/groups/", user=user ) grant_sudo(request) with self.assertRaises(PermissionDenied): _DummyPermissionView.as_view()(request) def test_permission_required_allows_with_permission(self) -> None: user = User.objects.create_user( email="staff6@example.com", password="password", is_staff=True, ) AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=True, ) view_group_perm = Permission.objects.get( content_type__app_label="auth", codename="view_group", ) user.user_permissions.add(view_group_perm) request = _build_request( self.factory, method="GET", path="/admin/groups/", user=user ) grant_sudo(request) response = _DummyPermissionView.as_view()(request) self.assertEqual(response.status_code, 200) def test_otp_verify_grants_sudo(self) -> None: user = User.objects.create_user( email="staff7@example.com", password="password", is_staff=True, ) device = AdminOTPDevice.objects.create( user=user, secret_encrypted="JBSWY3DPEHPK3PXP", is_confirmed=True, ) code = pyotp.TOTP(device.secret_encrypted).now() request = _build_request( self.factory, method="POST", path="/admin/otp/verify/", user=user, data={"code": code, "next": "/admin/"}, ) response = OTPVerifyView.as_view()(request) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "/admin/") self.assertTrue(is_sudo_active(request)) self.assertIn(SESSION_KEY_SUDO_UNTIL, request.session) self.assertEqual(request.session[SESSION_KEY_SUDO_USER_ID], user.id) self.assertIn(SESSION_KEY_SUDO_IP, request.session) @override_settings(ADMIN_OTP_TOTP_PERIOD=60) def test_admin_otp_totp_period_affects_totp_interval(self) -> None: service = TOTPService() secret = "JBSWY3DPEHPK3PXP" reference_totp_30 = pyotp.TOTP(secret, interval=30) reference_totp_60 = pyotp.TOTP(secret, interval=60) # Pick a deterministic timestamp where 30s and 60s codes differ. timestamp = 1_700_000_000 for _ in range(120): code_30 = reference_totp_30.at(timestamp) code_60 = reference_totp_60.at(timestamp) if code_30 != code_60: break timestamp += 1 else: self.fail("Failed to find a timestamp where 30s and 60s TOTP codes differ") service_totp = service.get_totp(secret) self.assertTrue( service_totp.verify(code_60, for_time=timestamp, valid_window=0) ) self.assertFalse( service_totp.verify(code_30, for_time=timestamp, valid_window=0) )