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:
Kyle Dammann
2026-10-30 21:24:59 +01:00
co-authored by Claude Opus 5.5
commit ef9deaeff7
68 changed files with 2558 additions and 0 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"
verbose_name = "Kern"
View File
@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from core.ratelimit import purge_expired
class Command(BaseCommand):
help = "Löscht abgelaufene Rate-Limit-Zähler."
def handle(self, *args, **options):
purge_expired()
+82
View File
@@ -0,0 +1,82 @@
import ipaddress
from django.conf import settings
FORWARDED_META_KEYS = ("HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_PROTO", "HTTP_X_FORWARDED_HOST", "HTTP_X_REAL_IP")
def _parse_ip(value):
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def is_trusted_proxy(ip):
return ip is not None and any(ip in net for net in settings.TRUSTED_PROXIES)
def resolve_client_ip(peer, meta):
"""Return the real client IP for a request that arrived from a trusted proxy.
With CLIENT_IP_HEADER set (e.g. CF-Connecting-IP) that header wins. Otherwise
X-Forwarded-For is walked from the right, skipping trusted proxies; the first
untrusted address is the client. Anything left of it is client-controlled.
"""
if settings.CLIENT_IP_HEADER:
key = "HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_")
ip = _parse_ip(meta.get(key, ""))
if ip is not None:
return ip
hops = [h for h in meta.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
for hop in reversed(hops):
ip = _parse_ip(hop)
if ip is None:
break
if not is_trusted_proxy(ip):
return ip
return peer
class ProxyMiddleware:
"""Honour X-Forwarded-* only when the TCP peer is a trusted proxy.
Requests from anyone else get those headers stripped, so is_secure() and
REMOTE_ADDR cannot be spoofed. request.META["REMOTE_ADDR"] holds the real
client IP afterwards; the original peer is kept in "PROXY_PEER_ADDR".
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
meta = request.META
peer = _parse_ip(meta.get("REMOTE_ADDR", ""))
if is_trusted_proxy(peer):
client = resolve_client_ip(peer, meta)
meta["PROXY_PEER_ADDR"] = str(peer)
meta["REMOTE_ADDR"] = str(client)
else:
for key in FORWARDED_META_KEYS:
meta.pop(key, None)
if settings.CLIENT_IP_HEADER:
meta.pop("HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_"), None)
return self.get_response(request)
class SecurityHeadersMiddleware:
"""Adds the headers Django does not set itself (CSP, Permissions-Policy)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response.headers.setdefault("Content-Security-Policy", settings.CONTENT_SECURITY_POLICY)
response.headers.setdefault("Permissions-Policy", settings.PERMISSIONS_POLICY)
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
user = getattr(request, "user", None)
if user is not None and user.is_authenticated:
# Pages with private content must not end up in shared caches.
response.headers.setdefault("Cache-Control", "private, no-store")
return response
+23
View File
@@ -0,0 +1,23 @@
# Generated by Django 5.2.18 on 2026-10-30 20:04
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='RateLimitBucket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255, unique=True)),
('window_start', models.DateTimeField()),
('count', models.PositiveIntegerField(default=0)),
],
),
]
View File
+12
View File
@@ -0,0 +1,12 @@
from django.db import models
class RateLimitBucket(models.Model):
"""Fixed-window counter used by core.ratelimit. One row per key."""
key = models.CharField(max_length=255, unique=True)
window_start = models.DateTimeField()
count = models.PositiveIntegerField(default=0)
def __str__(self):
return f"{self.key}: {self.count}"
+58
View File
@@ -0,0 +1,58 @@
"""Database-backed fixed-window rate limiting.
Stored in the database rather than a process-local cache so that the limit holds
across all gunicorn workers without needing Redis. A limit of 0 means unlimited.
"""
from datetime import timedelta
from django.db import IntegrityError, transaction
from django.utils import timezone
from .models import RateLimitBucket
def _expired(bucket, window, now):
return bucket.window_start + window <= now
def is_limited(key, limit, window: timedelta):
"""True if the key has already reached the limit in the current window."""
if limit <= 0:
return False
bucket = RateLimitBucket.objects.filter(key=key).first()
if bucket is None or _expired(bucket, window, timezone.now()):
return False
return bucket.count >= limit
def hit(key, limit, window: timedelta):
"""Count one event. Returns False if this event exceeds the limit."""
if limit <= 0:
return True
now = timezone.now()
for _ in range(2):
try:
with transaction.atomic():
bucket = RateLimitBucket.objects.select_for_update().filter(key=key).first()
if bucket is None:
RateLimitBucket.objects.create(key=key, window_start=now, count=1)
return True
if _expired(bucket, window, now):
bucket.window_start = now
bucket.count = 0
bucket.count += 1
bucket.save(update_fields=["window_start", "count"])
return bucket.count <= limit
except IntegrityError:
# Another worker created the row concurrently; retry with the row present.
continue
return False
def reset(key):
RateLimitBucket.objects.filter(key=key).delete()
def purge_expired(older_than: timedelta = timedelta(days=2)):
RateLimitBucket.objects.filter(window_start__lt=timezone.now() - older_than).delete()
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "core"
urlpatterns = [
path("", views.home, name="home"),
path("healthz", views.healthz, name="healthz"),
]
+30
View File
@@ -0,0 +1,30 @@
from django.contrib.auth.decorators import login_not_required
from django.db import connection
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
def home(request):
return render(request, "core/home.html")
@login_not_required
@never_cache
@require_GET
def healthz(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception:
return JsonResponse({"status": "error", "database": "unreachable"}, status=503)
return JsonResponse({"status": "ok"})
def error_403(request, exception=None):
return render(request, "errors/403.html", status=403)
def error_404(request, exception=None):
return render(request, "errors/404.html", status=404)