Adopt django-allauth with allauth.mfa, giving TOTP, WebAuthn passkeys and recovery codes — and the signup/password-reset flows we'll need next. There was no login UI at all before this (only /admin/), so this brings the auth stack. The critical piece is authentication/adapters.py. A passkey is bound to a WebAuthn Relying Party ID (a domain), and allauth derives that from the request host — which under our subdomain tenancy would bind a passkey to a *single* club (ajax-united.clubmanager.app) and silently fail at every other one. The adapter pins the RP ID to CLUBMANAGER_BASE_DOMAIN so one passkey works across all clubs. Note this cuts both ways: changing that base domain invalidates every existing passkey. RequireMFAMiddleware makes a second factor mandatory for anyone who can change other people's data — Django staff/superusers and holders of an elevated ClubRole (ADMIN/EDITOR), via the access service — while leaving it optional for regular members. /admin/login/ is routed through allauth, since Django's own admin login knows nothing about second factors. allauth is installed WITHOUT django.contrib.sites (optional since allauth 65), so ARCHITECTURE.md's rejection of the Sites framework stands and no Club.site bridge is needed. Sessions are shared across club subdomains, matching the one-passkey-everywhere model; tenancy still scopes what you can see. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
235 lines
9.3 KiB
Python
235 lines
9.3 KiB
Python
import uuid
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from allauth.core import context
|
|
from allauth.mfa.models import Authenticator
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.db import IntegrityError
|
|
from django.http import HttpResponse
|
|
from django.test import RequestFactory, TestCase, override_settings
|
|
from django.urls import reverse
|
|
|
|
from club.models import Club, ClubRole
|
|
from members.models import Member
|
|
|
|
from .adapters import ClubManagerMFAAdapter, webauthn_rp_id
|
|
from .middleware import RequireMFAMiddleware, mfa_required_for
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
def enrol_mfa(user):
|
|
"""Give ``user`` a second factor (enough for is_mfa_enabled)."""
|
|
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
|
|
|
|
|
|
class UserManagerTests(TestCase):
|
|
def test_create_user_defaults(self):
|
|
user = User.objects.create_user(email="alice@example.com", password="secret123")
|
|
|
|
self.assertEqual(user.email, "alice@example.com")
|
|
self.assertTrue(user.check_password("secret123"))
|
|
self.assertFalse(user.is_staff)
|
|
self.assertFalse(user.is_superuser)
|
|
self.assertTrue(user.is_active)
|
|
|
|
def test_create_user_requires_email(self):
|
|
with self.assertRaises(ValueError):
|
|
User.objects.create_user(email="", password="secret123")
|
|
|
|
def test_create_user_normalizes_email_domain(self):
|
|
# BaseUserManager lowercases the domain part of the address.
|
|
user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
|
|
|
|
self.assertEqual(user.email, "Bob@example.com")
|
|
|
|
def test_create_user_password_is_hashed(self):
|
|
user = User.objects.create_user(email="carol@example.com", password="secret123")
|
|
|
|
self.assertNotEqual(user.password, "secret123")
|
|
|
|
def test_create_user_without_password_is_unusable(self):
|
|
user = User.objects.create_user(email="dave@example.com")
|
|
|
|
self.assertFalse(user.has_usable_password())
|
|
|
|
def test_create_superuser_defaults(self):
|
|
admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
|
|
|
|
self.assertTrue(admin.is_staff)
|
|
self.assertTrue(admin.is_superuser)
|
|
self.assertTrue(admin.is_active)
|
|
|
|
def test_create_superuser_rejects_non_staff(self):
|
|
with self.assertRaises(ValueError):
|
|
User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
|
|
|
|
def test_create_superuser_rejects_non_superuser(self):
|
|
with self.assertRaises(ValueError):
|
|
User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
|
|
|
|
|
|
class UserModelTests(TestCase):
|
|
def test_email_is_username_field(self):
|
|
self.assertEqual(User.USERNAME_FIELD, "email")
|
|
self.assertEqual(User.REQUIRED_FIELDS, [])
|
|
|
|
def test_email_is_unique(self):
|
|
User.objects.create_user(email="dup@example.com", password="x")
|
|
with self.assertRaises(IntegrityError):
|
|
User.objects.create_user(email="dup@example.com", password="y")
|
|
|
|
def test_pk_is_uuid(self):
|
|
user = User.objects.create_user(email="uuid@example.com", password="x")
|
|
self.assertIsInstance(user.pk, uuid.UUID)
|
|
|
|
def test_str_and_names_fall_back_to_email_without_member(self):
|
|
user = User.objects.create_user(email="lonely@example.com", password="x")
|
|
|
|
self.assertEqual(str(user), "lonely@example.com")
|
|
self.assertEqual(user.get_full_name(), "lonely@example.com")
|
|
self.assertEqual(user.get_short_name(), "lonely@example.com")
|
|
|
|
def test_str_and_names_use_linked_member(self):
|
|
user = User.objects.create_user(email="linked@example.com", password="x")
|
|
Member.objects.create(user=user, first_name="Jane", last_name="Doe")
|
|
|
|
# Re-fetch so the reverse OneToOne relation is resolved from the DB.
|
|
user = User.objects.get(pk=user.pk)
|
|
|
|
self.assertEqual(str(user), "Jane Doe")
|
|
self.assertEqual(user.get_full_name(), "Jane Doe")
|
|
self.assertEqual(user.get_short_name(), "Jane")
|
|
|
|
|
|
@override_settings(
|
|
CLUBMANAGER_BASE_DOMAIN="clubmanager.app",
|
|
MFA_WEBAUTHN_RP_NAME="ClubManager",
|
|
ALLOWED_HOSTS=[".clubmanager.app", "example.test"],
|
|
)
|
|
class WebAuthnRelyingPartyTests(TestCase):
|
|
"""A passkey is bound to a Relying Party ID (a domain).
|
|
|
|
allauth's default RP ID is the request host, which under our subdomain
|
|
tenancy would bind a passkey to a single club. We pin it to the registrable
|
|
parent domain so ONE passkey works across every club.
|
|
"""
|
|
|
|
def rp_entity(self, host):
|
|
request = RequestFactory().get("/", HTTP_HOST=host)
|
|
with context.request_context(request):
|
|
return ClubManagerMFAAdapter().get_public_key_credential_rp_entity()
|
|
|
|
def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
|
|
self.assertEqual(self.rp_entity("ajax-united.clubmanager.app")["id"], "clubmanager.app")
|
|
|
|
def test_rp_id_is_identical_across_clubs(self):
|
|
# The whole point: a passkey registered at one club works at the others.
|
|
here = self.rp_entity("ajax-united.clubmanager.app")
|
|
there = self.rp_entity("rival-fc.clubmanager.app")
|
|
|
|
self.assertEqual(here["id"], there["id"])
|
|
|
|
def test_rp_name_comes_from_settings(self):
|
|
self.assertEqual(self.rp_entity("ajax-united.clubmanager.app")["name"], "ClubManager")
|
|
|
|
@override_settings(CLUBMANAGER_BASE_DOMAIN="")
|
|
def test_falls_back_to_the_request_host_without_a_base_domain(self):
|
|
request = RequestFactory().get("/", HTTP_HOST="example.test:8000")
|
|
|
|
with context.request_context(request):
|
|
self.assertEqual(webauthn_rp_id(), "example.test")
|
|
|
|
|
|
class MFARequirementTests(TestCase):
|
|
def setUp(self):
|
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def make_user(self, email, **kwargs):
|
|
return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
|
|
|
|
def with_role(self, user, role):
|
|
member = Member.objects.create(user=user, first_name="Ada", last_name="Min")
|
|
ClubRole.objects.create(club=self.club, member=member, role=role)
|
|
return user
|
|
|
|
def test_staff_must_have_mfa(self):
|
|
self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True)))
|
|
|
|
def test_superuser_must_have_mfa(self):
|
|
self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123")))
|
|
|
|
def test_club_admin_must_have_mfa(self):
|
|
user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN)
|
|
|
|
self.assertTrue(mfa_required_for(user))
|
|
|
|
def test_editor_must_have_mfa(self):
|
|
user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR)
|
|
|
|
self.assertTrue(mfa_required_for(user))
|
|
|
|
def test_plain_member_does_not_need_mfa(self):
|
|
user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER)
|
|
|
|
self.assertFalse(mfa_required_for(user))
|
|
|
|
def test_user_without_any_role_does_not_need_mfa(self):
|
|
self.assertFalse(mfa_required_for(self.make_user("nobody@example.com")))
|
|
|
|
|
|
class RequireMFAMiddlewareTests(TestCase):
|
|
def setUp(self):
|
|
self.factory = RequestFactory()
|
|
self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok"))
|
|
|
|
def dispatch(self, user, path="/"):
|
|
request = self.factory.get(path)
|
|
request.user = user
|
|
return self.middleware(request)
|
|
|
|
def make_staff(self):
|
|
return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
|
|
|
|
def test_anonymous_passes_through(self):
|
|
self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok")
|
|
|
|
def test_unprivileged_user_passes_through(self):
|
|
user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
|
|
|
|
self.assertEqual(self.dispatch(user).content, b"ok")
|
|
|
|
def test_privileged_user_without_mfa_is_sent_to_enrolment(self):
|
|
response = self.dispatch(self.make_staff())
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response.url, reverse("mfa_index"))
|
|
|
|
def test_privileged_user_can_still_reach_the_enrolment_pages(self):
|
|
# Otherwise they'd be redirected in a loop and could never enrol.
|
|
response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/")
|
|
|
|
self.assertEqual(response.content, b"ok")
|
|
|
|
def test_enrolled_privileged_user_passes_through(self):
|
|
staff = self.make_staff()
|
|
enrol_mfa(staff)
|
|
|
|
self.assertEqual(self.dispatch(staff).content, b"ok")
|
|
|
|
|
|
class AdminLoginRoutingTests(TestCase):
|
|
def test_admin_login_is_routed_through_allauth(self):
|
|
# Django's own admin login knows nothing about second factors.
|
|
response = self.client.get("/admin/login/", {"next": "/admin/"})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
redirect = urlparse(response.url)
|
|
self.assertEqual(redirect.path, reverse("account_login"))
|
|
# The original destination survives the hop (percent-encoded).
|
|
self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
|
|
|
|
def test_allauth_login_page_loads(self):
|
|
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
|