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
+26
View File
@@ -5,6 +5,8 @@ from django import forms
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from portal.mailer.address import is_valid_address
MAX_PASSWORD_LENGTH = 256 # Active Directory's limit
@@ -163,3 +165,27 @@ class DetailsForm(forms.Form):
def apply_errors(self, errors: dict[str, str]) -> None:
for name, message in errors.items():
self.add_error(name, message)
# -- e-mail change ----------------------------------------------------------------------------
MAX_ADDRESS_LENGTH = 254
class EmailChangeForm(forms.Form):
new_email = forms.CharField(
strip=True,
required=False, # the messages below say it better than the default
widget=forms.EmailInput(),
)
current_password = _password_field()
def clean_new_email(self):
value = self.cleaned_data["new_email"]
if not value:
raise forms.ValidationError(_("Please fill in this field."))
if len(value) > MAX_ADDRESS_LENGTH:
raise forms.ValidationError(_("This e-mail address is too long."))
if not is_valid_address(value):
raise forms.ValidationError(_("Please enter a valid e-mail address."))
return value
+29
View File
@@ -0,0 +1,29 @@
# Generated by Django 5.2.18 on 2026-10-19 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='PendingEmailChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_key', models.CharField(db_index=True, max_length=256)),
('sam', models.CharField(max_length=256)),
('kind', models.CharField(choices=[('confirm', 'Confirm'), ('undo', 'Undo')], max_length=10)),
('token_hash', models.CharField(max_length=64, unique=True)),
('old_address', models.CharField(blank=True, max_length=254)),
('new_address', models.CharField(max_length=254)),
('created_at', models.DateTimeField()),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
],
),
]
+25
View File
@@ -0,0 +1,25 @@
from django.db import models
class PendingEmailChange(models.Model):
"""A one-time link for an e-mail change. Owned by `profile` (AD-19).
The token itself is never stored, only its SHA-256 hash (AD-9). A `confirm` row holds the
address the user asked for (`new_address`) and the one they had then (`old_address`). An
`undo` row holds the address to restore (`old_address`) and the one that was confirmed
(`new_address`).
"""
class Kind(models.TextChoices):
CONFIRM = "confirm"
UNDO = "undo"
user_key = models.CharField(max_length=256, db_index=True) # lower case
sam = models.CharField(max_length=256) # exact sAMAccountName, for directory calls
kind = models.CharField(max_length=10, choices=Kind.choices)
token_hash = models.CharField(max_length=64, unique=True)
old_address = models.CharField(max_length=254, blank=True)
new_address = models.CharField(max_length=254)
created_at = models.DateTimeField()
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
+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))
+3
View File
@@ -6,4 +6,7 @@ urlpatterns = [
path("profile", views.profile, name="profile"),
path("password", views.password, name="password"),
path("password/done", views.password_done, name="password-done"),
path("email", views.email, name="email"),
path("email/confirm/<str:token>", views.email_confirm, name="email-confirm"),
path("email/undo/<str:token>", views.email_undo, name="email-undo"),
]
+111 -2
View File
@@ -1,4 +1,4 @@
"""The password page (FR-5..FR-7). No business logic here: see services.py (AD-17)."""
"""The password, profile and e-mail pages (FR-5..FR-11). No business logic here (AD-17)."""
from __future__ import annotations
@@ -6,6 +6,7 @@ import logging
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
from django.views.decorators.cache import never_cache
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
@@ -16,7 +17,8 @@ from portal.adapters.directory import DirectoryUnavailable
from portal.adapters.registry import get_directory
from . import services
from .forms import DETAIL_FIELDS, DetailsForm, PasswordChangeForm
from .forms import DETAIL_FIELDS, DetailsForm, EmailChangeForm, PasswordChangeForm
from .models import PendingEmailChange
logger = logging.getLogger(__name__)
@@ -159,3 +161,110 @@ def profile(request):
remember(request, result.account) # this session shows the new name at once
request.session[NOTICE_FLAG] = "saved" if result.changed else "unchanged"
return redirect("profile")
# -- e-mail change (FR-10, FR-11) -------------------------------------------------------------
EMAIL_SENT_FLAG = "email_change_sent"
def _email_page(request, form, current, *, status=200, throttled=False):
context = {"form": form, "current": current, "throttled": throttled}
return render(request, "profile/email.html", context, status=status)
@login_required
@sensitive_post_parameters("current_password")
@require_http_methods(["GET", "POST"])
def email(request):
user = request.portal_user.user
if request.method == "GET":
try:
details = services.load_details(user)
except DirectoryUnavailable:
return _unavailable(request)
if request.session.pop(EMAIL_SENT_FLAG, None):
return render(request, "profile/email_sent.html", {"current": details.mail})
return _email_page(request, EmailChangeForm(), details.mail)
form = EmailChangeForm(request.POST)
if not form.is_valid(): # no directory call before the throttle check (AD-7)
return _email_page(request, form, None, status=400)
try:
services.request_email_change(
user,
form.cleaned_data["new_email"],
form.cleaned_data["current_password"],
ip=request.client_ip,
)
except Throttled:
return _email_page(request, EmailChangeForm(), None, status=429, throttled=True)
except services.WrongCurrentPassword:
form.add_error("current_password", _("The current password is not correct."))
return _email_page(request, form, _current_address(user), status=400)
except services.EmailInvalid:
form.add_error("new_email", _("This is already your e-mail address."))
return _email_page(request, form, _current_address(user), status=400)
except DirectoryUnavailable:
return _unavailable(request)
request.session[EMAIL_SENT_FLAG] = True
return redirect("email")
def _current_address(user) -> str | None:
try:
return services.load_details(user).mail
except DirectoryUnavailable:
return None
def _dead_link(request):
return render(request, "profile/email_link_dead.html", status=410)
@never_cache
@sensitive_post_parameters()
@require_http_methods(["GET", "POST"])
def email_confirm(request, token):
"""No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
if request.method == "GET":
row = services.find_link(token, PendingEmailChange.Kind.CONFIRM)
if row is None:
return _dead_link(request)
return render(request, "profile/email_confirm.html", {"change": row})
try:
result = services.confirm_email_change(token, ip=request.client_ip)
except services.LinkDead:
return _dead_link(request)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/email_unknown.html", status=500)
return render(request, "profile/email_confirmed.html", {"notice": result.notice_queued})
@never_cache
@sensitive_post_parameters()
@require_http_methods(["GET", "POST"])
def email_undo(request, token):
"""No sign-in needed: the link proves the mailbox. GET only shows (AD-9)."""
if request.method == "GET":
row = services.find_link(token, PendingEmailChange.Kind.UNDO)
if row is None:
return _dead_link(request)
return render(request, "profile/email_undo.html", {"change": row})
try:
services.undo_email_change(token, ip=request.client_ip)
except services.LinkDead:
return _dead_link(request)
except services.ChangedAgain:
return render(request, "profile/email_changed_again.html", status=409)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/email_unknown.html", status=500)
return render(request, "profile/email_undone.html")