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:
co-authored by
Claude Sonnet 5.5
parent
3fd8f27127
commit
94c1d39c9d
+111
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user