Add phase 1: Docker setup, auth with invitations and roles
- Django 5.2 + PostgreSQL 17 via docker compose (app + db, volumes, healthcheck on /healthz, test profile running pytest against Postgres) - Custom e-mail user model with user/admin roles - Invitation-only registration with single-use, hashed, expiring links - Admin area: invitations, role changes, deactivation, password reset via one-time link or temporary password (forced change on login) - First admin created from ADMIN_EMAIL/ADMIN_PASSWORD on startup - Login rate limiting per e-mail and IP (DB-backed, worker-safe) - Proxy middleware honouring X-Forwarded-* only from TRUSTED_PROXIES, optional CLIENT_IP_HEADER (e.g. CF-Connecting-IP) - Security headers (CSP, HSTS, Permissions-Policy), secure __Host- cookies - Mobile-first German UI, CLAUDE.md, README, init-env script - 65 tests for auth, permissions, proxy handling and rate limiting Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
.venv
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
data
|
||||
backups
|
||||
staticfiles
|
||||
db.sqlite3
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.webp binary
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
/staticfiles/
|
||||
/data/
|
||||
/backups/
|
||||
db.sqlite3
|
||||
.claude/launch.json
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
verbose_name = "Konten"
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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"}},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
verbose_name = "Kern"
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.ratelimit import purge_expired
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Löscht abgelaufene Rate-Limit-Zähler."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
purge_expired()
|
||||
@@ -0,0 +1,82 @@
|
||||
import ipaddress
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
FORWARDED_META_KEYS = ("HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_PROTO", "HTTP_X_FORWARDED_HOST", "HTTP_X_REAL_IP")
|
||||
|
||||
|
||||
def _parse_ip(value):
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_trusted_proxy(ip):
|
||||
return ip is not None and any(ip in net for net in settings.TRUSTED_PROXIES)
|
||||
|
||||
|
||||
def resolve_client_ip(peer, meta):
|
||||
"""Return the real client IP for a request that arrived from a trusted proxy.
|
||||
|
||||
With CLIENT_IP_HEADER set (e.g. CF-Connecting-IP) that header wins. Otherwise
|
||||
X-Forwarded-For is walked from the right, skipping trusted proxies; the first
|
||||
untrusted address is the client. Anything left of it is client-controlled.
|
||||
"""
|
||||
if settings.CLIENT_IP_HEADER:
|
||||
key = "HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_")
|
||||
ip = _parse_ip(meta.get(key, ""))
|
||||
if ip is not None:
|
||||
return ip
|
||||
hops = [h for h in meta.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
|
||||
for hop in reversed(hops):
|
||||
ip = _parse_ip(hop)
|
||||
if ip is None:
|
||||
break
|
||||
if not is_trusted_proxy(ip):
|
||||
return ip
|
||||
return peer
|
||||
|
||||
|
||||
class ProxyMiddleware:
|
||||
"""Honour X-Forwarded-* only when the TCP peer is a trusted proxy.
|
||||
|
||||
Requests from anyone else get those headers stripped, so is_secure() and
|
||||
REMOTE_ADDR cannot be spoofed. request.META["REMOTE_ADDR"] holds the real
|
||||
client IP afterwards; the original peer is kept in "PROXY_PEER_ADDR".
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
meta = request.META
|
||||
peer = _parse_ip(meta.get("REMOTE_ADDR", ""))
|
||||
if is_trusted_proxy(peer):
|
||||
client = resolve_client_ip(peer, meta)
|
||||
meta["PROXY_PEER_ADDR"] = str(peer)
|
||||
meta["REMOTE_ADDR"] = str(client)
|
||||
else:
|
||||
for key in FORWARDED_META_KEYS:
|
||||
meta.pop(key, None)
|
||||
if settings.CLIENT_IP_HEADER:
|
||||
meta.pop("HTTP_" + settings.CLIENT_IP_HEADER.upper().replace("-", "_"), None)
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Adds the headers Django does not set itself (CSP, Permissions-Policy)."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
response.headers.setdefault("Content-Security-Policy", settings.CONTENT_SECURITY_POLICY)
|
||||
response.headers.setdefault("Permissions-Policy", settings.PERMISSIONS_POLICY)
|
||||
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
|
||||
user = getattr(request, "user", None)
|
||||
if user is not None and user.is_authenticated:
|
||||
# Pages with private content must not end up in shared caches.
|
||||
response.headers.setdefault("Cache-Control", "private, no-store")
|
||||
return response
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-30 20:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RateLimitBucket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255, unique=True)),
|
||||
('window_start', models.DateTimeField()),
|
||||
('count', models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class RateLimitBucket(models.Model):
|
||||
"""Fixed-window counter used by core.ratelimit. One row per key."""
|
||||
|
||||
key = models.CharField(max_length=255, unique=True)
|
||||
window_start = models.DateTimeField()
|
||||
count = models.PositiveIntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.key}: {self.count}"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Database-backed fixed-window rate limiting.
|
||||
|
||||
Stored in the database rather than a process-local cache so that the limit holds
|
||||
across all gunicorn workers without needing Redis. A limit of 0 means unlimited.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import RateLimitBucket
|
||||
|
||||
|
||||
def _expired(bucket, window, now):
|
||||
return bucket.window_start + window <= now
|
||||
|
||||
|
||||
def is_limited(key, limit, window: timedelta):
|
||||
"""True if the key has already reached the limit in the current window."""
|
||||
if limit <= 0:
|
||||
return False
|
||||
bucket = RateLimitBucket.objects.filter(key=key).first()
|
||||
if bucket is None or _expired(bucket, window, timezone.now()):
|
||||
return False
|
||||
return bucket.count >= limit
|
||||
|
||||
|
||||
def hit(key, limit, window: timedelta):
|
||||
"""Count one event. Returns False if this event exceeds the limit."""
|
||||
if limit <= 0:
|
||||
return True
|
||||
now = timezone.now()
|
||||
for _ in range(2):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
bucket = RateLimitBucket.objects.select_for_update().filter(key=key).first()
|
||||
if bucket is None:
|
||||
RateLimitBucket.objects.create(key=key, window_start=now, count=1)
|
||||
return True
|
||||
if _expired(bucket, window, now):
|
||||
bucket.window_start = now
|
||||
bucket.count = 0
|
||||
bucket.count += 1
|
||||
bucket.save(update_fields=["window_start", "count"])
|
||||
return bucket.count <= limit
|
||||
except IntegrityError:
|
||||
# Another worker created the row concurrently; retry with the row present.
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def reset(key):
|
||||
RateLimitBucket.objects.filter(key=key).delete()
|
||||
|
||||
|
||||
def purge_expired(older_than: timedelta = timedelta(days=2)):
|
||||
RateLimitBucket.objects.filter(window_start__lt=timezone.now() - older_than).delete()
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("healthz", views.healthz, name="healthz"),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.decorators import login_not_required
|
||||
from django.db import connection
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
|
||||
def home(request):
|
||||
return render(request, "core/home.html")
|
||||
|
||||
|
||||
@login_not_required
|
||||
@never_cache
|
||||
@require_GET
|
||||
def healthz(request):
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
except Exception:
|
||||
return JsonResponse({"status": "error", "database": "unreachable"}, status=503)
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
def error_403(request, exception=None):
|
||||
return render(request, "errors/403.html", status=403)
|
||||
|
||||
|
||||
def error_404(request, exception=None):
|
||||
return render(request, "errors/404.html", status=404)
|
||||
@@ -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:
|
||||
Executable
+15
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==9.2.0
|
||||
pytest-django==4.14.0
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
Executable
+32
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user