Files
RosterChief/authentication/middleware.py
Bernard Siebens 10736fd5ee 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>
2026-07-13 14:43:55 +02:00

48 lines
1.7 KiB
Python

"""Force MFA enrolment for privileged users.
Anyone who can change other people's data must have a second factor: Django
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
in *any* club. Regular members may enrol, but aren't forced to.
Enrolled users are challenged for their second factor by allauth at login; this
middleware only handles the other half — a privileged user who has never
enrolled is redirected to the MFA setup page until they do.
"""
from allauth.mfa.utils import is_mfa_enabled
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse
from club.models import ClubRole
#: Paths a not-yet-enrolled user must still reach (to enrol, or to log out).
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/")
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)
def mfa_required_for(user) -> bool:
"""Privileged users must hold a second factor."""
if user.is_staff or user.is_superuser:
return True
return ClubRole.objects.filter(member__user=user, role__in=ELEVATED_ROLES).exists()
class RequireMFAMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if self.needs_enrolment(request):
return redirect(reverse(settings.MFA_ENROLMENT_URL_NAME))
return self.get_response(request)
def needs_enrolment(self, request) -> bool:
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return False
if request.path.startswith(EXEMPT_PREFIXES):
return False
return mfa_required_for(user) and not is_mfa_enabled(user)