feat: add e-mail change with confirmation and undo

Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
This commit is contained in:
Ebbe Baß
2026-10-19 10:05:41 +02:00
co-authored by Claude Sonnet 5.5
parent 3fd8f27127
commit 94c1d39c9d
35 changed files with 1792 additions and 14 deletions
+22
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import re
from contextvars import ContextVar
from datetime import UTC, datetime
@@ -13,6 +14,25 @@ request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
# One-time links carry their token in the path (AD-9); a log line must never hold it.
_LINK_TOKEN = re.compile(r"(/email/(?:confirm|undo)/)[^/\s\"']+")
def redact(text: str) -> str:
return _LINK_TOKEN.sub(r"\1<redacted>", text)
class RedactLinkTokens(logging.Filter):
"""Blank the token of a one-time link in a record's message."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
redacted = redact(message)
if redacted != message:
record.msg, record.args = redacted, ()
return True
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
@@ -37,11 +57,13 @@ def build_logging(level: str) -> dict:
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": "portal.config.logging.JsonFormatter"}},
"filters": {"redact_link_tokens": {"()": "portal.config.logging.RedactLinkTokens"}},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
"filters": ["redact_link_tokens"],
}
},
"loggers": {"django.request": {"level": "ERROR"}},