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,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
|
||||
Reference in New Issue
Block a user