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:
Kyle Dammann
2026-10-30 21:24:59 +01:00
co-authored by Claude Opus 5.5
commit ef9deaeff7
68 changed files with 2558 additions and 0 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
import pytest
from django.test import Client
from accounts.models import User
PASSWORD = "Sehr-sicheres-Passwort-42"
@pytest.fixture
def admin_user(db):
return User.objects.create_admin(email="[email protected]", password=PASSWORD, display_name="Admin")
@pytest.fixture
def user(db):
return User.objects.create_user(email="[email protected]", password=PASSWORD, display_name="Anna")
@pytest.fixture
def other_user(db):
return User.objects.create_user(email="[email protected]", password=PASSWORD, display_name="Ben")
def logged_in_client(u):
c = Client()
c.force_login(u)
return c
@pytest.fixture
def admin_client(admin_user):
return logged_in_client(admin_user)
@pytest.fixture
def user_client(user):
return logged_in_client(user)
+70
View File
@@ -0,0 +1,70 @@
import pytest
from django.test import Client
from django.urls import reverse
from accounts.models import Invitation, User
from .conftest import PASSWORD
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"])
def test_admin_pages_forbidden_for_users(user_client, name):
assert user_client.get(reverse(name)).status_code == 403
@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"])
def test_admin_pages_ok_for_admin(admin_client, name):
assert admin_client.get(reverse(name)).status_code == 200
@pytest.mark.parametrize("action", ["make_admin", "deactivate", "reset_link", "temp_password"])
def test_user_actions_forbidden_for_users(user_client, other_user, action):
response = user_client.post(reverse("accounts:admin_user_action", args=[other_user.pk, action]))
assert response.status_code == 403
other_user.refresh_from_db()
assert other_user.role == User.Role.USER and other_user.is_active and other_user.check_password(PASSWORD)
def test_user_cannot_create_invitation(user_client):
user_client.post(reverse("accounts:admin_invitations"), {"role": "admin", "valid_days": 7})
assert not Invitation.objects.exists()
def test_admin_creates_invitation(admin_client):
response = admin_client.post(reverse("accounts:admin_invitations"), {"role": "user", "valid_days": 3, "note": "Oma"})
assert response.status_code == 200
assert "/einladung/" in response.context["value"]
assert Invitation.objects.get().note == "Oma"
def test_admin_can_promote_and_deactivate(admin_client, user):
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"]))
user.refresh_from_db()
assert user.role == User.Role.ADMIN
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
user.refresh_from_db()
assert not user.is_active
def test_deactivated_user_session_ends(admin_client, user):
session = Client()
session.force_login(user)
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
assert session.get(reverse("core:home")).status_code == 302
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
for action in ["make_user", "deactivate"]:
admin_client.post(reverse("accounts:admin_user_action", args=[admin_user.pk, action]))
admin_user.refresh_from_db()
assert admin_user.is_admin
def test_unknown_action_rejected(admin_client, user):
assert admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "delete"])).status_code == 403
def test_admin_actions_require_post(admin_client, user):
assert admin_client.get(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"])).status_code == 405
+269
View File
@@ -0,0 +1,269 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from accounts.models import Invitation, PasswordResetToken, User
from .conftest import PASSWORD
pytestmark = pytest.mark.django_db
NEW_PASSWORD = "Ganz-neues-Passwort-77"
def token_from(link):
return link.rstrip("/").rsplit("/", 1)[-1]
def login(client, email, password):
return client.post(reverse("accounts:login"), {"email": email, "password": password})
# --- Login ------------------------------------------------------------------------
def test_login_with_valid_credentials(user):
client = Client()
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
assert response.status_code == 302
assert client.get(reverse("core:home")).status_code == 200
def test_login_wrong_password_gives_generic_error(user):
response = login(Client(), user.email, "falsch")
assert response.status_code == 400
assert "E-Mail oder Passwort ist falsch." in response.content.decode()
def test_unknown_email_gives_same_error(db):
response = login(Client(), "[email protected]", "egal")
assert "E-Mail oder Passwort ist falsch." in response.content.decode()
def test_inactive_user_cannot_login(user):
user.is_active = False
user.save()
assert login(Client(), user.email, PASSWORD).status_code == 400
def test_password_is_hashed_with_argon2(user):
assert user.password.startswith("argon2")
def test_login_rate_limit_per_email(user):
client = Client()
for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL):
login(client, user.email, "falsch")
# Even the correct password is refused while locked.
response = login(client, user.email, PASSWORD)
assert response.status_code == 429
def test_login_rate_limit_per_ip(db, settings):
settings.LOGIN_MAX_FAILURES_PER_IP = 3
client = Client(REMOTE_ADDR="203.0.113.9")
for i in range(3):
login(client, f"x{i}@example.test", "falsch")
assert login(client, "[email protected]", "falsch").status_code == 429
# Other clients are unaffected.
assert login(Client(REMOTE_ADDR="203.0.113.10"), "[email protected]", "falsch").status_code == 400
def test_successful_login_resets_email_counter(user):
client = Client()
for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL - 1):
login(client, user.email, "falsch")
assert login(client, user.email, PASSWORD).status_code == 302
client.post(reverse("accounts:logout"))
login(client, user.email, "falsch")
assert login(client, user.email, PASSWORD).status_code == 302
def test_login_redirect_rejects_external_next(user):
response = Client().post(
reverse("accounts:login") + "?next=https://evil.example/",
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
)
assert response.status_code == 302
assert response["Location"] == reverse("core:home")
def test_logout_requires_post(user_client):
assert user_client.get(reverse("accounts:logout")).status_code == 405
assert user_client.post(reverse("accounts:logout")).status_code == 302
assert user_client.get(reverse("core:home")).status_code == 302
def test_login_requires_csrf_token(user):
client = Client(enforce_csrf_checks=True)
response = login(client, user.email, PASSWORD)
assert response.status_code == 403
# --- Login required everywhere ----------------------------------------------------
@pytest.mark.parametrize(
"url",
["/", "/konto/", "/konto/passwort/", "/verwaltung/nutzer/", "/verwaltung/einladungen/"],
)
def test_pages_require_login(db, url):
response = Client().get(url)
assert response.status_code == 302
assert response["Location"].startswith(reverse("accounts:login"))
def test_no_open_registration_route(db):
for url in ["/registrieren/", "/register/", "/signup/", "/einladung/"]:
assert Client().get(url).status_code in (302, 404)
# --- Invitations ------------------------------------------------------------------
def register(client, token, **data):
payload = {"email": "[email protected]", "display_name": "Neu", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD}
payload.update(data)
return client.post(reverse("accounts:register", args=[token]), payload)
def test_invitation_link_uses_app_base_url(admin_user):
_, link = Invitation.issue(created_by=admin_user)
assert link.startswith(settings.APP_BASE_URL + "/einladung/")
def test_register_with_valid_invitation(admin_user):
invitation, link = Invitation.issue(created_by=admin_user)
client = Client()
response = register(client, token_from(link))
assert response.status_code == 302
new_user = User.objects.get(email="[email protected]")
assert new_user.role == User.Role.USER
invitation.refresh_from_db()
assert invitation.used_by == new_user and invitation.status == "used"
assert client.get(reverse("core:home")).status_code == 200
def test_invitation_is_single_use(admin_user):
_, link = Invitation.issue(created_by=admin_user)
register(Client(), token_from(link))
response = register(Client(), token_from(link), email="[email protected]")
assert response.status_code == 404
assert not User.objects.filter(email="[email protected]").exists()
def test_expired_invitation_rejected(admin_user):
invitation, link = Invitation.issue(created_by=admin_user)
invitation.expires_at = timezone.now() - timedelta(minutes=1)
invitation.save()
assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404
def test_revoked_invitation_rejected(admin_client, admin_user):
invitation, link = Invitation.issue(created_by=admin_user)
admin_client.post(reverse("accounts:admin_invitation_revoke", args=[invitation.pk]))
assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404
def test_random_token_rejected(db):
assert Client().get(reverse("accounts:register", args=["nicht-gueltig"])).status_code == 404
def test_invitation_token_not_stored_in_plain_text(admin_user):
invitation, link = Invitation.issue(created_by=admin_user)
assert token_from(link) not in invitation.token_hash
def test_invitation_bound_to_email(admin_user):
_, link = Invitation.issue(created_by=admin_user, email="[email protected]")
register(Client(), token_from(link), email="[email protected]")
assert User.objects.filter(email="[email protected]").exists()
assert not User.objects.filter(email="[email protected]").exists()
def test_invitation_assigns_role(admin_user):
_, link = Invitation.issue(created_by=admin_user, role=User.Role.ADMIN)
register(Client(), token_from(link))
assert User.objects.get(email="[email protected]").role == User.Role.ADMIN
def test_register_rejects_existing_email(admin_user, user):
_, link = Invitation.issue(created_by=admin_user)
response = register(Client(), token_from(link), email=user.email.upper())
assert response.status_code == 200
assert User.objects.filter(email=user.email).count() == 1
def test_register_rejects_weak_password(admin_user):
_, link = Invitation.issue(created_by=admin_user)
response = register(Client(), token_from(link), password1="12345678", password2="12345678")
assert response.status_code == 200
assert not User.objects.filter(email="[email protected]").exists()
# --- Password reset by admin ------------------------------------------------------
def test_reset_link_flow(admin_user, user):
_, link = PasswordResetToken.issue(user, created_by=admin_user)
client = Client()
response = client.post(
reverse("accounts:password_reset", args=[token_from(link)]),
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
)
assert response.status_code == 302
user.refresh_from_db()
assert user.check_password(NEW_PASSWORD)
# Single use
assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404
def test_new_reset_link_invalidates_old(admin_user, user):
_, old = PasswordResetToken.issue(user, created_by=admin_user)
PasswordResetToken.issue(user, created_by=admin_user)
assert Client().get(reverse("accounts:password_reset", args=[token_from(old)])).status_code == 404
def test_reset_link_expires(admin_user, user):
token, link = PasswordResetToken.issue(user, created_by=admin_user)
token.expires_at = timezone.now() - timedelta(seconds=1)
token.save()
assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404
def test_reset_link_ends_existing_sessions(admin_user, user):
old_session = Client()
old_session.force_login(user)
_, link = PasswordResetToken.issue(user, created_by=admin_user)
Client().post(
reverse("accounts:password_reset", args=[token_from(link)]),
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
)
assert old_session.get(reverse("core:home")).status_code == 302
def test_temp_password_forces_change(admin_client, user):
response = admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "temp_password"]))
temp = response.context["value"]
client = Client()
assert login(client, user.email, temp).status_code == 302
# Every page redirects to the password change until a new password is set.
assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change")
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
assert client.get(reverse("core:home")).status_code == 200
user.refresh_from_db()
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
def test_own_password_change_requires_old_password(user_client, user):
response = user_client.post(
reverse("accounts:password_change"),
{"old_password": "falsch", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
)
assert response.status_code == 200
user.refresh_from_db()
assert user.check_password(PASSWORD)
+151
View File
@@ -0,0 +1,151 @@
from datetime import timedelta
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from django.http import HttpResponse
from django.test import Client, RequestFactory
from accounts.models import User
from core import ratelimit
from core.middleware import ProxyMiddleware
pytestmark = pytest.mark.django_db
# --- Proxy handling --------------------------------------------------------------
def run_proxy(remote_addr, **headers):
request = RequestFactory().get("/", REMOTE_ADDR=remote_addr, **headers)
ProxyMiddleware(lambda r: HttpResponse())(request)
return request
def test_forwarded_headers_from_trusted_proxy_are_used():
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7", HTTP_X_FORWARDED_PROTO="https")
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
assert request.is_secure()
def test_forwarded_headers_from_untrusted_peer_are_dropped():
request = run_proxy("203.0.113.50", HTTP_X_FORWARDED_FOR="10.0.0.1", HTTP_X_FORWARDED_PROTO="https")
assert request.META["REMOTE_ADDR"] == "203.0.113.50"
assert "HTTP_X_FORWARDED_FOR" not in request.META
assert not request.is_secure()
def test_spoofed_left_entries_in_forwarded_for_are_ignored():
# Client sent "X-Forwarded-For: 1.2.3.4"; the proxy appended the real client address.
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="1.2.3.4, 198.51.100.7")
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
def test_chained_trusted_proxies_are_skipped():
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7, 192.168.1.2")
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
def test_client_ip_header_only_from_trusted_proxy(settings):
settings.CLIENT_IP_HEADER = "CF-Connecting-IP"
trusted = run_proxy("172.18.0.5", HTTP_CF_CONNECTING_IP="198.51.100.8", HTTP_X_FORWARDED_FOR="162.158.1.1")
assert trusted.META["REMOTE_ADDR"] == "198.51.100.8"
untrusted = run_proxy("203.0.113.50", HTTP_CF_CONNECTING_IP="198.51.100.8")
assert untrusted.META["REMOTE_ADDR"] == "203.0.113.50"
# --- Security headers & health -----------------------------------------------------
def test_security_headers(db):
response = Client().get("/anmelden/")
assert "default-src 'self'" in response["Content-Security-Policy"]
assert response["X-Frame-Options"] == "DENY"
assert response["X-Content-Type-Options"] == "nosniff"
assert response["Referrer-Policy"] == "same-origin"
assert "Permissions-Policy" in response
def test_hsts_on_secure_requests(db):
response = Client().get("/anmelden/", secure=True)
assert "max-age=" in response["Strict-Transport-Security"]
def test_session_cookie_flags(user):
from django.conf import settings
from .conftest import PASSWORD
response = Client().post("/anmelden/", {"email": user.email, "password": PASSWORD})
cookie = response.cookies[settings.SESSION_COOKIE_NAME]
assert cookie["httponly"]
assert cookie["samesite"] == "Lax"
assert bool(cookie["secure"]) == settings.COOKIE_SECURE
def test_authenticated_pages_not_cacheable(user_client):
assert "no-store" in user_client.get("/")["Cache-Control"]
def test_healthz(db):
response = Client().get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
# --- Rate limiter ------------------------------------------------------------------
def test_ratelimit_counts_and_resets():
window = timedelta(minutes=1)
assert ratelimit.hit("k", 2, window)
assert ratelimit.hit("k", 2, window)
assert ratelimit.is_limited("k", 2, window)
assert not ratelimit.hit("k", 2, window)
ratelimit.reset("k")
assert not ratelimit.is_limited("k", 2, window)
def test_ratelimit_zero_means_unlimited():
for _ in range(50):
assert ratelimit.hit("u", 0, timedelta(minutes=1))
assert not ratelimit.is_limited("u", 0, timedelta(minutes=1))
def test_ratelimit_window_expires():
window = timedelta(seconds=0)
ratelimit.hit("w", 1, window)
assert not ratelimit.is_limited("w", 1, window)
assert ratelimit.hit("w", 1, window)
# --- First admin from environment --------------------------------------------------
def test_ensure_admin_creates_admin_once(monkeypatch):
monkeypatch.setenv("ADMIN_EMAIL", "[email protected]")
monkeypatch.setenv("ADMIN_PASSWORD", "Start-Passwort-2026")
call_command("ensure_admin")
admin = User.objects.get(email="[email protected]")
assert admin.is_admin and admin.check_password("Start-Passwort-2026")
# A second run with a different password must not touch the existing account.
monkeypatch.setenv("ADMIN_PASSWORD", "Anderes-Passwort-2026")
call_command("ensure_admin")
admin.refresh_from_db()
assert admin.check_password("Start-Passwort-2026")
assert User.objects.count() == 1
def test_ensure_admin_rejects_weak_password(monkeypatch):
monkeypatch.setenv("ADMIN_EMAIL", "[email protected]")
monkeypatch.setenv("ADMIN_PASSWORD", "123")
with pytest.raises(CommandError):
call_command("ensure_admin")
assert not User.objects.exists()
def test_ensure_admin_without_env_does_nothing(monkeypatch):
monkeypatch.delenv("ADMIN_EMAIL", raising=False)
call_command("ensure_admin")
assert not User.objects.exists()