- 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]>
157 lines
6.8 KiB
Python
157 lines
6.8 KiB
Python
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})
|