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