Add phase 1: Docker setup, auth with invitations and roles

- Django 5.2 + PostgreSQL 17 via docker compose (app + db, volumes,
  healthcheck on /healthz, test profile running pytest against Postgres)
- Custom e-mail user model with user/admin roles
- Invitation-only registration with single-use, hashed, expiring links
- Admin area: invitations, role changes, deactivation, password reset
  via one-time link or temporary password (forced change on login)
- First admin created from ADMIN_EMAIL/ADMIN_PASSWORD on startup
- Login rate limiting per e-mail and IP (DB-backed, worker-safe)
- Proxy middleware honouring X-Forwarded-* only from TRUSTED_PROXIES,
  optional CLIENT_IP_HEADER (e.g. CF-Connecting-IP)
- Security headers (CSP, HSTS, Permissions-Policy), secure __Host- cookies
- Mobile-first German UI, CLAUDE.md, README, init-env script
- 65 tests for auth, permissions, proxy handling and rate limiting

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:24:59 +01:00
co-authored by Claude Opus 5.5
commit ef9deaeff7
68 changed files with 2558 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.venv
**/__pycache__
.pytest_cache
data
backups
staticfiles
db.sqlite3
+51
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
+10
View File
@@ -0,0 +1,10 @@
.env
.venv/
__pycache__/
*.pyc
.pytest_cache/
/staticfiles/
/data/
/backups/
db.sqlite3
.claude/launch.json
+60
View File
@@ -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, [email protected]
SECRET_KEY=dev python manage.py makemigrations
# Docker
./scripts/init-env.sh [email protected] # 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.
+35
View File
@@ -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
+66
View File
@@ -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 <gitea-url>/kochbuch.git && cd kochbuch
./scripts/init-env.sh [email protected] # 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://<docker-host>: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 <Cloudflare-Netze>; real_ip_header CF-Connecting-IP;`).
Dann reicht `TRUSTED_PROXIES=<IP deines Nginx>`.
- 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
```
View File
+137
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"
verbose_name = "Konten"
+107
View File
@@ -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
View File
@@ -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.")
+17
View File
@@ -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)
+79
View File
@@ -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,
},
),
]
View File
+156
View File
@@ -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])
+18
View File
@@ -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()
+18
View File
@@ -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/<str:token>/", views.register, name="register"),
path("passwort-zuruecksetzen/<str:token>/", 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/<int:pk>/<str:action>/", admin_views.user_action, name="admin_user_action"),
path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"),
path("verwaltung/einladungen/<int:pk>/widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"),
]
+156
View File
@@ -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})
View File
+192
View File
@@ -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"}},
}
+17
View File
@@ -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
+9
View File
@@ -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"
+7
View File
@@ -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()
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"
verbose_name = "Kern"
View File
@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from core.ratelimit import purge_expired
class Command(BaseCommand):
help = "Löscht abgelaufene Rate-Limit-Zähler."
def handle(self, *args, **options):
purge_expired()
+82
View File
@@ -0,0 +1,82 @@
import ipaddress
from django.conf import settings
FORWARDED_META_KEYS = ("HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_PROTO", "HTTP_X_FORWARDED_HOST", "HTTP_X_REAL_IP")
def _parse_ip(value):
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def is_trusted_proxy(ip):
return ip is not None and any(ip in net for net in settings.TRUSTED_PROXIES)
def resolve_client_ip(peer, meta):
"""Return the real client IP for a request that arrived from a trusted proxy.
With CLIENT_IP_HEADER set (e.g. CF-Connecting-IP) that header wins. Otherwise
X-Forwarded-For is walked from the right, skipping trusted proxies; the first
untrusted address is the client. Anything left of it is client-controlled.
"""
if settings.CLIENT_IP_HEADER:
key = "HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_")
ip = _parse_ip(meta.get(key, ""))
if ip is not None:
return ip
hops = [h for h in meta.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
for hop in reversed(hops):
ip = _parse_ip(hop)
if ip is None:
break
if not is_trusted_proxy(ip):
return ip
return peer
class ProxyMiddleware:
"""Honour X-Forwarded-* only when the TCP peer is a trusted proxy.
Requests from anyone else get those headers stripped, so is_secure() and
REMOTE_ADDR cannot be spoofed. request.META["REMOTE_ADDR"] holds the real
client IP afterwards; the original peer is kept in "PROXY_PEER_ADDR".
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
meta = request.META
peer = _parse_ip(meta.get("REMOTE_ADDR", ""))
if is_trusted_proxy(peer):
client = resolve_client_ip(peer, meta)
meta["PROXY_PEER_ADDR"] = str(peer)
meta["REMOTE_ADDR"] = str(client)
else:
for key in FORWARDED_META_KEYS:
meta.pop(key, None)
if settings.CLIENT_IP_HEADER:
meta.pop("HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_"), None)
return self.get_response(request)
class SecurityHeadersMiddleware:
"""Adds the headers Django does not set itself (CSP, Permissions-Policy)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response.headers.setdefault("Content-Security-Policy", settings.CONTENT_SECURITY_POLICY)
response.headers.setdefault("Permissions-Policy", settings.PERMISSIONS_POLICY)
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
user = getattr(request, "user", None)
if user is not None and user.is_authenticated:
# Pages with private content must not end up in shared caches.
response.headers.setdefault("Cache-Control", "private, no-store")
return response
+23
View File
@@ -0,0 +1,23 @@
# Generated by Django 5.2.18 on 2026-10-30 20:04
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='RateLimitBucket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255, unique=True)),
('window_start', models.DateTimeField()),
('count', models.PositiveIntegerField(default=0)),
],
),
]
View File
+12
View File
@@ -0,0 +1,12 @@
from django.db import models
class RateLimitBucket(models.Model):
"""Fixed-window counter used by core.ratelimit. One row per key."""
key = models.CharField(max_length=255, unique=True)
window_start = models.DateTimeField()
count = models.PositiveIntegerField(default=0)
def __str__(self):
return f"{self.key}: {self.count}"
+58
View File
@@ -0,0 +1,58 @@
"""Database-backed fixed-window rate limiting.
Stored in the database rather than a process-local cache so that the limit holds
across all gunicorn workers without needing Redis. A limit of 0 means unlimited.
"""
from datetime import timedelta
from django.db import IntegrityError, transaction
from django.utils import timezone
from .models import RateLimitBucket
def _expired(bucket, window, now):
return bucket.window_start + window <= now
def is_limited(key, limit, window: timedelta):
"""True if the key has already reached the limit in the current window."""
if limit <= 0:
return False
bucket = RateLimitBucket.objects.filter(key=key).first()
if bucket is None or _expired(bucket, window, timezone.now()):
return False
return bucket.count >= limit
def hit(key, limit, window: timedelta):
"""Count one event. Returns False if this event exceeds the limit."""
if limit <= 0:
return True
now = timezone.now()
for _ in range(2):
try:
with transaction.atomic():
bucket = RateLimitBucket.objects.select_for_update().filter(key=key).first()
if bucket is None:
RateLimitBucket.objects.create(key=key, window_start=now, count=1)
return True
if _expired(bucket, window, now):
bucket.window_start = now
bucket.count = 0
bucket.count += 1
bucket.save(update_fields=["window_start", "count"])
return bucket.count <= limit
except IntegrityError:
# Another worker created the row concurrently; retry with the row present.
continue
return False
def reset(key):
RateLimitBucket.objects.filter(key=key).delete()
def purge_expired(older_than: timedelta = timedelta(days=2)):
RateLimitBucket.objects.filter(window_start__lt=timezone.now() - older_than).delete()
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "core"
urlpatterns = [
path("", views.home, name="home"),
path("healthz", views.healthz, name="healthz"),
]
+30
View File
@@ -0,0 +1,30 @@
from django.contrib.auth.decorators import login_not_required
from django.db import connection
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
def home(request):
return render(request, "core/home.html")
@login_not_required
@never_cache
@require_GET
def healthz(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception:
return JsonResponse({"status": "error", "database": "unreachable"}, status=503)
return JsonResponse({"status": "ok"})
def error_403(request, exception=None):
return render(request, "errors/403.html", status=403)
def error_404(request, exception=None):
return render(request, "errors/404.html", status=404)
+53
View File
@@ -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:
+15
View File
@@ -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.
Executable
+14
View File
@@ -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()
+9
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.2.0
pytest-django==4.14.0
+5
View File
@@ -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
+24
View File
@@ -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", "[email protected]")
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"])
+32
View File
@@ -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
+185
View File
@@ -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; }
+14
View File
@@ -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";
}
});
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Einladungen{% endblock %}
{% block content %}
<h1>Einladungen</h1>
<form method="post" class="card" novalidate>
{% csrf_token %}
<h2>Neue Einladung</h2>
{% include "form_fields.html" %}
<button type="submit" class="button">Einladungslink erzeugen</button>
</form>
<h2>Bisherige Einladungen</h2>
{% if invitations %}
<ul class="list">
{% for inv in invitations %}
<li class="card">
<div class="row-head">
<strong>{{ inv.note|default:inv.email|default:"Einladung" }}</strong>
<span class="badge{% if inv.status != 'open' %} badge-muted{% endif %}">{{ inv.status_label }}</span>
<span class="badge badge-muted">{{ inv.get_role_display }}</span>
</div>
<div class="muted">
{% 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 %}
</div>
{% if inv.status == "open" %}
<form method="post" action="{% url 'accounts:admin_invitation_revoke' inv.pk %}">{% csrf_token %}<button class="button danger small">Widerrufen</button></form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Noch keine Einladungen.</p>
{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="narrow">
<h1>{{ title }}</h1>
<div class="card">
<label for="secret-value">Nur jetzt sichtbar – bitte kopieren:</label>
<div class="copyrow">
<input id="secret-value" type="text" value="{{ value }}" readonly>
<button type="button" class="button" data-copy="#secret-value">Kopieren</button>
</div>
<p class="muted">{{ hint }}</p>
</div>
<p><a href="{% url 'accounts:admin_users' %}">Zurück zur Nutzerverwaltung</a> · <a href="{% url 'accounts:admin_invitations' %}">Einladungen</a></p>
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Nutzer verwalten{% endblock %}
{% block content %}
<h1>Nutzer</h1>
<p><a class="button" href="{% url 'accounts:admin_invitations' %}">Neue Einladung</a></p>
<ul class="list">
{% for u in users %}
<li class="card{% if not u.is_active %} inactive{% endif %}">
<div class="row-head">
<strong>{{ u.display_name }}</strong>
<span class="badge">{{ u.get_role_display }}</span>
{% if not u.is_active %}<span class="badge badge-muted">deaktiviert</span>{% endif %}
{% if u.must_change_password %}<span class="badge badge-muted">temp. Passwort</span>{% endif %}
</div>
<div class="muted">{{ u.email }} · seit {{ u.date_joined|date:"d.m.Y" }}</div>
<details class="actions">
<summary>Aktionen</summary>
<div class="action-buttons">
{% if u.is_active %}
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'reset_link' %}">{% csrf_token %}<button class="button secondary">Reset-Link erzeugen</button></form>
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'temp_password' %}">{% csrf_token %}<button class="button secondary">Temporäres Passwort</button></form>
{% if u != user %}
{% if u.role == "admin" %}
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'make_user' %}">{% csrf_token %}<button class="button secondary">Zu Nutzer machen</button></form>
{% else %}
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'make_admin' %}">{% csrf_token %}<button class="button secondary">Zu Admin machen</button></form>
{% endif %}
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'deactivate' %}">{% csrf_token %}<button class="button danger">Deaktivieren</button></form>
{% endif %}
{% else %}
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'activate' %}">{% csrf_token %}<button class="button secondary">Aktivieren</button></form>
{% endif %}
</div>
</details>
</li>
{% endfor %}
</ul>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Bereits angemeldet{% endblock %}
{% block content %}
<div class="narrow">
<h1>Du bist bereits angemeldet</h1>
<p>Um mit diesem Einladungslink ein neues Konto anzulegen, melde dich zuerst ab. Der Link bleibt gültig.</p>
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="button">Abmelden</button>
</form>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Link ungültig{% endblock %}
{% block content %}
<div class="narrow">
<h1>Link ungültig</h1>
<p>Dieser {{ kind }} ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte frag beim Admin nach einem neuen Link.</p>
<p><a href="{% url 'accounts:login' %}">Zur Anmeldung</a></p>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<div class="narrow">
<h1>Anmelden</h1>
<form method="post" class="card" novalidate>
{% csrf_token %}
{% include "form_fields.html" %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit" class="button wide">Anmelden</button>
</form>
<p class="muted">Kein Konto? Die Registrierung ist nur mit einer Einladung möglich. Passwort vergessen? Bitte wende dich an den Admin.</p>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Passwort ändern{% endblock %}
{% block content %}
<div class="narrow">
<h1>Passwort ändern</h1>
{% if forced %}<p class="msg msg-warning">Du hast ein temporäres Passwort erhalten. Bitte lege jetzt ein eigenes fest.</p>{% endif %}
<form method="post" class="card" novalidate>
{% csrf_token %}
{% include "form_fields.html" %}
<button type="submit" class="button wide">Passwort speichern</button>
</form>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Passwort zurücksetzen{% endblock %}
{% block content %}
<div class="narrow">
<h1>Neues Passwort</h1>
<p>Lege ein neues Passwort für <strong>{{ reset_user.email }}</strong> fest.</p>
<form method="post" class="card" novalidate>
{% csrf_token %}
{% include "form_fields.html" %}
<button type="submit" class="button wide">Passwort speichern</button>
</form>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Mein Konto{% endblock %}
{% block content %}
<div class="narrow">
<h1>Mein Konto</h1>
<p class="muted">{{ user.email }} · {{ user.get_role_display }}</p>
<form method="post" class="card" novalidate>
{% csrf_token %}
{% include "form_fields.html" %}
<button type="submit" class="button">Speichern</button>
</form>
<p><a href="{% url 'accounts:password_change' %}">Passwort ändern</a></p>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Registrieren{% endblock %}
{% block content %}
<div class="narrow">
<h1>Konto anlegen</h1>
<p>Du wurdest zum Kochbuch eingeladen. Lege jetzt dein Konto an.</p>
<form method="post" class="card" novalidate>
{% csrf_token %}
{% include "form_fields.html" %}
<button type="submit" class="button wide">Konto anlegen</button>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% load static %}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#b4532a">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Kochbuch{% endblock %} · Kochbuch</title>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<script src="{% static 'js/app.js' %}" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="{% url 'core:home' %}">Kochbuch</a>
{% if user.is_authenticated %}
<details class="menu">
<summary aria-label="Menü">☰</summary>
<nav>
<a href="{% url 'core:home' %}">Rezepte</a>
<a href="{% url 'accounts:profile' %}">Mein Konto</a>
{% if user.is_admin %}
<a href="{% url 'accounts:admin_users' %}">Nutzer verwalten</a>
<a href="{% url 'accounts:admin_invitations' %}">Einladungen</a>
{% endif %}
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="linklike">Abmelden</button>
</form>
</nav>
</details>
{% endif %}
</header>
<main class="container">
{% if messages %}
<ul class="messages">
{% for message in messages %}<li class="msg msg-{{ message.tags }}">{{ message }}</li>{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Rezepte{% endblock %}
{% block content %}
<h1>Hallo {{ user.display_name }}!</h1>
<div class="card">
<p>Hier entstehen bald die Rezepte. Konto, Einladungen und Rollen sind bereits eingerichtet.</p>
{% if user.is_admin %}
<p><a class="button" href="{% url 'accounts:admin_invitations' %}">Jemanden einladen</a></p>
{% endif %}
</div>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}Kein Zugriff{% endblock %}
{% block content %}
<h1>Kein Zugriff</h1>
<p>Dafür fehlen dir die Berechtigungen.</p>
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}Nicht gefunden{% endblock %}
{% block content %}
<h1>Nicht gefunden</h1>
<p>Diese Seite gibt es nicht oder du hast keinen Zugriff darauf.</p>
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% if form.non_field_errors %}
<div class="msg msg-error" role="alert">{% for e in form.non_field_errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}</div>
{% endif %}
{% for field in form.visible_fields %}
<div class="field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<small class="help">{{ field.help_text }}</small>{% endif %}
{% for e in field.errors %}<small class="error">{{ e }}</small>{% endfor %}
</div>
{% endfor %}
{% for field in form.hidden_fields %}{{ field }}{% endfor %}
View File
+37
View File
@@ -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="[email protected]", password=PASSWORD, display_name="Admin")
@pytest.fixture
def user(db):
return User.objects.create_user(email="[email protected]", password=PASSWORD, display_name="Anna")
@pytest.fixture
def other_user(db):
return User.objects.create_user(email="[email protected]", 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)
+70
View File
@@ -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
+269
View File
@@ -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, "[email protected]", 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(), "[email protected]", "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, "[email protected]", "falsch").status_code == 429
# Other clients are unaffected.
assert login(Client(REMOTE_ADDR="203.0.113.10"), "[email protected]", "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": "[email protected]", "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="[email protected]")
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="[email protected]")
assert response.status_code == 404
assert not User.objects.filter(email="[email protected]").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="[email protected]")
register(Client(), token_from(link), email="[email protected]")
assert User.objects.filter(email="[email protected]").exists()
assert not User.objects.filter(email="[email protected]").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="[email protected]").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="[email protected]").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)
+151
View File
@@ -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", "[email protected]")
monkeypatch.setenv("ADMIN_PASSWORD", "Start-Passwort-2026")
call_command("ensure_admin")
admin = User.objects.get(email="[email protected]")
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", "[email protected]")
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()