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,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
verbose_name = "Kern"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user