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]>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"""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"}},
|
||||
}
|
||||
Reference in New Issue
Block a user