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:
@@ -44,6 +44,12 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"phonenumber_field",
|
||||
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
|
||||
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
|
||||
# the tenant root, not Site).
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.mfa",
|
||||
"club.apps.ClubConfig",
|
||||
"authentication.apps.AuthenticationConfig",
|
||||
"members.apps.MembersConfig",
|
||||
@@ -59,11 +65,18 @@ MIDDLEWARE = [
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
"authentication.middleware.RequireMFAMiddleware",
|
||||
"club.tenancy.ClubTenantMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
# Multi-tenancy: base domain whose subdomains resolve to a club, e.g.
|
||||
# "ajax-united.clubmanager.app" -> the club with slug "ajax-united". Leave
|
||||
# unset to fall back to generic "slug.example.com" (3+ label) resolution.
|
||||
@@ -73,6 +86,50 @@ ROOT_URLCONF = "clubmanager.urls"
|
||||
|
||||
AUTH_USER_MODEL = "authentication.User"
|
||||
|
||||
|
||||
# Authentication (django-allauth)
|
||||
|
||||
LOGIN_URL = "account_login"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
# The User model logs in by email and has no username field.
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
ACCOUNT_LOGIN_METHODS = {"email"}
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||
|
||||
|
||||
# Two-factor authentication (allauth.mfa)
|
||||
|
||||
MFA_SUPPORTED_TYPES = ["totp", "webauthn", "recovery_codes"]
|
||||
|
||||
# Passkeys are a first factor: sign in with Touch ID / a security key alone.
|
||||
MFA_PASSKEY_LOGIN_ENABLED = True
|
||||
MFA_PASSKEY_SIGNUP_ENABLED = False
|
||||
|
||||
# WebAuthn needs a secure context. Browsers treat *.localhost as secure, but the
|
||||
# dev server is plain HTTP, so allow the insecure origin while DEBUG.
|
||||
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
|
||||
|
||||
# A passkey is bound to a Relying Party ID (a domain). Our adapter pins it to
|
||||
# CLUBMANAGER_BASE_DOMAIN so that ONE passkey works across every club subdomain
|
||||
# — allauth's default (the request host) would bind it to a single club.
|
||||
MFA_ADAPTER = "authentication.adapters.ClubManagerMFAAdapter"
|
||||
MFA_WEBAUTHN_RP_NAME = config("CLUBMANAGER_RP_NAME", default="ClubManager")
|
||||
|
||||
# Where RequireMFAMiddleware sends privileged users who haven't enrolled yet.
|
||||
MFA_ENROLMENT_URL_NAME = "mfa_index"
|
||||
|
||||
|
||||
# Sessions are shared across club subdomains: log in once and you're authenticated
|
||||
# on every club (matching the one-passkey-everywhere model). Tenancy still scopes
|
||||
# what you can *see* — that is the access service's job, not the cookie's.
|
||||
# Browsers reject a Domain attribute on localhost, so it stays host-only in dev.
|
||||
SHARED_COOKIE_DOMAIN = f".{CLUBMANAGER_BASE_DOMAIN}" if CLUBMANAGER_BASE_DOMAIN and CLUBMANAGER_BASE_DOMAIN != "localhost" else None
|
||||
|
||||
SESSION_COOKIE_DOMAIN = config("DJANGO_SESSION_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
|
||||
CSRF_COOKIE_DOMAIN = config("DJANGO_CSRF_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"""
|
||||
URL configuration for clubmanager project.
|
||||
"""URL configuration for clubmanager.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
``/admin/login/`` is deliberately intercepted *before* ``admin.site.urls`` and
|
||||
redirected to the allauth login, so Django staff go through the same MFA
|
||||
challenge as everyone else — Django's own admin login form knows nothing about
|
||||
second factors. ``RequireMFAMiddleware`` then blocks any staff user who has not
|
||||
enrolled.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user