feat(auth): two-factor authentication (TOTP, passkeys, recovery codes)

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>
This commit is contained in:
2026-07-13 14:43:55 +02:00
parent 819700ad0c
commit 10736fd5ee
10 changed files with 490 additions and 16 deletions

View File

@@ -0,0 +1,37 @@
"""allauth adapters.
The MFA adapter exists for one important reason: WebAuthn credentials are bound
to a **Relying Party ID** (a domain). allauth's default RP ID is the request's
host — which under our subdomain tenancy would be ``ajax-united.clubmanager.app``,
binding a passkey to *one club*. A member of two clubs would then need two
passkeys, and a credential registered at one club would silently fail at another.
Pinning the RP ID to the registrable parent domain (``clubmanager.app``) makes a
single passkey work across every club subdomain.
"""
from allauth.mfa.adapter import DefaultMFAAdapter
from django.conf import settings
class ClubManagerMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self) -> dict[str, str]:
return {
"id": webauthn_rp_id(),
"name": settings.MFA_WEBAUTHN_RP_NAME,
}
def webauthn_rp_id() -> str:
"""The registrable parent domain that passkeys are bound to.
Falls back to the request host when no base domain is configured (e.g. a
bare ``localhost`` dev server), which keeps WebAuthn usable there.
"""
base_domain = getattr(settings, "CLUBMANAGER_BASE_DOMAIN", "")
if base_domain:
return base_domain
from allauth.core import context
return context.request.get_host().partition(":")[0]