Files
Kochbuch/accounts/views.py
T
Kyle DammannandClaude Opus 5.5 ef9deaeff7 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]>
2026-10-30 21:24:59 +01:00

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})