mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 05:45:17 -05:00
feat: Implement passkey authentication, account management features, and a dedicated MFA login verification flow.
This commit is contained in:
@@ -28,3 +28,65 @@ class IsStaffOrReadOnly(permissions.BasePermission):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return request.user and request.user.is_staff
|
||||
|
||||
|
||||
class IsAdminWithSecondFactor(permissions.BasePermission):
|
||||
"""
|
||||
Requires admin status AND at least one configured second factor.
|
||||
|
||||
Accepts either:
|
||||
- TOTP (MFA/Authenticator app)
|
||||
- WebAuthn (Passkey/Security key)
|
||||
|
||||
This permission ensures that admin users have a second factor configured
|
||||
before they can access sensitive admin endpoints.
|
||||
"""
|
||||
|
||||
message = "Admin access requires MFA or Passkey to be configured."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
|
||||
# Must be authenticated
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Must be admin (staff, superuser, or ADMIN role)
|
||||
if not self._is_admin(user):
|
||||
self.message = "You do not have admin privileges."
|
||||
return False
|
||||
|
||||
# Must have at least one second factor configured
|
||||
if not self._has_second_factor(user):
|
||||
self.message = "Admin access requires MFA or Passkey to be configured."
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_admin(self, user) -> bool:
|
||||
"""Check if user has admin privileges."""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if user.is_staff:
|
||||
return True
|
||||
# Check custom role field if it exists
|
||||
if hasattr(user, "role") and user.role in ("ADMIN", "SUPERUSER"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_second_factor(self, user) -> bool:
|
||||
"""Check if user has at least one second factor configured."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
# Check for TOTP or WebAuthn authenticators
|
||||
return Authenticator.objects.filter(
|
||||
user=user,
|
||||
type__in=[Authenticator.Type.TOTP, Authenticator.Type.WEBAUTHN]
|
||||
).exists()
|
||||
except ImportError:
|
||||
# allauth.mfa not installed
|
||||
return False
|
||||
except Exception:
|
||||
# Any other error, fail closed (deny access)
|
||||
return False
|
||||
|
||||
137
backend/apps/core/tests/test_permissions.py
Normal file
137
backend/apps/core/tests/test_permissions.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Tests for custom permissions, particularly IsAdminWithSecondFactor.
|
||||
|
||||
Tests that admin users must have MFA or Passkey configured before
|
||||
accessing sensitive admin endpoints.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.core.permissions import IsAdminWithSecondFactor
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestIsAdminWithSecondFactor(TestCase):
|
||||
"""Tests for IsAdminWithSecondFactor permission class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.factory = RequestFactory()
|
||||
self.permission = IsAdminWithSecondFactor()
|
||||
|
||||
def _make_request(self, user=None):
|
||||
"""Create a mock request with the given user."""
|
||||
request = self.factory.get("/api/v1/admin/test/")
|
||||
request.user = user if user else MagicMock(is_authenticated=False)
|
||||
return request
|
||||
|
||||
def test_anonymous_user_denied(self):
|
||||
"""Anonymous users should be denied access."""
|
||||
request = self._make_request()
|
||||
request.user.is_authenticated = False
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
|
||||
def test_non_admin_user_denied(self):
|
||||
"""Non-admin users should be denied access."""
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = False
|
||||
user.role = "USER"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
self.assertIn("admin privileges", self.permission.message)
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_admin_without_mfa_denied(self, mock_has_second_factor):
|
||||
"""Admin without MFA or Passkey should be denied access."""
|
||||
mock_has_second_factor.return_value = False
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.role = "ADMIN"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertFalse(self.permission.has_permission(request, None))
|
||||
self.assertIn("MFA or Passkey", self.permission.message)
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_superuser_with_mfa_allowed(self, mock_has_second_factor):
|
||||
"""Superuser with MFA configured should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_staff_with_passkey_allowed(self, mock_has_second_factor):
|
||||
"""Staff user with Passkey configured should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = True
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
@patch("apps.core.permissions.IsAdminWithSecondFactor._has_second_factor")
|
||||
def test_admin_role_with_mfa_allowed(self, mock_has_second_factor):
|
||||
"""User with ADMIN role and MFA should be allowed access."""
|
||||
mock_has_second_factor.return_value = True
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.is_superuser = False
|
||||
user.is_staff = False
|
||||
user.role = "ADMIN"
|
||||
|
||||
request = self._make_request(user)
|
||||
|
||||
self.assertTrue(self.permission.has_permission(request, None))
|
||||
|
||||
def test_has_second_factor_with_totp(self):
|
||||
"""Test _has_second_factor detects TOTP authenticator."""
|
||||
user = MagicMock()
|
||||
|
||||
with patch("apps.core.permissions.Authenticator") as MockAuth:
|
||||
# Mock the queryset to return True for TOTP
|
||||
mock_qs = MagicMock()
|
||||
mock_qs.filter.return_value.exists.return_value = True
|
||||
MockAuth.objects.filter.return_value = mock_qs
|
||||
MockAuth.Type.TOTP = "totp"
|
||||
MockAuth.Type.WEBAUTHN = "webauthn"
|
||||
|
||||
# Need to patch the import inside the method
|
||||
with patch.dict("sys.modules", {"allauth.mfa.models": MagicMock(Authenticator=MockAuth)}):
|
||||
result = self.permission._has_second_factor(user)
|
||||
# This tests the exception path since import is mocked at module level
|
||||
# The actual integration test would require a full database setup
|
||||
|
||||
def test_has_second_factor_import_error(self):
|
||||
"""Test _has_second_factor handles ImportError gracefully."""
|
||||
user = MagicMock()
|
||||
|
||||
with patch.dict("sys.modules", {"allauth.mfa.models": None}):
|
||||
with patch("builtins.__import__", side_effect=ImportError):
|
||||
# Should return False, not raise exception
|
||||
result = self.permission._has_second_factor(user)
|
||||
self.assertFalse(result)
|
||||
Reference in New Issue
Block a user