- 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]>
138 lines
5.6 KiB
Python
138 lines
5.6 KiB
Python
"""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")
|