Files
Kochbuch/accounts/forms.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

108 lines
3.9 KiB
Python

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