Files
RosterChief/clubmanager/settings.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

205 lines
6.5 KiB
Python

"""
Django settings for clubmanager project.
Generated by 'django-admin startproject' using Django 6.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
from decouple import Csv, config
from dj_database_url import parse as db_url
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config("DJANGO_SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="")
INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", cast=Csv(), default="127.0.0.1")
CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="")
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"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",
"teams.apps.TeamsConfig",
"events.apps.EventsConfig",
"formbuilder.apps.FormbuilderConfig",
"shop.apps.ShopConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"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.
CLUBMANAGER_BASE_DOMAIN = config("CLUBMANAGER_BASE_DOMAIN", default="")
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",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "clubmanager.wsgi.application"
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
"default": config("DJANGO_DATABASE_URL", default="sqlite:///db.sqlite3", cast=db_url),
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = config("DJANGO_TIME_ZONE", default="Europe/Brussels", cast=str)
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Phone numbers (django-phonenumber-field)
PHONENUMBER_DEFAULT_REGION = "BE"
PHONENUMBER_DB_FORMAT = "E164"