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
+358 -1
View File
@@ -1,4 +1,4 @@
"""Password change and profile details of the signed-in user (FR-5..FR-9; AD-3, AD-4, AD-8).
"""Password change, profile details and e-mail change (FR-5..FR-11; AD-3, AD-4, AD-8, AD-9).
The order is fixed: throttle check, policy read, verification of the current password, local
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
@@ -8,8 +8,10 @@ stored or put into an exception.
from __future__ import annotations
import hashlib
import logging
import math
import secrets
from dataclasses import dataclass, replace
from datetime import timedelta
from zoneinfo import ZoneInfo
@@ -35,6 +37,7 @@ from portal.audit import services as audit
from portal.mailer import services as mailer
from .forms import DETAIL_FIELDS, check_details
from .models import PendingEmailChange
logger = logging.getLogger(__name__)
@@ -455,3 +458,357 @@ def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> Det
_record_details_failure(user, ip, "error")
raise
return DetailsUpdated(changed, updated)
# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
#
# Request: validate locally (the form), throttle.check, verify_password, then the pending row,
# the queued mail and the audit entry in one transaction. Confirm and undo: the token claim,
# the directory write, the other rows, the queued mail and the audit entry in one transaction.
# Addresses, tokens and passwords never go to a log line or an audit entry.
REQUEST_ENDPOINT = "email-change-request" # five requests per hour per user
REQUEST_LIMIT = 5
REQUEST_WINDOW_SECONDS = 60 * 60
CONFIRM_VALID_FOR = timedelta(hours=1)
UNDO_VALID_FOR = timedelta(hours=24)
MAX_TOKEN_LENGTH = 128
class EmailInvalid(Exception):
"""The new address was refused after the directory was read; `reason` is `same`."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__("The new e-mail address is not usable")
class LinkDead(Exception):
"""The token is unknown, used, expired or replaced. Never says which."""
class ChangedAgain(Exception):
"""The address in the directory is no longer the one that was confirmed."""
@dataclass(frozen=True)
class Confirmed:
notice_queued: bool
@dataclass(frozen=True)
class Undone:
admin_notified: bool
def new_token() -> tuple[str, str]:
"""(token for the link, hash for the database)."""
raw = secrets.token_urlsafe(32)
return raw, hash_token(raw)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def find_link(token: str, kind: str) -> PendingEmailChange | None:
"""The unused, unexpired row of this kind for the token, or None. Changes nothing."""
if not token or len(token) > MAX_TOKEN_LENGTH:
return None
return PendingEmailChange.objects.filter(
token_hash=hash_token(token),
kind=kind,
used_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
def _link(path: str, token: str) -> str:
return f"{settings.OIDC.public_base_url}/email/{path}/{token}"
def _display_name(account: DirectoryUser) -> str:
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
return name or account.sam
def _local_now():
return timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
def _email_failure(user: SessionUser, ip: str, action: str, reason: str, **detail) -> None:
audit.record_failure(user.key, action, ip=ip, detail={"reason": reason, **detail})
def request_email_change(user: SessionUser, new_address: str, current: str, *, ip: str) -> None:
"""Ask for a confirmation link at `new_address`. The directory stays unchanged.
`new_address` has passed the form's checks. Raises Throttled, WrongCurrentPassword,
EmailInvalid or DirectoryUnavailable. Must be called outside an open transaction.
"""
action = "email.change_failed"
try:
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
throttle.check(REQUEST_ENDPOINT, user=user.key)
except throttle.Throttled:
audit.record_failure(user.key, "email.throttled", ip=ip)
raise
directory = get_directory()
try:
policy = directory.password_policy(user.sam)
if not directory.verify_password(user.sam, current):
throttle.hit(
ENDPOINT,
user=user.key,
ip=ip or None,
max_failures=failure_limit(policy),
pause_seconds=pause_seconds(policy),
)
_email_failure(user, ip, action, "wrong_current", **_threshold_note(policy))
raise WrongCurrentPassword(policy)
account = directory.find_user(user.sam)
if account is None:
raise DirectoryUnavailable("The account could not be read")
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
throttle.reset(ENDPOINT, user=user.key) # the directory forgets failures after a good bind
if new_address.casefold() == (account.mail or "").strip().casefold():
raise EmailInvalid("same")
raw, digest = new_token()
now = timezone.now()
try:
with transaction.atomic():
PendingEmailChange.objects.filter(
user_key=user.key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
).delete() # the newest request replaces the earlier one
PendingEmailChange.objects.create(
user_key=user.key,
sam=user.sam,
kind=PendingEmailChange.Kind.CONFIRM,
token_hash=digest,
old_address=(account.mail or "").strip(),
new_address=new_address,
created_at=now,
expires_at=now + CONFIRM_VALID_FOR,
)
mailer.enqueue(
"email_change_confirm",
new_address,
{"name": _display_name(account), "link": _link("confirm", raw)},
)
audit.record(user.key, "email.change_requested", ip=ip)
except Exception:
_email_failure(user, ip, action, "error")
raise
throttle.hit(
REQUEST_ENDPOINT,
user=user.key,
max_failures=REQUEST_LIMIT,
pause_seconds=REQUEST_WINDOW_SECONDS,
)
def _unclear(user: SessionUser, ip: str, action: str, what: str, step: str, exc: Exception):
"""The directory changed but the portal's part rolled back (AD-4): log, never throw."""
logger.error(
"E-mail %s in the directory but the portal did not record it: "
"user=%s ip=%s time=%s step=%s error=%s",
what,
user.key,
ip,
timezone.now().isoformat(),
step,
type(exc).__name__,
)
try:
_email_failure(user, ip, action, "unclear", step=step)
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
def confirm_email_change(token: str, *, ip: str) -> Confirmed:
"""Write the new address to the directory and mail the old one an undo link.
Raises LinkDead, DirectoryUnavailable or OutcomeUnknown. Must be called outside an open
transaction.
"""
action = "email.change_failed"
row = find_link(token, PendingEmailChange.Kind.CONFIRM)
if row is None:
raise LinkDead
user = SessionUser(key=row.user_key, sam=row.sam)
directory = get_directory()
try:
account = directory.find_user(row.sam)
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable", stage="confirm")
raise
if account is None:
raise LinkDead
previous = (account.mail or "").strip()
notice = mailer.is_valid_address(previous)
changed_in_directory = False
step = "claim"
try:
with transaction.atomic():
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
used_at=timezone.now()
)
if not claimed: # a parallel request used it first
raise LinkDead
step = "directory"
directory.update_attributes(row.sam, {"mail": row.new_address})
changed_in_directory = True
if notice:
step = "queue_notice"
raw, digest = new_token()
now = timezone.now()
PendingEmailChange.objects.create(
user_key=row.user_key,
sam=row.sam,
kind=PendingEmailChange.Kind.UNDO,
token_hash=digest,
old_address=previous,
new_address=row.new_address,
created_at=now,
expires_at=now + UNDO_VALID_FOR,
)
mailer.enqueue(
"email_changed_notice",
previous,
{
"name": _display_name(account),
"new_address": row.new_address,
"link": _link("undo", raw),
"changed_at": _local_now(),
"zone_name": settings.DISPLAY_TIME_ZONE,
},
)
step = "audit"
audit.record(
user.key,
"email.changed",
ip=ip,
detail={"notice": "queued" if notice else "skipped"},
)
except LinkDead:
raise
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable", stage="confirm")
raise
except (ValidationFailed, Forbidden) as exc:
logger.error(
"The directory refused an e-mail write: user=%s ip=%s error=%s",
user.key,
ip,
type(exc).__name__,
)
_email_failure(user, ip, action, "refused", stage="confirm")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
_unclear(user, ip, action, "address changed", step, exc)
raise OutcomeUnknown("The address was changed but not recorded") from exc
_email_failure(user, ip, action, "error", stage="confirm")
raise
return Confirmed(notice_queued=notice)
def undo_email_change(token: str, *, ip: str) -> Undone:
"""Restore the old address, end every session of the user and tell the Admin.
Raises LinkDead, ChangedAgain, DirectoryUnavailable or OutcomeUnknown. Must be called
outside an open transaction.
"""
action = "email.undo_failed"
row = find_link(token, PendingEmailChange.Kind.UNDO)
if row is None:
raise LinkDead
user = SessionUser(key=row.user_key, sam=row.sam)
directory = get_directory()
try:
account = directory.find_user(row.sam)
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
if account is None:
raise LinkDead
if (account.mail or "").strip().casefold() != row.new_address.casefold():
raise ChangedAgain
admin = settings.ADMIN_NOTIFY_EMAIL
changed_in_directory = False
step = "claim"
try:
with transaction.atomic():
claimed = PendingEmailChange.objects.filter(pk=row.pk, used_at__isnull=True).update(
used_at=timezone.now()
)
if not claimed:
raise LinkDead
step = "directory"
directory.update_attributes(row.sam, {"mail": row.old_address})
changed_in_directory = True
step = "end_sessions" # after the write, so a rolled-back write ends nothing
ended = end_sessions(user)
# A request made meanwhile (by whoever had the session) must not outlive the undo.
PendingEmailChange.objects.filter(
user_key=row.user_key, kind=PendingEmailChange.Kind.CONFIRM, used_at__isnull=True
).delete()
if admin:
step = "queue_admin_notice"
mailer.enqueue(
"email_undone_admin",
admin,
{
"username": row.sam,
"undone_at": _local_now(),
"zone_name": settings.DISPLAY_TIME_ZONE,
},
)
step = "audit"
audit.record(
user.key,
"email.undone",
ip=ip,
detail={
"sessions_ended": ended,
"admin_notice": "queued" if admin else "skipped",
},
)
except LinkDead:
raise
except DirectoryUnavailable:
_email_failure(user, ip, action, "unavailable")
raise
except (ValidationFailed, Forbidden) as exc:
logger.error(
"The directory refused an e-mail write: user=%s ip=%s error=%s",
user.key,
ip,
type(exc).__name__,
)
_email_failure(user, ip, action, "refused")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
try:
end_sessions(user)
except Exception:
logger.error("The sessions of %s could not be ended", user.key)
_unclear(user, ip, action, "address restored", step, exc)
raise OutcomeUnknown("The address was restored but not recorded") from exc
_email_failure(user, ip, action, "error")
raise
if not admin:
logger.error(
"ADMIN_NOTIFY_EMAIL is not set: the Admin was not told that user=%s undid an "
"e-mail change",
user.key,
)
return Undone(admin_notified=bool(admin))