- 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]>
108 lines
3.9 KiB
Python
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
|