Files
Kochbuch/config/settings.py
T
Kyle DammannandClaude Opus 5.5 ef9deaeff7 Add phase 1: Docker setup, auth with invitations and roles
- Django 5.2 + PostgreSQL 17 via docker compose (app + db, volumes,
  healthcheck on /healthz, test profile running pytest against Postgres)
- Custom e-mail user model with user/admin roles
- Invitation-only registration with single-use, hashed, expiring links
- Admin area: invitations, role changes, deactivation, password reset
  via one-time link or temporary password (forced change on login)
- First admin created from ADMIN_EMAIL/ADMIN_PASSWORD on startup
- Login rate limiting per e-mail and IP (DB-backed, worker-safe)
- Proxy middleware honouring X-Forwarded-* only from TRUSTED_PROXIES,
  optional CLIENT_IP_HEADER (e.g. CF-Connecting-IP)
- Security headers (CSP, HSTS, Permissions-Policy), secure __Host- cookies
- Mobile-first German UI, CLAUDE.md, README, init-env script
- 65 tests for auth, permissions, proxy handling and rate limiting

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:59 +01:00

193 lines
6.9 KiB
Python

"""Django settings. All deployment-specific values come from environment variables (see .env.example)."""
import ipaddress
import os
from pathlib import Path
from urllib.parse import urlsplit
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
def env(name, default=""):
return os.environ.get(name, default).strip()
def env_bool(name, default=False):
value = env(name)
if not value:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_int(name, default):
value = env(name)
return int(value) if value else default
def env_list(name, default=""):
return [item.strip() for item in env(name, default).split(",") if item.strip()]
DEBUG = env_bool("DEBUG")
SECRET_KEY = env("SECRET_KEY")
if not SECRET_KEY:
raise ImproperlyConfigured("SECRET_KEY ist nicht gesetzt (siehe .env.example).")
APP_BASE_URL = env("APP_BASE_URL", "http://localhost:8000").rstrip("/")
_base = urlsplit(APP_BASE_URL)
if _base.scheme not in {"http", "https"} or not _base.hostname:
raise ImproperlyConfigured("APP_BASE_URL muss eine absolute http(s)-URL sein.")
# 127.0.0.1/localhost are needed for the container healthcheck.
ALLOWED_HOSTS = [_base.hostname, "localhost", "127.0.0.1", *env_list("ALLOWED_HOSTS")]
CSRF_TRUSTED_ORIGINS = [f"{_base.scheme}://{_base.netloc}"]
TRUSTED_PROXIES = [
ipaddress.ip_network(net, strict=False)
for net in env_list("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16")
]
CLIENT_IP_HEADER = env("CLIENT_IP_HEADER")
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"core",
"accounts",
]
MIDDLEWARE = [
# Must run first: sanitises X-Forwarded-* before anything reads REMOTE_ADDR or is_secure().
"core.middleware.ProxyMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"core.middleware.SecurityHeadersMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
# Every view requires login unless decorated with @login_not_required.
"django.contrib.auth.middleware.LoginRequiredMiddleware",
"accounts.middleware.ForcePasswordChangeMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
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",
],
},
},
]
if env("POSTGRES_HOST"):
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": env("POSTGRES_HOST"),
"PORT": env("POSTGRES_PORT", "5432"),
"NAME": env("POSTGRES_DB", "kochbuch"),
"USER": env("POSTGRES_USER", "kochbuch"),
"PASSWORD": env("POSTGRES_PASSWORD"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
}
else:
# Local development and tests without Docker.
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "core:home"
LOGOUT_REDIRECT_URL = "accounts:login"
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 10}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LOGIN_MAX_FAILURES_PER_EMAIL = env_int("LOGIN_MAX_FAILURES_PER_EMAIL", 5)
LOGIN_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30)
LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15)
INVITATION_VALID_DAYS = 7
PASSWORD_RESET_VALID_HOURS = 24
# --- Cookies & security headers ---------------------------------------------
COOKIE_SECURE = env_bool("COOKIE_SECURE", default=True)
SESSION_COOKIE_SECURE = COOKIE_SECURE
CSRF_COOKIE_SECURE = COOKIE_SECURE
# The __Host- prefix pins the cookie to this exact host; browsers only accept it over HTTPS.
SESSION_COOKIE_NAME = "__Host-kb_session" if COOKIE_SECURE else "kb_session"
CSRF_COOKIE_NAME = "__Host-kb_csrf" if COOKIE_SECURE else "kb_csrf"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = env_int("HSTS_SECONDS", 31536000)
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "same-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
# The reverse proxy redirects to HTTPS; HSTS for other subdomains/preload is the domain owner's call.
SILENCED_SYSTEM_CHECKS = ["security.W005", "security.W008", "security.W021"]
CONTENT_SECURITY_POLICY = (
"default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; "
"font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; "
"form-action 'self'; frame-ancestors 'none'"
)
PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), screen-wake-lock=(self)"
# --- I18N ----------------------------------------------------------------------
LANGUAGE_CODE = "de"
TIME_ZONE = env("TIME_ZONE", "Europe/Berlin")
USE_I18N = True
USE_TZ = True
# --- Static & media --------------------------------------------------------------
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# Uploaded files are never served directly; they go through permission-checked views.
MEDIA_ROOT = Path(env("MEDIA_ROOT", str(BASE_DIR / "data" / "media")))
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {"django.request": {"level": "WARNING"}},
}