From ef9deaeff724fe882e6258cb7aaf84e6ca880008 Mon Sep 17 00:00:00 2001 From: Kyle Dammann Date: Fri, 30 Oct 2026 21:24:59 +0100 Subject: [PATCH] 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 --- .dockerignore | 9 + .env.example | 51 ++++ .gitattributes | 4 + .gitignore | 10 + CLAUDE.md | 60 +++++ Dockerfile | 35 +++ README.md | 66 +++++ accounts/__init__.py | 0 accounts/admin_views.py | 137 ++++++++++ accounts/apps.py | 6 + accounts/forms.py | 107 ++++++++ accounts/management/__init__.py | 0 accounts/management/commands/__init__.py | 0 accounts/management/commands/ensure_admin.py | 35 +++ accounts/middleware.py | 17 ++ accounts/migrations/0001_initial.py | 79 ++++++ accounts/migrations/__init__.py | 0 accounts/models.py | 156 +++++++++++ accounts/tokens.py | 18 ++ accounts/urls.py | 18 ++ accounts/views.py | 156 +++++++++++ config/__init__.py | 0 config/settings.py | 192 +++++++++++++ config/settings_test.py | 17 ++ config/urls.py | 9 + config/wsgi.py | 7 + core/__init__.py | 0 core/apps.py | 6 + core/management/__init__.py | 0 core/management/commands/__init__.py | 0 core/management/commands/purge_ratelimits.py | 10 + core/middleware.py | 82 ++++++ core/migrations/0001_initial.py | 23 ++ core/migrations/__init__.py | 0 core/models.py | 12 + core/ratelimit.py | 58 ++++ core/urls.py | 10 + core/views.py | 30 +++ docker-compose.yml | 53 ++++ docker/entrypoint.sh | 15 ++ manage.py | 14 + pyproject.toml | 9 + requirements-dev.txt | 3 + requirements.txt | 5 + scripts/dev_server.py | 24 ++ scripts/init-env.sh | 32 +++ static/css/app.css | 185 +++++++++++++ static/js/app.js | 14 + templates/accounts/admin/invitations.html | 35 +++ templates/accounts/admin/show_once.html | 16 ++ templates/accounts/admin/users.html | 38 +++ templates/accounts/already_logged_in.html | 12 + templates/accounts/link_invalid.html | 9 + templates/accounts/login.html | 14 + templates/accounts/password_change.html | 13 + templates/accounts/password_reset.html | 13 + templates/accounts/profile.html | 14 + templates/accounts/register.html | 13 + templates/base.html | 43 +++ templates/core/home.html | 11 + templates/errors/403.html | 7 + templates/errors/404.html | 7 + templates/form_fields.html | 12 + tests/__init__.py | 0 tests/conftest.py | 37 +++ tests/test_admin_permissions.py | 70 +++++ tests/test_auth.py | 269 +++++++++++++++++++ tests/test_infrastructure.py | 151 +++++++++++ 68 files changed, 2558 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 accounts/__init__.py create mode 100644 accounts/admin_views.py create mode 100644 accounts/apps.py create mode 100644 accounts/forms.py create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 accounts/management/commands/ensure_admin.py create mode 100644 accounts/middleware.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/tokens.py create mode 100644 accounts/urls.py create mode 100644 accounts/views.py create mode 100644 config/__init__.py create mode 100644 config/settings.py create mode 100644 config/settings_test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/apps.py create mode 100644 core/management/__init__.py create mode 100644 core/management/commands/__init__.py create mode 100644 core/management/commands/purge_ratelimits.py create mode 100644 core/middleware.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/ratelimit.py create mode 100644 core/urls.py create mode 100644 core/views.py create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100755 manage.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 scripts/dev_server.py create mode 100755 scripts/init-env.sh create mode 100644 static/css/app.css create mode 100644 static/js/app.js create mode 100644 templates/accounts/admin/invitations.html create mode 100644 templates/accounts/admin/show_once.html create mode 100644 templates/accounts/admin/users.html create mode 100644 templates/accounts/already_logged_in.html create mode 100644 templates/accounts/link_invalid.html create mode 100644 templates/accounts/login.html create mode 100644 templates/accounts/password_change.html create mode 100644 templates/accounts/password_reset.html create mode 100644 templates/accounts/profile.html create mode 100644 templates/accounts/register.html create mode 100644 templates/base.html create mode 100644 templates/core/home.html create mode 100644 templates/errors/403.html create mode 100644 templates/errors/404.html create mode 100644 templates/form_fields.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_admin_permissions.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_infrastructure.py 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()