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
+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")