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]>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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
|
||||
Reference in New Issue
Block a user