commit ef9deaeff724fe882e6258cb7aaf84e6ca880008 Author: Kyle Dammann Date: Fri Oct 30 21:24:59 2026 +0100 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e15f1b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +.venv +**/__pycache__ +.pytest_cache +data +backups +staticfiles +db.sqlite3 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45c35ae --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# Kopieren nach .env und anpassen: cp .env.example .env +# Pflichtwerte sind mit [PFLICHT] markiert. + +# --- Allgemein --------------------------------------------------------------- +# [PFLICHT] Langer Zufallswert, z. B.: openssl rand -base64 48 +SECRET_KEY= +# [PFLICHT] Öffentliche Adresse, wie sie im Browser steht (für Einladungslinks, CSRF, Hosts) +APP_BASE_URL=https://kochbuch.dammann.vip +# Zusätzliche erlaubte Hostnamen (kommagetrennt), z. B. für direkten LAN-Zugriff +ALLOWED_HOSTS= +TIME_ZONE=Europe/Berlin +# Nur für lokale Entwicklung auf true setzen +DEBUG=false + +# --- Netzwerk / Reverse Proxy ------------------------------------------------ +# Port auf dem Host, auf dem die App erreichbar ist (HTTP, ohne TLS) +APP_PORT=8000 +# Adresse, an die der Port gebunden wird (127.0.0.1 = nur lokal, 0.0.0.0 = alle Interfaces) +APP_BIND_ADDRESS=0.0.0.0 +# IPs/Netze (CIDR, kommagetrennt), deren X-Forwarded-For/-Proto-Header vertraut wird. +# Standard: private Netze. Am besten auf die IP deines Nginx eingrenzen. +TRUSTED_PROXIES=127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 +# Optional: Header mit der echten Client-IP, gesetzt von einem vertrauenswürdigen Proxy, +# z. B. CF-Connecting-IP bei Cloudflare. Leer = X-Forwarded-For auswerten. +CLIENT_IP_HEADER= +# Cookies nur über HTTPS senden. Nur für Tests ohne HTTPS auf false setzen. +COOKIE_SECURE=true +# HSTS-Dauer in Sekunden (0 = aus) +HSTS_SECONDS=31536000 + +# --- Erster Admin (wird beim Start angelegt, falls die E-Mail noch nicht existiert) --- +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_NAME=Admin + +# --- Datenbank --------------------------------------------------------------- +POSTGRES_DB=kochbuch +POSTGRES_USER=kochbuch +# [PFLICHT] z. B.: openssl rand -base64 32 +POSTGRES_PASSWORD= + +# --- Server-Ressourcen --------------------------------------------------------- +GUNICORN_WORKERS=2 +GUNICORN_THREADS=4 +GUNICORN_TIMEOUT=120 + +# --- Anmelde-Schutz ------------------------------------------------------------ +# Fehlversuche pro E-Mail bzw. pro IP innerhalb des Zeitfensters (Minuten) +LOGIN_MAX_FAILURES_PER_EMAIL=5 +LOGIN_MAX_FAILURES_PER_IP=30 +LOGIN_LOCKOUT_MINUTES=15 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a14f49 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.webp binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4c5a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +/staticfiles/ +/data/ +/backups/ +db.sqlite3 +.claude/launch.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d408482 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# Kochbuch – Notes for Claude + +Self-hosted recipe web app for a homelab. Runs via docker compose behind the owner's existing +Nginx + Cloudflare (public URL: https://kochbuch.dammann.vip). The app itself serves plain HTTP on one +port; TLS, domain and proxy are out of scope. + +## Status +- [x] Phase 1: Docker setup, DB, auth with invitations, roles +- [ ] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites +- [ ] Phase 3: Comments, serving calculator, cooking mode (wake lock) +- [ ] Phase 4: AI (OpenAI): clean up manual input + photo/screenshot import +- [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target) +- [ ] Phase 6: Backup script, full README + +## Stack +Django 5.2 LTS (Python 3.12), server-rendered templates, HTMX planned for Phase 2+, PostgreSQL 17, +gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step. + +## Layout +- `config/` – settings (all env-driven, see `.env.example`), urls, wsgi. `settings_test.py` for pytest. +- `core/` – cross-cutting: `ProxyMiddleware` (trusted proxies, client IP), `SecurityHeadersMiddleware` + (CSP, Permissions-Policy, no-store for logged-in pages), DB-backed rate limiter (`core/ratelimit.py`), + `/healthz`, error pages. +- `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`, + login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`), + `ensure_admin` management command (first admin from env). +- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`. +- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn. +- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker). + +## Commands +```bash +# Local (Windows dev box: venv lives at C:\Users\kyled\.venvs\kochbuch due to path-length issues) +python -m pytest # tests (SQLite) +python scripts/dev_server.py # http://localhost:8000, admin@kochbuch.localhost +SECRET_KEY=dev python manage.py makemigrations + +# Docker +./scripts/init-env.sh admin@example.com # once +docker compose up -d --build +docker compose --profile test run --rm test # tests against Postgres +docker compose logs -f app +``` + +## Conventions +- Code, comments, commits: English. UI, README, user-facing messages: German (informal "du"). +- **Default deny:** `LoginRequiredMiddleware` is active; public views need `@login_not_required`. + Admin views use `admin_required` (403). Private content that a user may not see → 404, never 403 + (don't leak existence). +- **Visibility (Phase 2+):** every recipe query for a user goes through one central queryset method + (`visible_to(user)`), including list, detail, search, tags, images, comments. Uploaded files are never + served from a static path – only through permission-checked views. Tests must cover each access path. +- Tags are stored lowercase. +- One-time tokens (invitations, resets) are stored only as SHA-256 hashes; the link is shown once. +- Rate limits use `core.ratelimit.hit/is_limited`; limit 0 = unlimited. AI limits (Phase 4) must exist + but default to 0 (owner's request), configurable via env. +- Forwarded headers are trusted only from `TRUSTED_PROXIES`; gunicorn does not trust any itself. +- CSP is strict (`script-src 'self'`, `style-src 'self'`): no inline scripts/styles/handlers in templates. +- AI output is always an editable draft, never saved directly. `OPENAI_API_KEY` only via env. +- Keep it lean: small server, no heavy dependencies without reason. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..587eecf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN groupadd --system --gid 1000 app \ + && useradd --system --uid 1000 --gid app --home /app app + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh \ + && SECRET_KEY=build-only python manage.py collectstatic --noinput \ + && mkdir -p /data/media && chown -R app:app /data + +USER app +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4).status == 200 else 1)" +ENTRYPOINT ["/app/docker/entrypoint.sh"] + +# Test image: same app plus dev dependencies (docker compose --profile test run --rm test) +FROM base AS test +USER root +COPY requirements-dev.txt . +RUN pip install -r requirements-dev.txt +USER app +ENTRYPOINT [] +CMD ["pytest"] + +FROM base AS production diff --git a/README.md b/README.md new file mode 100644 index 0000000..dda9cfe --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Kochbuch + +Selbst gehostetes Familien-Kochbuch mit Einladungen, privaten und öffentlichen Rezepten und KI-Import. +Läuft per docker compose (App + PostgreSQL) hinter einem bestehenden Reverse Proxy. + +> Stand: Phase 1 (Konten, Einladungen, Rollen). Die vollständige Anleitung inkl. Update und Backup +> folgt in Phase 6. + +## Installation + +```bash +git clone /kochbuch.git && cd kochbuch +./scripts/init-env.sh deine@mail.de # erzeugt .env mit zufälligen Secrets und Admin-Passwort +nano .env # APP_BASE_URL, TRUSTED_PROXIES, APP_PORT prüfen +docker compose up -d --build +docker compose ps # app sollte nach ~30 s "healthy" sein +``` + +Danach mit der Admin-E-Mail und dem ausgegebenen Passwort anmelden. `ADMIN_PASSWORD` kann nach dem ersten +Start aus der `.env` entfernt werden – ein bestehendes Konto wird nie überschrieben. + +Weitere Personen lädst du unter **Menü → Einladungen** ein. Der Link wird nur einmal angezeigt. +Passwort vergessen? **Menü → Nutzer verwalten → Aktionen**: Reset-Link oder temporäres Passwort. + +## Reverse Proxy (Nginx + Cloudflare) + +Die App spricht nur HTTP auf `APP_PORT`. Beispiel für Nginx: + +```nginx +server { + server_name kochbuch.dammann.vip; + # listen/ssl wie bei deinen anderen Diensten + + client_max_body_size 30m; # Foto-Uploads + + location / { + proxy_pass http://:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 120s; # KI-Aufrufe können dauern + } +} +``` + +**Client-IP:** Die App wertet `X-Forwarded-For` nur aus, wenn die Anfrage von einer IP aus +`TRUSTED_PROXIES` kommt. Das ist wichtig für das Login-Rate-Limit. Mit Cloudflare gibt es zwei Wege: + +- Nginx ermittelt die echte IP selbst (`set_real_ip_from ; real_ip_header CF-Connecting-IP;`). + Dann reicht `TRUSTED_PROXIES=`. +- Oder in der `.env` `CLIENT_IP_HEADER=CF-Connecting-IP` setzen. Nur sinnvoll, wenn dein Nginx ausschließlich + über Cloudflare erreichbar ist, sonst kann der Header gefälscht werden. + +## Konfiguration + +Alle Variablen stehen kommentiert in [`.env.example`](.env.example). + +## Entwicklung + +```bash +python -m venv .venv && . .venv/bin/activate +pip install -r requirements-dev.txt +python -m pytest # Tests (SQLite) +python scripts/dev_server.py # http://localhost:8000 +docker compose --profile test run --rm test # Tests gegen PostgreSQL +``` diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin_views.py b/accounts/admin_views.py new file mode 100644 index 0000000..fffc970 --- /dev/null +++ b/accounts/admin_views.py @@ -0,0 +1,137 @@ +"""User and invitation management for admins (/verwaltung/).""" + +import secrets +from functools import wraps + +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_POST + +from .forms import InvitationForm +from .models import Invitation, PasswordResetToken, User + + +def admin_required(view): + @wraps(view) + def wrapper(request, *args, **kwargs): + if not request.user.is_admin: + raise PermissionDenied + return view(request, *args, **kwargs) + + return wrapper + + +def _show_once(request, title, value, hint): + """Render a secret (link or temporary password) that is not stored in plain text.""" + return render(request, "accounts/admin/show_once.html", {"title": title, "value": value, "hint": hint}) + + +@admin_required +@never_cache +def user_list(request): + users = User.objects.order_by("-is_active", "display_name") + return render(request, "accounts/admin/users.html", {"users": users}) + + +USER_ACTIONS = {"make_admin", "make_user", "deactivate", "activate", "reset_link", "temp_password"} + + +@admin_required +@require_POST +@never_cache +def user_action(request, pk, action): + if action not in USER_ACTIONS: + raise PermissionDenied + with transaction.atomic(): + target = get_object_or_404(User.objects.select_for_update(), pk=pk) + + if action in {"make_user", "deactivate"}: + if target == request.user: + messages.error(request, "Du kannst dir selbst die Admin-Rechte nicht entziehen oder dich deaktivieren.") + return redirect("accounts:admin_users") + other_admins = User.objects.filter(role=User.Role.ADMIN, is_active=True).exclude(pk=target.pk) + if target.is_admin and not other_admins.exists(): + messages.error(request, "Es muss mindestens einen aktiven Admin geben.") + return redirect("accounts:admin_users") + + if action == "make_admin": + target.role = User.Role.ADMIN + target.save(update_fields=["role"]) + messages.success(request, f"{target} ist jetzt Admin.") + elif action == "make_user": + target.role = User.Role.USER + target.save(update_fields=["role"]) + messages.success(request, f"{target} ist jetzt Nutzer.") + elif action == "deactivate": + target.is_active = False + target.save(update_fields=["is_active"]) + PasswordResetToken.objects.usable().filter(user=target).update(revoked_at=timezone.now()) + messages.success(request, f"{target} wurde deaktiviert. Die Rezepte bleiben erhalten.") + elif action == "activate": + target.is_active = True + target.save(update_fields=["is_active"]) + messages.success(request, f"{target} wurde wieder aktiviert.") + elif action == "reset_link": + if not target.is_active: + messages.error(request, "Deaktivierte Nutzer können ihr Passwort nicht zurücksetzen.") + return redirect("accounts:admin_users") + _, link = PasswordResetToken.issue(target, created_by=request.user) + return _show_once( + request, + f"Link zum Zurücksetzen für {target}", + link, + "Der Link ist 24 Stunden gültig und nur einmal verwendbar. Ältere Links für diesen Nutzer sind ungültig.", + ) + elif action == "temp_password": + if not target.is_active: + messages.error(request, "Deaktivierte Nutzer können sich nicht anmelden.") + return redirect("accounts:admin_users") + password = secrets.token_urlsafe(12) + target.set_password(password) + target.must_change_password = True + target.save(update_fields=["password", "must_change_password"]) + return _show_once( + request, + f"Temporäres Passwort für {target}", + password, + "Nach der Anmeldung muss sofort ein eigenes Passwort gesetzt werden. Bestehende Sitzungen wurden beendet.", + ) + return redirect("accounts:admin_users") + + +@admin_required +@never_cache +def invitation_list(request): + form = InvitationForm(request.POST or None) + if request.method == "POST" and form.is_valid(): + _, link = Invitation.issue( + created_by=request.user, + email=form.cleaned_data["email"], + role=form.cleaned_data["role"], + note=form.cleaned_data["note"], + valid_days=form.cleaned_data["valid_days"], + ) + return _show_once( + request, + "Neue Einladung", + link, + f"Schicke diesen Link an die eingeladene Person. Er ist {form.cleaned_data['valid_days']} Tage gültig " + "und nur einmal verwendbar. Aus Sicherheitsgründen wird er nicht gespeichert und später nicht mehr angezeigt.", + ) + invitations = Invitation.objects.select_related("created_by", "used_by")[:100] + return render(request, "accounts/admin/invitations.html", {"form": form, "invitations": invitations}) + + +@admin_required +@require_POST +def invitation_revoke(request, pk): + invitation = get_object_or_404(Invitation, pk=pk) + if invitation.status == "open": + invitation.revoked_at = timezone.now() + invitation.save(update_fields=["revoked_at"]) + messages.success(request, "Einladung widerrufen.") + return redirect("accounts:admin_invitations") diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..38af316 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "accounts" + verbose_name = "Konten" diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..1ea3f2a --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,107 @@ +from django import forms +from django.contrib.auth import password_validation + +from .models import Invitation, User, normalize_email + + +class LoginForm(forms.Form): + email = forms.EmailField(label="E-Mail", widget=forms.EmailInput(attrs={"autocomplete": "email", "autofocus": True})) + password = forms.CharField(label="Passwort", strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "current-password"})) + + def clean_email(self): + return normalize_email(self.cleaned_data["email"]) + + +class SetPasswordMixin(forms.Form): + """Two password fields validated with Django's password validators.""" + + password1 = forms.CharField( + label="Neues Passwort", + strip=False, + widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), + help_text="Mindestens 10 Zeichen, nicht nur Ziffern, kein gängiges Passwort.", + ) + password2 = forms.CharField( + label="Passwort wiederholen", strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}) + ) + + def password_user(self): + """User instance the new password is checked against (similarity validator).""" + return None + + def clean(self): + cleaned = super().clean() + p1, p2 = cleaned.get("password1"), cleaned.get("password2") + if p1 and p2 and p1 != p2: + self.add_error("password2", "Die Passwörter stimmen nicht überein.") + elif p1: + try: + password_validation.validate_password(p1, self.password_user()) + except forms.ValidationError as error: + self.add_error("password1", error) + return cleaned + + +class RegistrationForm(SetPasswordMixin): + email = forms.EmailField(label="E-Mail", widget=forms.EmailInput(attrs={"autocomplete": "email"})) + display_name = forms.CharField(label="Anzeigename", max_length=80, widget=forms.TextInput(attrs={"autocomplete": "nickname"})) + + field_order = ["email", "display_name", "password1", "password2"] + + def __init__(self, *args, invitation: Invitation, **kwargs): + super().__init__(*args, **kwargs) + self.invitation = invitation + if invitation.email: + self.fields["email"].initial = invitation.email + self.fields["email"].disabled = True + + def clean_email(self): + email = normalize_email(self.invitation.email or self.cleaned_data["email"]) + if User.objects.filter(email=email).exists(): + raise forms.ValidationError("Für diese E-Mail-Adresse gibt es bereits ein Konto.") + return email + + def password_user(self): + return User(email=self.cleaned_data.get("email", ""), display_name=self.cleaned_data.get("display_name", "")) + + +class UserPasswordForm(SetPasswordMixin): + def __init__(self, *args, user, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def password_user(self): + return self.user + + +class PasswordChangeForm(UserPasswordForm): + old_password = forms.CharField( + label="Aktuelles Passwort", strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}) + ) + field_order = ["old_password", "password1", "password2"] + + def clean_old_password(self): + password = self.cleaned_data["old_password"] + if not self.user.check_password(password): + raise forms.ValidationError("Das aktuelle Passwort ist falsch.") + return password + + +class ProfileForm(forms.ModelForm): + class Meta: + model = User + fields = ["display_name"] + + +class InvitationForm(forms.ModelForm): + valid_days = forms.IntegerField(label="Gültig (Tage)", min_value=1, max_value=60, initial=7) + + class Meta: + model = Invitation + fields = ["email", "role", "note"] + + def clean_email(self): + email = normalize_email(self.cleaned_data["email"]) + if email and User.objects.filter(email=email).exists(): + raise forms.ValidationError("Für diese E-Mail-Adresse gibt es bereits ein Konto.") + return email diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/ensure_admin.py b/accounts/management/commands/ensure_admin.py new file mode 100644 index 0000000..99794eb --- /dev/null +++ b/accounts/management/commands/ensure_admin.py @@ -0,0 +1,35 @@ +import os + +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError + +from accounts.models import User, normalize_email + + +class Command(BaseCommand): + help = ( + "Legt den ersten Admin aus ADMIN_EMAIL/ADMIN_PASSWORD an, falls diese E-Mail noch kein Konto hat. " + "Bestehende Konten werden nie verändert." + ) + + def handle(self, *args, **options): + email = normalize_email(os.environ.get("ADMIN_EMAIL", "")) + password = os.environ.get("ADMIN_PASSWORD", "") + name = os.environ.get("ADMIN_NAME", "").strip() or "Admin" + + if not email: + if not User.objects.filter(role=User.Role.ADMIN).exists(): + self.stderr.write("Warnung: Es gibt noch keinen Admin. Setze ADMIN_EMAIL und ADMIN_PASSWORD in .env.") + return + if User.objects.filter(email=email).exists(): + return + if not password: + raise CommandError("ADMIN_EMAIL ist gesetzt, aber ADMIN_PASSWORD fehlt.") + try: + validate_password(password) + except ValidationError as error: + raise CommandError("ADMIN_PASSWORD ist zu schwach: " + " ".join(error.messages)) from error + + User.objects.create_admin(email=email, password=password, display_name=name) + self.stdout.write(f"Admin {email} angelegt. ADMIN_PASSWORD kann jetzt aus .env entfernt werden.") diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..e0f28b9 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,17 @@ +from django.shortcuts import redirect +from django.urls import reverse + + +class ForcePasswordChangeMiddleware: + """After an admin set a temporary password, the user must choose a new one first.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if user.is_authenticated and user.must_change_password: + allowed = {reverse("accounts:password_change"), reverse("accounts:logout"), reverse("core:healthz")} + if request.path not in allowed: + return redirect("accounts:password_change") + return self.get_response(request) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..bcb3958 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.18 on 2026-10-30 20:07 + +import django.db.models.deletion +import django.db.models.functions.text +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='E-Mail')), + ('display_name', models.CharField(max_length=80, verbose_name='Anzeigename')), + ('role', models.CharField(choices=[('user', 'Nutzer'), ('admin', 'Admin')], default='user', max_length=10, verbose_name='Rolle')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktiv')), + ('must_change_password', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Registriert am')), + ], + options={ + 'verbose_name': 'Nutzer', + 'verbose_name_plural': 'Nutzer', + 'ordering': ['display_name'], + 'constraints': [models.UniqueConstraint(django.db.models.functions.text.Lower('email'), name='user_email_ci_unique')], + }, + ), + migrations.CreateModel( + name='Invitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(editable=False, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('email', models.EmailField(blank=True, help_text='Wenn gesetzt, kann nur diese Adresse sich registrieren.', max_length=254, verbose_name='E-Mail (optional)')), + ('role', models.CharField(choices=[('user', 'Nutzer'), ('admin', 'Admin')], default='user', max_length=10, verbose_name='Rolle')), + ('note', models.CharField(blank=True, help_text='Nur für dich, z. B. für wen die Einladung ist.', max_length=120, verbose_name='Notiz')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('used_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Einladung', + 'verbose_name_plural': 'Einladungen', + 'ordering': ['-created_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PasswordResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(editable=False, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Passwort-Reset', + 'verbose_name_plural': 'Passwort-Resets', + 'ordering': ['-created_at'], + 'abstract': False, + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..f4b6f4c --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,156 @@ +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager +from django.db import models +from django.db.models.functions import Lower +from django.urls import reverse +from django.utils import timezone + +from .tokens import hash_token, new_token + + +def normalize_email(email): + return (email or "").strip().lower() + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None, display_name="", role="user", **extra): + email = normalize_email(email) + if not email: + raise ValueError("E-Mail-Adresse fehlt.") + user = self.model(email=email, display_name=display_name or email.split("@")[0], role=role, **extra) + user.set_password(password) + user.save(using=self._db) + return user + + def create_admin(self, email, password=None, display_name="", **extra): + return self.create_user(email, password, display_name, role=User.Role.ADMIN, **extra) + + def get_by_natural_key(self, email): + return self.get(email=normalize_email(email)) + + +class User(AbstractBaseUser): + class Role(models.TextChoices): + USER = "user", "Nutzer" + ADMIN = "admin", "Admin" + + email = models.EmailField("E-Mail", unique=True) + display_name = models.CharField("Anzeigename", max_length=80) + role = models.CharField("Rolle", max_length=10, choices=Role.choices, default=Role.USER) + is_active = models.BooleanField("Aktiv", default=True) + must_change_password = models.BooleanField(default=False) + date_joined = models.DateTimeField("Registriert am", default=timezone.now) + + objects = UserManager() + + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = ["display_name"] + + class Meta: + verbose_name = "Nutzer" + verbose_name_plural = "Nutzer" + ordering = ["display_name"] + constraints = [models.UniqueConstraint(Lower("email"), name="user_email_ci_unique")] + + def __str__(self): + return self.display_name + + def save(self, *args, **kwargs): + self.email = normalize_email(self.email) + super().save(*args, **kwargs) + + @property + def is_admin(self): + return self.is_active and self.role == self.Role.ADMIN + + +class TokenQuerySet(models.QuerySet): + def usable(self): + return self.filter(used_at__isnull=True, revoked_at__isnull=True, expires_at__gt=timezone.now()) + + def get_usable(self, raw_token): + """Return the usable token object for a raw token, or None.""" + if not raw_token: + return None + return self.usable().filter(token_hash=hash_token(raw_token)).first() + + +class OneTimeToken(models.Model): + token_hash = models.CharField(max_length=64, unique=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + + objects = TokenQuerySet.as_manager() + + class Meta: + abstract = True + ordering = ["-created_at"] + + @property + def status(self): + if self.used_at: + return "used" + if self.revoked_at: + return "revoked" + if self.expires_at <= timezone.now(): + return "expired" + return "open" + + @property + def status_label(self): + return {"used": "verwendet", "revoked": "widerrufen", "expired": "abgelaufen", "open": "offen"}[self.status] + + +class Invitation(OneTimeToken): + email = models.EmailField("E-Mail (optional)", blank=True, help_text="Wenn gesetzt, kann nur diese Adresse sich registrieren.") + role = models.CharField("Rolle", max_length=10, choices=User.Role.choices, default=User.Role.USER) + note = models.CharField("Notiz", max_length=120, blank=True, help_text="Nur für dich, z. B. für wen die Einladung ist.") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+") + used_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + + class Meta(OneTimeToken.Meta): + verbose_name = "Einladung" + verbose_name_plural = "Einladungen" + + @classmethod + def issue(cls, created_by, email="", role=User.Role.USER, note="", valid_days=None): + """Create an invitation and return (invitation, absolute_link). The link is not stored.""" + raw, token_hash = new_token() + days = valid_days or settings.INVITATION_VALID_DAYS + invitation = cls.objects.create( + token_hash=token_hash, + email=normalize_email(email), + role=role, + note=note, + created_by=created_by, + expires_at=timezone.now() + timedelta(days=days), + ) + return invitation, settings.APP_BASE_URL + reverse("accounts:register", args=[raw]) + + +class PasswordResetToken(OneTimeToken): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reset_tokens") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+") + + class Meta(OneTimeToken.Meta): + verbose_name = "Passwort-Reset" + verbose_name_plural = "Passwort-Resets" + + @classmethod + def issue(cls, user, created_by): + """Create a reset link for a user. Earlier open links for that user are revoked.""" + now = timezone.now() + cls.objects.usable().filter(user=user).update(revoked_at=now) + raw, token_hash = new_token() + token = cls.objects.create( + token_hash=token_hash, + user=user, + created_by=created_by, + expires_at=now + timedelta(hours=settings.PASSWORD_RESET_VALID_HOURS), + ) + return token, settings.APP_BASE_URL + reverse("accounts:password_reset", args=[raw]) diff --git a/accounts/tokens.py b/accounts/tokens.py new file mode 100644 index 0000000..8080eb2 --- /dev/null +++ b/accounts/tokens.py @@ -0,0 +1,18 @@ +"""One-time tokens for invitation and password-reset links. + +Only a SHA-256 hash is stored, so a database leak does not expose usable links. +The raw token exists only in the link shown to the admin once. +""" + +import hashlib +import secrets + + +def new_token(): + """Return (raw_token, token_hash).""" + raw = secrets.token_urlsafe(32) + return raw, hash_token(raw) + + +def hash_token(raw): + return hashlib.sha256(raw.encode()).hexdigest() diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..b00616a --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import admin_views, views + +app_name = "accounts" + +urlpatterns = [ + path("anmelden/", views.login_view, name="login"), + path("abmelden/", views.logout_view, name="logout"), + path("einladung//", views.register, name="register"), + path("passwort-zuruecksetzen//", views.password_reset, name="password_reset"), + path("konto/", views.profile, name="profile"), + path("konto/passwort/", views.password_change, name="password_change"), + path("verwaltung/nutzer/", admin_views.user_list, name="admin_users"), + path("verwaltung/nutzer///", admin_views.user_action, name="admin_user_action"), + path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"), + path("verwaltung/einladungen//widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..cec3072 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,156 @@ +from datetime import timedelta + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import authenticate, login, logout, update_session_auth_hash +from django.contrib.auth.decorators import login_not_required +from django.db import transaction +from django.shortcuts import redirect, render +from django.utils import timezone +from django.utils.http import url_has_allowed_host_and_scheme +from django.views.decorators.cache import never_cache +from django.views.decorators.debug import sensitive_post_parameters +from django.views.decorators.http import require_POST + +from core import ratelimit + +from .forms import LoginForm, PasswordChangeForm, ProfileForm, RegistrationForm, UserPasswordForm +from .models import Invitation, PasswordResetToken, User + + +def _login_limits(request, email): + ip = request.META.get("REMOTE_ADDR", "unknown") + return [ + (f"login:ip:{ip}", settings.LOGIN_MAX_FAILURES_PER_IP), + (f"login:email:{email}", settings.LOGIN_MAX_FAILURES_PER_EMAIL), + ] + + +def _safe_next(request): + target = request.POST.get("next") or request.GET.get("next") or "" + if url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()): + return target + return settings.LOGIN_REDIRECT_URL + + +@login_not_required +@sensitive_post_parameters("password") +@never_cache +def login_view(request): + if request.user.is_authenticated: + return redirect("core:home") + form = LoginForm(request.POST or None) + status = 200 + if request.method == "POST" and form.is_valid(): + email = form.cleaned_data["email"] + window = timedelta(minutes=settings.LOGIN_LOCKOUT_MINUTES) + limits = _login_limits(request, email) + if any(ratelimit.is_limited(key, limit, window) for key, limit in limits): + form.add_error( + None, + f"Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuche es in " + f"{settings.LOGIN_LOCKOUT_MINUTES} Minuten erneut.", + ) + status = 429 + else: + user = authenticate(request, email=email, password=form.cleaned_data["password"]) + if user is not None: + ratelimit.reset(limits[1][0]) + login(request, user) + return redirect(_safe_next(request)) + for key, limit in limits: + ratelimit.hit(key, limit, window) + # Same message for unknown e-mail, wrong password and deactivated accounts. + form.add_error(None, "E-Mail oder Passwort ist falsch.") + status = 400 + return render(request, "accounts/login.html", {"form": form, "next": request.GET.get("next", "")}, status=status) + + +@require_POST +def logout_view(request): + logout(request) + return redirect(settings.LOGOUT_REDIRECT_URL) + + +@login_not_required +@sensitive_post_parameters("password1", "password2") +@never_cache +def register(request, token): + invitation = Invitation.objects.get_usable(token) + if invitation is None: + return render(request, "accounts/link_invalid.html", {"kind": "Einladungslink"}, status=404) + if request.user.is_authenticated: + return render(request, "accounts/already_logged_in.html", status=409) + + form = RegistrationForm(request.POST or None, invitation=invitation) + if request.method == "POST" and form.is_valid(): + with transaction.atomic(): + # Re-check under a row lock so one link cannot create two accounts. + invitation = Invitation.objects.select_for_update().usable().filter(pk=invitation.pk).first() + if invitation is None: + return render(request, "accounts/link_invalid.html", {"kind": "Einladungslink"}, status=404) + user = User.objects.create_user( + email=form.cleaned_data["email"], + password=form.cleaned_data["password1"], + display_name=form.cleaned_data["display_name"], + role=invitation.role, + ) + invitation.used_at = timezone.now() + invitation.used_by = user + invitation.save(update_fields=["used_at", "used_by"]) + login(request, user, backend="django.contrib.auth.backends.ModelBackend") + messages.success(request, "Willkommen im Kochbuch!") + return redirect("core:home") + return render(request, "accounts/register.html", {"form": form, "invitation": invitation}) + + +@login_not_required +@sensitive_post_parameters("password1", "password2") +@never_cache +def password_reset(request, token): + reset = PasswordResetToken.objects.get_usable(token) + if reset is None or not reset.user.is_active: + return render(request, "accounts/link_invalid.html", {"kind": "Link zum Zurücksetzen"}, status=404) + + form = UserPasswordForm(request.POST or None, user=reset.user) + if request.method == "POST" and form.is_valid(): + with transaction.atomic(): + reset = PasswordResetToken.objects.select_for_update().usable().filter(pk=reset.pk).first() + if reset is None: + return render(request, "accounts/link_invalid.html", {"kind": "Link zum Zurücksetzen"}, status=404) + user = reset.user + user.set_password(form.cleaned_data["password1"]) + user.must_change_password = False + user.save(update_fields=["password", "must_change_password"]) + reset.used_at = timezone.now() + reset.save(update_fields=["used_at"]) + login(request, user, backend="django.contrib.auth.backends.ModelBackend") + messages.success(request, "Dein Passwort wurde geändert.") + return redirect("core:home") + return render(request, "accounts/password_reset.html", {"form": form, "reset_user": reset.user}) + + +@never_cache +def profile(request): + form = ProfileForm(request.POST or None, instance=request.user) + if request.method == "POST" and form.is_valid(): + form.save() + messages.success(request, "Profil gespeichert.") + return redirect("accounts:profile") + return render(request, "accounts/profile.html", {"form": form}) + + +@sensitive_post_parameters("old_password", "password1", "password2") +@never_cache +def password_change(request): + user = request.user + form_class = UserPasswordForm if user.must_change_password else PasswordChangeForm + form = form_class(request.POST or None, user=user) + if request.method == "POST" and form.is_valid(): + user.set_password(form.cleaned_data["password1"]) + user.must_change_password = False + user.save(update_fields=["password", "must_change_password"]) + update_session_auth_hash(request, user) + messages.success(request, "Dein Passwort wurde geändert.") + return redirect("core:home") + return render(request, "accounts/password_change.html", {"form": form, "forced": user.must_change_password}) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..a3bc931 --- /dev/null +++ b/config/settings.py @@ -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"}}, +} diff --git a/config/settings_test.py b/config/settings_test.py new file mode 100644 index 0000000..f9db873 --- /dev/null +++ b/config/settings_test.py @@ -0,0 +1,17 @@ +"""Test settings: same as production, with defaults for required variables.""" + +import os + +os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production") +os.environ.setdefault("APP_BASE_URL", "https://kochbuch.example.test") + +from .settings import * # noqa: E402,F403 + +STORAGES = { + **STORAGES, # noqa: F405 + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, +} + +# Static files are served by the finders in tests; no collectstatic needed. +WHITENOISE_USE_FINDERS = True +WHITENOISE_AUTOREFRESH = True diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..149fb24 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path + +urlpatterns = [ + path("", include("core.urls")), + path("", include("accounts.urls")), +] + +handler403 = "core.views.error_403" +handler404 = "core.views.error_404" diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..497c74e --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "core" + verbose_name = "Kern" diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/purge_ratelimits.py b/core/management/commands/purge_ratelimits.py new file mode 100644 index 0000000..653f2a9 --- /dev/null +++ b/core/management/commands/purge_ratelimits.py @@ -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() diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..5aa301a --- /dev/null +++ b/core/middleware.py @@ -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 diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..59edf03 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..4b002da --- /dev/null +++ b/core/models.py @@ -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}" diff --git a/core/ratelimit.py b/core/ratelimit.py new file mode 100644 index 0000000..1d7438d --- /dev/null +++ b/core/ratelimit.py @@ -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() diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..49efc8c --- /dev/null +++ b/core/urls.py @@ -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"), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..c8abdd2 --- /dev/null +++ b/core/views.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..172f1d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +name: kochbuch + +services: + app: + build: + context: . + target: production + image: kochbuch:latest + restart: unless-stopped + env_file: .env + environment: + POSTGRES_HOST: db + ports: + - "${APP_BIND_ADDRESS:-0.0.0.0}:${APP_PORT:-8000}:8000" + volumes: + - media:/data/media + depends_on: + db: + condition: service_healthy + mem_limit: 512m + + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-kochbuch} + POSTGRES_USER: ${POSTGRES_USER:-kochbuch} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + mem_limit: 256m + + # Runs the test suite against Postgres: docker compose --profile test run --rm test + test: + profiles: ["test"] + build: + context: . + target: test + env_file: .env + environment: + POSTGRES_HOST: db + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: + media: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..8a87b2e --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +python manage.py migrate --noinput +python manage.py ensure_admin +python manage.py purge_ratelimits + +exec gunicorn config.wsgi:application \ + --bind 0.0.0.0:8000 \ + --workers "${GUNICORN_WORKERS:-2}" \ + --threads "${GUNICORN_THREADS:-4}" \ + --timeout "${GUNICORN_TIMEOUT:-120}" \ + --access-logfile - +# Forwarded headers are evaluated by core.middleware.ProxyMiddleware (TRUSTED_PROXIES), +# not by gunicorn, so gunicorn keeps its default of trusting none of them from remote peers. diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..f90c4b8 --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a241d06 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "kochbuch" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings_test" +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ce11a98 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.2.0 +pytest-django==4.14.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9e9c56 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Django==5.2.18 +psycopg[binary]==3.3.6 +argon2-cffi==25.1.0 +gunicorn==26.2.0 +whitenoise==6.12.0 diff --git a/scripts/dev_server.py b/scripts/dev_server.py new file mode 100644 index 0000000..c4d5435 --- /dev/null +++ b/scripts/dev_server.py @@ -0,0 +1,24 @@ +"""Local development server without Docker (SQLite, plain HTTP). Not for production.""" + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +os.chdir(ROOT) + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +os.environ.setdefault("DEBUG", "true") +os.environ.setdefault("SECRET_KEY", "dev-only-secret-key") +os.environ.setdefault("APP_BASE_URL", "http://localhost:8000") +os.environ.setdefault("COOKIE_SECURE", "false") +os.environ.setdefault("ADMIN_EMAIL", "admin@kochbuch.localhost") +os.environ.setdefault("ADMIN_PASSWORD", "Dev-Admin-Passwort-1") +os.environ.setdefault("ADMIN_NAME", "Dev-Admin") + +from django.core.management import execute_from_command_line # noqa: E402 + +execute_from_command_line(["manage.py", "migrate", "--noinput"]) +execute_from_command_line(["manage.py", "ensure_admin"]) +execute_from_command_line(["manage.py", "runserver", "127.0.0.1:8000", "--noreload"]) diff --git a/scripts/init-env.sh b/scripts/init-env.sh new file mode 100755 index 0000000..abd9794 --- /dev/null +++ b/scripts/init-env.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Creates .env from .env.example with freshly generated secrets. +# Usage: ./scripts/init-env.sh [admin-email] +set -e +cd "$(dirname "$0")/.." + +if [ -f .env ]; then + echo ".env existiert bereits – nichts geändert." >&2 + exit 1 +fi + +rand() { head -c "$1" /dev/urandom | base64 | tr -d '\n/+=' ; } + +secret_key=$(rand 48) +db_password=$(rand 32) +admin_password=$(rand 18) +admin_email=${1:-} + +sed -e "s|^SECRET_KEY=.*|SECRET_KEY=${secret_key}|" \ + -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \ + -e "s|^ADMIN_EMAIL=.*|ADMIN_EMAIL=${admin_email}|" \ + -e "s|^ADMIN_PASSWORD=.*|ADMIN_PASSWORD=${admin_password}|" \ + .env.example > .env +chmod 600 .env + +echo ".env angelegt. Bitte APP_BASE_URL prüfen." +if [ -n "$admin_email" ]; then + echo "Admin: ${admin_email} / Passwort: ${admin_password}" + echo "Nach dem ersten Start kann ADMIN_PASSWORD aus .env entfernt werden." +else + echo "Hinweis: ADMIN_EMAIL ist leer – trage sie in .env ein, bevor du startest." +fi diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..a509b39 --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,185 @@ +/* Mobile-first base styles. No build step, no framework. */ +:root { + --bg: #faf7f2; + --surface: #ffffff; + --text: #2a2420; + --muted: #74675d; + --border: #e6ddd2; + --accent: #b4532a; + --accent-text: #ffffff; + --danger: #b3261e; + --ok-bg: #e7f3e8; + --ok-text: #1f5130; + --warn-bg: #fdf0d9; + --warn-text: #6b4a10; + --err-bg: #fbe4e2; + --err-text: #7d1b15; + --radius: 12px; + --tap: 44px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1b1816; + --surface: #26221f; + --text: #efe8e1; + --muted: #a99c90; + --border: #3a342f; + --accent: #e07a4b; + --accent-text: #1b1816; + --danger: #f08a80; + --ok-bg: #1f3325; + --ok-text: #b9e3c3; + --warn-bg: #3a2f17; + --warn-text: #f3d9a2; + --err-bg: #3d1f1c; + --err-text: #f6c2bc; + } +} + +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 17px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +a { color: var(--accent); } +h1 { font-size: 1.6rem; margin: 0.5rem 0 1rem; line-height: 1.2; } +h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; } +.card h2:first-child { margin-top: 0; } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + padding-top: max(0.5rem, env(safe-area-inset-top)); + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.brand { font-weight: 700; font-size: 1.2rem; text-decoration: none; color: var(--text); } + +.menu { position: relative; } +.menu summary { + list-style: none; + cursor: pointer; + min-width: var(--tap); + min-height: var(--tap); + display: grid; + place-items: center; + font-size: 1.4rem; +} +.menu summary::-webkit-details-marker { display: none; } +.menu nav { + position: absolute; + right: 0; + top: calc(100% + 4px); + min-width: 220px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); + padding: 0.25rem 0; +} +.menu nav a, .menu nav .linklike { + display: block; + width: 100%; + padding: 0.75rem 1rem; + text-align: left; + color: var(--text); + text-decoration: none; +} +.menu nav form { margin: 0; } + +.container { + max-width: 760px; + margin: 0 auto; + padding: 1rem; + padding-bottom: max(2rem, env(safe-area-inset-bottom)); +} +.narrow { max-width: 440px; margin: 0 auto; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; +} +.card.inactive { opacity: 0.65; } + +.muted { color: var(--muted); font-size: 0.92rem; } + +.field { margin-bottom: 1rem; } +.field label, .card > label { display: block; font-weight: 600; margin-bottom: 0.3rem; } +input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="url"], +select, textarea { + width: 100%; + min-height: var(--tap); + padding: 0.6rem 0.75rem; + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; +} +input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.has-error input, .has-error select, .has-error textarea { border-color: var(--danger); } +.help { display: block; color: var(--muted); margin-top: 0.25rem; } +.error { display: block; color: var(--danger); margin-top: 0.25rem; } + +.button, button { + font: inherit; + cursor: pointer; +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--tap); + padding: 0.5rem 1.1rem; + border: 0; + border-radius: 999px; + background: var(--accent); + color: var(--accent-text); + font-weight: 600; + text-decoration: none; +} +.button.wide { width: 100%; } +.button.secondary { background: transparent; color: var(--accent); border: 1px solid var(--accent); } +.button.danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); } +.button.small { min-height: 36px; padding: 0.3rem 0.9rem; font-size: 0.9rem; margin-top: 0.5rem; } +.linklike { background: none; border: 0; padding: 0; color: var(--accent); } + +.messages { list-style: none; padding: 0; margin: 0 0 1rem; } +.msg { padding: 0.75rem 1rem; border-radius: var(--radius); margin-bottom: 0.5rem; } +.msg-success, .msg-info { background: var(--ok-bg); color: var(--ok-text); } +.msg-warning { background: var(--warn-bg); color: var(--warn-text); } +.msg-error { background: var(--err-bg); color: var(--err-text); } + +.list { list-style: none; padding: 0; margin: 0; } +.row-head { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; } +.badge { + font-size: 0.78rem; + padding: 0.1rem 0.55rem; + border-radius: 999px; + background: var(--accent); + color: var(--accent-text); +} +.badge-muted { background: var(--border); color: var(--text); } + +.actions { margin-top: 0.5rem; } +.actions summary { cursor: pointer; color: var(--accent); min-height: var(--tap); display: flex; align-items: center; } +.action-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.action-buttons form { margin: 0; } + +.copyrow { display: flex; gap: 0.5rem; margin: 0.5rem 0; } +.copyrow input { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 0.9rem; } diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..1788dc3 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,14 @@ +// Small progressive enhancements. Everything works without JavaScript. +document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy]"); + if (!button) return; + const input = document.querySelector(button.dataset.copy); + if (!input) return; + input.select(); + try { + await navigator.clipboard.writeText(input.value); + button.textContent = "Kopiert ✓"; + } catch { + button.textContent = "Bitte manuell kopieren"; + } +}); diff --git a/templates/accounts/admin/invitations.html b/templates/accounts/admin/invitations.html new file mode 100644 index 0000000..8a27e0a --- /dev/null +++ b/templates/accounts/admin/invitations.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Einladungen{% endblock %} +{% block content %} +

Einladungen

+
+ {% csrf_token %} +

Neue Einladung

+ {% include "form_fields.html" %} + +
+ +

Bisherige Einladungen

+{% if invitations %} +
    + {% for inv in invitations %} +
  • +
    + {{ inv.note|default:inv.email|default:"Einladung" }} + {{ inv.status_label }} + {{ inv.get_role_display }} +
    +
    + {% if inv.email %}{{ inv.email }} · {% endif %}erstellt {{ inv.created_at|date:"d.m.Y" }} + {% if inv.used_by %} · verwendet von {{ inv.used_by.display_name }}{% else %} · gültig bis {{ inv.expires_at|date:"d.m.Y H:i" }}{% endif %} +
    + {% if inv.status == "open" %} +
    {% csrf_token %}
    + {% endif %} +
  • + {% endfor %} +
+{% else %} +

Noch keine Einladungen.

+{% endif %} +{% endblock %} diff --git a/templates/accounts/admin/show_once.html b/templates/accounts/admin/show_once.html new file mode 100644 index 0000000..beac09c --- /dev/null +++ b/templates/accounts/admin/show_once.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +
+

{{ title }}

+
+ +
+ + +
+

{{ hint }}

+
+

Zurück zur Nutzerverwaltung · Einladungen

+
+{% endblock %} diff --git a/templates/accounts/admin/users.html b/templates/accounts/admin/users.html new file mode 100644 index 0000000..f996d3b --- /dev/null +++ b/templates/accounts/admin/users.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Nutzer verwalten{% endblock %} +{% block content %} +

Nutzer

+

Neue Einladung

+
    + {% for u in users %} +
  • +
    + {{ u.display_name }} + {{ u.get_role_display }} + {% if not u.is_active %}deaktiviert{% endif %} + {% if u.must_change_password %}temp. Passwort{% endif %} +
    +
    {{ u.email }} · seit {{ u.date_joined|date:"d.m.Y" }}
    +
    + Aktionen +
    + {% if u.is_active %} +
    {% csrf_token %}
    +
    {% csrf_token %}
    + {% if u != user %} + {% if u.role == "admin" %} +
    {% csrf_token %}
    + {% else %} +
    {% csrf_token %}
    + {% endif %} +
    {% csrf_token %}
    + {% endif %} + {% else %} +
    {% csrf_token %}
    + {% endif %} +
    +
    +
  • + {% endfor %} +
+{% endblock %} diff --git a/templates/accounts/already_logged_in.html b/templates/accounts/already_logged_in.html new file mode 100644 index 0000000..7495d6c --- /dev/null +++ b/templates/accounts/already_logged_in.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Bereits angemeldet{% endblock %} +{% block content %} +
+

Du bist bereits angemeldet

+

Um mit diesem Einladungslink ein neues Konto anzulegen, melde dich zuerst ab. Der Link bleibt gültig.

+
+ {% csrf_token %} + +
+
+{% endblock %} diff --git a/templates/accounts/link_invalid.html b/templates/accounts/link_invalid.html new file mode 100644 index 0000000..fced794 --- /dev/null +++ b/templates/accounts/link_invalid.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Link ungültig{% endblock %} +{% block content %} +
+

Link ungültig

+

Dieser {{ kind }} ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte frag beim Admin nach einem neuen Link.

+

Zur Anmeldung

+
+{% endblock %} diff --git a/templates/accounts/login.html b/templates/accounts/login.html new file mode 100644 index 0000000..4b1163d --- /dev/null +++ b/templates/accounts/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Anmelden{% endblock %} +{% block content %} +
+

Anmelden

+
+ {% csrf_token %} + {% include "form_fields.html" %} + + +
+

Kein Konto? Die Registrierung ist nur mit einer Einladung möglich. Passwort vergessen? Bitte wende dich an den Admin.

+
+{% endblock %} diff --git a/templates/accounts/password_change.html b/templates/accounts/password_change.html new file mode 100644 index 0000000..bf7a1bb --- /dev/null +++ b/templates/accounts/password_change.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Passwort ändern{% endblock %} +{% block content %} +
+

Passwort ändern

+ {% if forced %}

Du hast ein temporäres Passwort erhalten. Bitte lege jetzt ein eigenes fest.

{% endif %} +
+ {% csrf_token %} + {% include "form_fields.html" %} + +
+
+{% endblock %} diff --git a/templates/accounts/password_reset.html b/templates/accounts/password_reset.html new file mode 100644 index 0000000..a6f7187 --- /dev/null +++ b/templates/accounts/password_reset.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Passwort zurücksetzen{% endblock %} +{% block content %} +
+

Neues Passwort

+

Lege ein neues Passwort für {{ reset_user.email }} fest.

+
+ {% csrf_token %} + {% include "form_fields.html" %} + +
+
+{% endblock %} diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html new file mode 100644 index 0000000..0b17992 --- /dev/null +++ b/templates/accounts/profile.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Mein Konto{% endblock %} +{% block content %} +
+

Mein Konto

+

{{ user.email }} · {{ user.get_role_display }}

+
+ {% csrf_token %} + {% include "form_fields.html" %} + +
+

Passwort ändern

+
+{% endblock %} diff --git a/templates/accounts/register.html b/templates/accounts/register.html new file mode 100644 index 0000000..25e7d5a --- /dev/null +++ b/templates/accounts/register.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Registrieren{% endblock %} +{% block content %} +
+

Konto anlegen

+

Du wurdest zum Kochbuch eingeladen. Lege jetzt dein Konto an.

+
+ {% csrf_token %} + {% include "form_fields.html" %} + +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a5a9d29 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,43 @@ +{% load static %} + + + + + + + {% block title %}Kochbuch{% endblock %} · Kochbuch + + + + +
+ Kochbuch + {% if user.is_authenticated %} + + {% endif %} +
+ +
+ {% if messages %} +
    + {% for message in messages %}
  • {{ message }}
  • {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/core/home.html b/templates/core/home.html new file mode 100644 index 0000000..8933295 --- /dev/null +++ b/templates/core/home.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Rezepte{% endblock %} +{% block content %} +

Hallo {{ user.display_name }}!

+
+

Hier entstehen bald die Rezepte. Konto, Einladungen und Rollen sind bereits eingerichtet.

+ {% if user.is_admin %} +

Jemanden einladen

+ {% endif %} +
+{% endblock %} diff --git a/templates/errors/403.html b/templates/errors/403.html new file mode 100644 index 0000000..9a0e296 --- /dev/null +++ b/templates/errors/403.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}Kein Zugriff{% endblock %} +{% block content %} +

Kein Zugriff

+

Dafür fehlen dir die Berechtigungen.

+

Zur Startseite

+{% endblock %} diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..a1f2751 --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}Nicht gefunden{% endblock %} +{% block content %} +

Nicht gefunden

+

Diese Seite gibt es nicht oder du hast keinen Zugriff darauf.

+

Zur Startseite

+{% endblock %} diff --git a/templates/form_fields.html b/templates/form_fields.html new file mode 100644 index 0000000..d1ef6c8 --- /dev/null +++ b/templates/form_fields.html @@ -0,0 +1,12 @@ +{% if form.non_field_errors %} + +{% endif %} +{% for field in form.visible_fields %} +
+ + {{ field }} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {% for e in field.errors %}{{ e }}{% endfor %} +
+{% endfor %} +{% for field in form.hidden_fields %}{{ field }}{% endfor %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..993fa4b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +import pytest +from django.test import Client + +from accounts.models import User + +PASSWORD = "Sehr-sicheres-Passwort-42" + + +@pytest.fixture +def admin_user(db): + return User.objects.create_admin(email="admin@example.test", password=PASSWORD, display_name="Admin") + + +@pytest.fixture +def user(db): + return User.objects.create_user(email="anna@example.test", password=PASSWORD, display_name="Anna") + + +@pytest.fixture +def other_user(db): + return User.objects.create_user(email="ben@example.test", password=PASSWORD, display_name="Ben") + + +def logged_in_client(u): + c = Client() + c.force_login(u) + return c + + +@pytest.fixture +def admin_client(admin_user): + return logged_in_client(admin_user) + + +@pytest.fixture +def user_client(user): + return logged_in_client(user) diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py new file mode 100644 index 0000000..2600923 --- /dev/null +++ b/tests/test_admin_permissions.py @@ -0,0 +1,70 @@ +import pytest +from django.test import Client +from django.urls import reverse + +from accounts.models import Invitation, User + +from .conftest import PASSWORD + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"]) +def test_admin_pages_forbidden_for_users(user_client, name): + assert user_client.get(reverse(name)).status_code == 403 + + +@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"]) +def test_admin_pages_ok_for_admin(admin_client, name): + assert admin_client.get(reverse(name)).status_code == 200 + + +@pytest.mark.parametrize("action", ["make_admin", "deactivate", "reset_link", "temp_password"]) +def test_user_actions_forbidden_for_users(user_client, other_user, action): + response = user_client.post(reverse("accounts:admin_user_action", args=[other_user.pk, action])) + assert response.status_code == 403 + other_user.refresh_from_db() + assert other_user.role == User.Role.USER and other_user.is_active and other_user.check_password(PASSWORD) + + +def test_user_cannot_create_invitation(user_client): + user_client.post(reverse("accounts:admin_invitations"), {"role": "admin", "valid_days": 7}) + assert not Invitation.objects.exists() + + +def test_admin_creates_invitation(admin_client): + response = admin_client.post(reverse("accounts:admin_invitations"), {"role": "user", "valid_days": 3, "note": "Oma"}) + assert response.status_code == 200 + assert "/einladung/" in response.context["value"] + assert Invitation.objects.get().note == "Oma" + + +def test_admin_can_promote_and_deactivate(admin_client, user): + admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"])) + user.refresh_from_db() + assert user.role == User.Role.ADMIN + admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"])) + user.refresh_from_db() + assert not user.is_active + + +def test_deactivated_user_session_ends(admin_client, user): + session = Client() + session.force_login(user) + admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"])) + assert session.get(reverse("core:home")).status_code == 302 + + +def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user): + for action in ["make_user", "deactivate"]: + admin_client.post(reverse("accounts:admin_user_action", args=[admin_user.pk, action])) + admin_user.refresh_from_db() + assert admin_user.is_admin + + +def test_unknown_action_rejected(admin_client, user): + assert admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "delete"])).status_code == 403 + + +def test_admin_actions_require_post(admin_client, user): + assert admin_client.get(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"])).status_code == 405 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3575b0a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,269 @@ +from datetime import timedelta + +import pytest +from django.conf import settings +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from accounts.models import Invitation, PasswordResetToken, User + +from .conftest import PASSWORD + +pytestmark = pytest.mark.django_db + +NEW_PASSWORD = "Ganz-neues-Passwort-77" + + +def token_from(link): + return link.rstrip("/").rsplit("/", 1)[-1] + + +def login(client, email, password): + return client.post(reverse("accounts:login"), {"email": email, "password": password}) + + +# --- Login ------------------------------------------------------------------------ + + +def test_login_with_valid_credentials(user): + client = Client() + response = login(client, "Anna@Example.test", PASSWORD) # e-mail is case-insensitive + assert response.status_code == 302 + assert client.get(reverse("core:home")).status_code == 200 + + +def test_login_wrong_password_gives_generic_error(user): + response = login(Client(), user.email, "falsch") + assert response.status_code == 400 + assert "E-Mail oder Passwort ist falsch." in response.content.decode() + + +def test_unknown_email_gives_same_error(db): + response = login(Client(), "niemand@example.test", "egal") + assert "E-Mail oder Passwort ist falsch." in response.content.decode() + + +def test_inactive_user_cannot_login(user): + user.is_active = False + user.save() + assert login(Client(), user.email, PASSWORD).status_code == 400 + + +def test_password_is_hashed_with_argon2(user): + assert user.password.startswith("argon2") + + +def test_login_rate_limit_per_email(user): + client = Client() + for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL): + login(client, user.email, "falsch") + # Even the correct password is refused while locked. + response = login(client, user.email, PASSWORD) + assert response.status_code == 429 + + +def test_login_rate_limit_per_ip(db, settings): + settings.LOGIN_MAX_FAILURES_PER_IP = 3 + client = Client(REMOTE_ADDR="203.0.113.9") + for i in range(3): + login(client, f"x{i}@example.test", "falsch") + assert login(client, "y@example.test", "falsch").status_code == 429 + # Other clients are unaffected. + assert login(Client(REMOTE_ADDR="203.0.113.10"), "y@example.test", "falsch").status_code == 400 + + +def test_successful_login_resets_email_counter(user): + client = Client() + for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL - 1): + login(client, user.email, "falsch") + assert login(client, user.email, PASSWORD).status_code == 302 + client.post(reverse("accounts:logout")) + login(client, user.email, "falsch") + assert login(client, user.email, PASSWORD).status_code == 302 + + +def test_login_redirect_rejects_external_next(user): + response = Client().post( + reverse("accounts:login") + "?next=https://evil.example/", + {"email": user.email, "password": PASSWORD, "next": "https://evil.example/"}, + ) + assert response.status_code == 302 + assert response["Location"] == reverse("core:home") + + +def test_logout_requires_post(user_client): + assert user_client.get(reverse("accounts:logout")).status_code == 405 + assert user_client.post(reverse("accounts:logout")).status_code == 302 + assert user_client.get(reverse("core:home")).status_code == 302 + + +def test_login_requires_csrf_token(user): + client = Client(enforce_csrf_checks=True) + response = login(client, user.email, PASSWORD) + assert response.status_code == 403 + + +# --- Login required everywhere ---------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + ["/", "/konto/", "/konto/passwort/", "/verwaltung/nutzer/", "/verwaltung/einladungen/"], +) +def test_pages_require_login(db, url): + response = Client().get(url) + assert response.status_code == 302 + assert response["Location"].startswith(reverse("accounts:login")) + + +def test_no_open_registration_route(db): + for url in ["/registrieren/", "/register/", "/signup/", "/einladung/"]: + assert Client().get(url).status_code in (302, 404) + + +# --- Invitations ------------------------------------------------------------------ + + +def register(client, token, **data): + payload = {"email": "neu@example.test", "display_name": "Neu", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD} + payload.update(data) + return client.post(reverse("accounts:register", args=[token]), payload) + + +def test_invitation_link_uses_app_base_url(admin_user): + _, link = Invitation.issue(created_by=admin_user) + assert link.startswith(settings.APP_BASE_URL + "/einladung/") + + +def test_register_with_valid_invitation(admin_user): + invitation, link = Invitation.issue(created_by=admin_user) + client = Client() + response = register(client, token_from(link)) + assert response.status_code == 302 + new_user = User.objects.get(email="neu@example.test") + assert new_user.role == User.Role.USER + invitation.refresh_from_db() + assert invitation.used_by == new_user and invitation.status == "used" + assert client.get(reverse("core:home")).status_code == 200 + + +def test_invitation_is_single_use(admin_user): + _, link = Invitation.issue(created_by=admin_user) + register(Client(), token_from(link)) + response = register(Client(), token_from(link), email="zweite@example.test") + assert response.status_code == 404 + assert not User.objects.filter(email="zweite@example.test").exists() + + +def test_expired_invitation_rejected(admin_user): + invitation, link = Invitation.issue(created_by=admin_user) + invitation.expires_at = timezone.now() - timedelta(minutes=1) + invitation.save() + assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404 + + +def test_revoked_invitation_rejected(admin_client, admin_user): + invitation, link = Invitation.issue(created_by=admin_user) + admin_client.post(reverse("accounts:admin_invitation_revoke", args=[invitation.pk])) + assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404 + + +def test_random_token_rejected(db): + assert Client().get(reverse("accounts:register", args=["nicht-gueltig"])).status_code == 404 + + +def test_invitation_token_not_stored_in_plain_text(admin_user): + invitation, link = Invitation.issue(created_by=admin_user) + assert token_from(link) not in invitation.token_hash + + +def test_invitation_bound_to_email(admin_user): + _, link = Invitation.issue(created_by=admin_user, email="fest@example.test") + register(Client(), token_from(link), email="anders@example.test") + assert User.objects.filter(email="fest@example.test").exists() + assert not User.objects.filter(email="anders@example.test").exists() + + +def test_invitation_assigns_role(admin_user): + _, link = Invitation.issue(created_by=admin_user, role=User.Role.ADMIN) + register(Client(), token_from(link)) + assert User.objects.get(email="neu@example.test").role == User.Role.ADMIN + + +def test_register_rejects_existing_email(admin_user, user): + _, link = Invitation.issue(created_by=admin_user) + response = register(Client(), token_from(link), email=user.email.upper()) + assert response.status_code == 200 + assert User.objects.filter(email=user.email).count() == 1 + + +def test_register_rejects_weak_password(admin_user): + _, link = Invitation.issue(created_by=admin_user) + response = register(Client(), token_from(link), password1="12345678", password2="12345678") + assert response.status_code == 200 + assert not User.objects.filter(email="neu@example.test").exists() + + +# --- Password reset by admin ------------------------------------------------------ + + +def test_reset_link_flow(admin_user, user): + _, link = PasswordResetToken.issue(user, created_by=admin_user) + client = Client() + response = client.post( + reverse("accounts:password_reset", args=[token_from(link)]), + {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD}, + ) + assert response.status_code == 302 + user.refresh_from_db() + assert user.check_password(NEW_PASSWORD) + # Single use + assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404 + + +def test_new_reset_link_invalidates_old(admin_user, user): + _, old = PasswordResetToken.issue(user, created_by=admin_user) + PasswordResetToken.issue(user, created_by=admin_user) + assert Client().get(reverse("accounts:password_reset", args=[token_from(old)])).status_code == 404 + + +def test_reset_link_expires(admin_user, user): + token, link = PasswordResetToken.issue(user, created_by=admin_user) + token.expires_at = timezone.now() - timedelta(seconds=1) + token.save() + assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404 + + +def test_reset_link_ends_existing_sessions(admin_user, user): + old_session = Client() + old_session.force_login(user) + _, link = PasswordResetToken.issue(user, created_by=admin_user) + Client().post( + reverse("accounts:password_reset", args=[token_from(link)]), + {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD}, + ) + assert old_session.get(reverse("core:home")).status_code == 302 + + +def test_temp_password_forces_change(admin_client, user): + response = admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "temp_password"])) + temp = response.context["value"] + client = Client() + assert login(client, user.email, temp).status_code == 302 + # Every page redirects to the password change until a new password is set. + assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change") + client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD}) + assert client.get(reverse("core:home")).status_code == 200 + user.refresh_from_db() + assert not user.must_change_password and user.check_password(NEW_PASSWORD) + + +def test_own_password_change_requires_old_password(user_client, user): + response = user_client.post( + reverse("accounts:password_change"), + {"old_password": "falsch", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD}, + ) + assert response.status_code == 200 + user.refresh_from_db() + assert user.check_password(PASSWORD) diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py new file mode 100644 index 0000000..9c6de94 --- /dev/null +++ b/tests/test_infrastructure.py @@ -0,0 +1,151 @@ +from datetime import timedelta + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from django.http import HttpResponse +from django.test import Client, RequestFactory + +from accounts.models import User +from core import ratelimit +from core.middleware import ProxyMiddleware + +pytestmark = pytest.mark.django_db + + +# --- Proxy handling -------------------------------------------------------------- + + +def run_proxy(remote_addr, **headers): + request = RequestFactory().get("/", REMOTE_ADDR=remote_addr, **headers) + ProxyMiddleware(lambda r: HttpResponse())(request) + return request + + +def test_forwarded_headers_from_trusted_proxy_are_used(): + request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7", HTTP_X_FORWARDED_PROTO="https") + assert request.META["REMOTE_ADDR"] == "198.51.100.7" + assert request.is_secure() + + +def test_forwarded_headers_from_untrusted_peer_are_dropped(): + request = run_proxy("203.0.113.50", HTTP_X_FORWARDED_FOR="10.0.0.1", HTTP_X_FORWARDED_PROTO="https") + assert request.META["REMOTE_ADDR"] == "203.0.113.50" + assert "HTTP_X_FORWARDED_FOR" not in request.META + assert not request.is_secure() + + +def test_spoofed_left_entries_in_forwarded_for_are_ignored(): + # Client sent "X-Forwarded-For: 1.2.3.4"; the proxy appended the real client address. + request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="1.2.3.4, 198.51.100.7") + assert request.META["REMOTE_ADDR"] == "198.51.100.7" + + +def test_chained_trusted_proxies_are_skipped(): + request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7, 192.168.1.2") + assert request.META["REMOTE_ADDR"] == "198.51.100.7" + + +def test_client_ip_header_only_from_trusted_proxy(settings): + settings.CLIENT_IP_HEADER = "CF-Connecting-IP" + trusted = run_proxy("172.18.0.5", HTTP_CF_CONNECTING_IP="198.51.100.8", HTTP_X_FORWARDED_FOR="162.158.1.1") + assert trusted.META["REMOTE_ADDR"] == "198.51.100.8" + untrusted = run_proxy("203.0.113.50", HTTP_CF_CONNECTING_IP="198.51.100.8") + assert untrusted.META["REMOTE_ADDR"] == "203.0.113.50" + + +# --- Security headers & health ----------------------------------------------------- + + +def test_security_headers(db): + response = Client().get("/anmelden/") + assert "default-src 'self'" in response["Content-Security-Policy"] + assert response["X-Frame-Options"] == "DENY" + assert response["X-Content-Type-Options"] == "nosniff" + assert response["Referrer-Policy"] == "same-origin" + assert "Permissions-Policy" in response + + +def test_hsts_on_secure_requests(db): + response = Client().get("/anmelden/", secure=True) + assert "max-age=" in response["Strict-Transport-Security"] + + +def test_session_cookie_flags(user): + from django.conf import settings + + from .conftest import PASSWORD + + response = Client().post("/anmelden/", {"email": user.email, "password": PASSWORD}) + cookie = response.cookies[settings.SESSION_COOKIE_NAME] + assert cookie["httponly"] + assert cookie["samesite"] == "Lax" + assert bool(cookie["secure"]) == settings.COOKIE_SECURE + + +def test_authenticated_pages_not_cacheable(user_client): + assert "no-store" in user_client.get("/")["Cache-Control"] + + +def test_healthz(db): + response = Client().get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +# --- Rate limiter ------------------------------------------------------------------ + + +def test_ratelimit_counts_and_resets(): + window = timedelta(minutes=1) + assert ratelimit.hit("k", 2, window) + assert ratelimit.hit("k", 2, window) + assert ratelimit.is_limited("k", 2, window) + assert not ratelimit.hit("k", 2, window) + ratelimit.reset("k") + assert not ratelimit.is_limited("k", 2, window) + + +def test_ratelimit_zero_means_unlimited(): + for _ in range(50): + assert ratelimit.hit("u", 0, timedelta(minutes=1)) + assert not ratelimit.is_limited("u", 0, timedelta(minutes=1)) + + +def test_ratelimit_window_expires(): + window = timedelta(seconds=0) + ratelimit.hit("w", 1, window) + assert not ratelimit.is_limited("w", 1, window) + assert ratelimit.hit("w", 1, window) + + +# --- First admin from environment -------------------------------------------------- + + +def test_ensure_admin_creates_admin_once(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "Chef@Example.test") + monkeypatch.setenv("ADMIN_PASSWORD", "Start-Passwort-2026") + call_command("ensure_admin") + admin = User.objects.get(email="chef@example.test") + assert admin.is_admin and admin.check_password("Start-Passwort-2026") + + # A second run with a different password must not touch the existing account. + monkeypatch.setenv("ADMIN_PASSWORD", "Anderes-Passwort-2026") + call_command("ensure_admin") + admin.refresh_from_db() + assert admin.check_password("Start-Passwort-2026") + assert User.objects.count() == 1 + + +def test_ensure_admin_rejects_weak_password(monkeypatch): + monkeypatch.setenv("ADMIN_EMAIL", "chef@example.test") + monkeypatch.setenv("ADMIN_PASSWORD", "123") + with pytest.raises(CommandError): + call_command("ensure_admin") + assert not User.objects.exists() + + +def test_ensure_admin_without_env_does_nothing(monkeypatch): + monkeypatch.delenv("ADMIN_EMAIL", raising=False) + call_command("ensure_admin") + assert not User.objects.exists()