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
+358
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user