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
815 lines
29 KiB
Python
815 lines
29 KiB
Python
"""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
|
|
one transaction. Passwords live only in local variables here: they are never logged, audited,
|
|
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
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext as _
|
|
from django.utils.translation import ngettext
|
|
|
|
from portal.accounts import throttle
|
|
from portal.accounts.sessions import SessionUser, end_sessions
|
|
from portal.adapters.directory import (
|
|
DirectoryUnavailable,
|
|
DirectoryUser,
|
|
Forbidden,
|
|
PasswordPolicy,
|
|
PasswordRejected,
|
|
ValidationFailed,
|
|
)
|
|
from portal.adapters.registry import get_directory
|
|
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__)
|
|
|
|
ENDPOINT = "password-change"
|
|
CHARACTER_GROUPS_REQUIRED = 3 # AD complexity: three of upper, lower, digit, other
|
|
|
|
|
|
class WrongCurrentPassword(Exception):
|
|
def __init__(self, policy: PasswordPolicy | None = None) -> None:
|
|
self.policy = policy
|
|
super().__init__("The current password is not correct")
|
|
|
|
|
|
class PasswordNotAccepted(Exception):
|
|
"""The new password breaks a rule. `reasons` holds keys for rules the portal could name."""
|
|
|
|
def __init__(self, reasons: list[str], policy: PasswordPolicy | None = None) -> None:
|
|
self.reasons = reasons
|
|
self.policy = policy
|
|
super().__init__("The new password is not accepted")
|
|
|
|
|
|
class OutcomeUnknown(Exception):
|
|
"""The directory accepted the new password but the portal could not finish its own part."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Changed:
|
|
mail_queued: bool
|
|
other_sessions_ended: int
|
|
|
|
|
|
# -- the rules, in words ----------------------------------------------------------------------
|
|
|
|
|
|
def describe_policy(policy: PasswordPolicy) -> list[str]:
|
|
"""The user's password rules as sentences in the active language."""
|
|
rules = []
|
|
if policy.min_length:
|
|
rules.append(
|
|
ngettext(
|
|
"At least %(n)s character",
|
|
"At least %(n)s characters",
|
|
policy.min_length,
|
|
)
|
|
% {"n": policy.min_length}
|
|
)
|
|
if policy.complexity:
|
|
rules.append(
|
|
_(
|
|
"Characters from at least three of these groups: upper case letters, "
|
|
"lower case letters, digits and special characters"
|
|
)
|
|
)
|
|
rules.append(_("No part of your user name or display name"))
|
|
if policy.history_length:
|
|
rules.append(
|
|
ngettext(
|
|
"Not your last %(n)s password",
|
|
"Not one of your last %(n)s passwords",
|
|
policy.history_length,
|
|
)
|
|
% {"n": policy.history_length}
|
|
)
|
|
if policy.min_age:
|
|
rules.append(_min_age_rule(policy.min_age))
|
|
if policy.max_age:
|
|
days = max(1, round(policy.max_age.total_seconds() / 86400))
|
|
rules.append(
|
|
ngettext(
|
|
"A password is valid for %(n)s day",
|
|
"A password is valid for %(n)s days",
|
|
days,
|
|
)
|
|
% {"n": days}
|
|
)
|
|
return rules
|
|
|
|
|
|
def _min_age_rule(age: timedelta) -> str:
|
|
if age >= timedelta(days=1):
|
|
days = math.ceil(age.total_seconds() / 86400) # round up: too early is refused
|
|
return ngettext(
|
|
"A new password can be changed again after %(n)s day",
|
|
"A new password can be changed again after %(n)s days",
|
|
days,
|
|
) % {"n": days}
|
|
hours = max(1, math.ceil(age.total_seconds() / 3600))
|
|
return ngettext(
|
|
"A new password can be changed again after %(n)s hour",
|
|
"A new password can be changed again after %(n)s hours",
|
|
hours,
|
|
) % {"n": hours}
|
|
|
|
|
|
def _character_groups(password: str) -> int:
|
|
groups = set()
|
|
for char in password:
|
|
if char.isupper():
|
|
groups.add("upper")
|
|
elif char.islower():
|
|
groups.add("lower")
|
|
elif char.isdigit():
|
|
groups.add("digit")
|
|
else:
|
|
groups.add("other")
|
|
return len(groups)
|
|
|
|
|
|
def local_hints(new: str, policy: PasswordPolicy) -> list[str]:
|
|
"""Rule keys the portal can check itself; the directory stays the authority.
|
|
|
|
Nothing is claimed when a fine-grained policy applies but could not be read: the domain
|
|
values may be stricter or looser than the rules that really apply.
|
|
"""
|
|
if policy.fine_grained and not policy.fine_grained_readable:
|
|
return []
|
|
reasons = []
|
|
if len(new) < policy.min_length:
|
|
reasons.append("too_short")
|
|
if policy.complexity and _character_groups(new) < CHARACTER_GROUPS_REQUIRED:
|
|
reasons.append("too_few_groups")
|
|
return reasons
|
|
|
|
|
|
# -- the limit -----------------------------------------
|
|
|
|
|
|
def failure_limit(policy: PasswordPolicy) -> int:
|
|
"""Wrong current passwords allowed: below the directory's lockout threshold (FR-6)."""
|
|
threshold = policy.lockout_threshold
|
|
if threshold <= 0:
|
|
return throttle.THROTTLE_MAX_FAILURES
|
|
return max(1, min(throttle.THROTTLE_MAX_FAILURES, threshold - 1))
|
|
|
|
|
|
def pause_seconds(policy: PasswordPolicy) -> int:
|
|
window = int(policy.lockout_window.total_seconds()) if policy.lockout_window else 0
|
|
return max(throttle.THROTTLE_PAUSE_SECONDS, window)
|
|
|
|
|
|
# -- the change -----------------------------------------
|
|
|
|
|
|
def _threshold_note(policy: PasswordPolicy | None) -> dict:
|
|
"""Audit note for a lockout threshold of 1, which the portal cannot protect against."""
|
|
if policy is not None and policy.lockout_threshold == 1:
|
|
return {"lockout_protection": "none"}
|
|
return {}
|
|
|
|
|
|
def _record_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
|
|
audit.record_failure(
|
|
user.key, "password.change_failed", ip=ip, detail={"reason": reason, **detail}
|
|
)
|
|
|
|
|
|
def change_password(
|
|
user: SessionUser, current: str, new: str, *, ip: str, session_key: str | None
|
|
) -> Changed:
|
|
"""Change the signed-in user's password.
|
|
|
|
Raises Throttled, WrongCurrentPassword, PasswordNotAccepted, DirectoryUnavailable or
|
|
OutcomeUnknown. Must be called outside an open transaction.
|
|
"""
|
|
try:
|
|
throttle.check(ENDPOINT, user=user.key, ip=ip or None)
|
|
except throttle.Throttled:
|
|
audit.record_failure(user.key, "password.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),
|
|
)
|
|
_record_failure(user, ip, "wrong_current", **_threshold_note(policy))
|
|
raise WrongCurrentPassword(policy)
|
|
account = directory.find_user(user.sam)
|
|
except DirectoryUnavailable:
|
|
_record_failure(user, ip, "unavailable")
|
|
raise
|
|
|
|
reasons = local_hints(new, policy)
|
|
if reasons:
|
|
_record_failure(user, ip, "policy", rules=reasons)
|
|
raise PasswordNotAccepted(reasons, policy)
|
|
|
|
changed_in_directory = False
|
|
step = "directory"
|
|
try:
|
|
with transaction.atomic():
|
|
directory.change_password(user.sam, current, new)
|
|
changed_in_directory = True
|
|
step = "end_sessions"
|
|
ended = end_sessions(user, except_session_key=session_key)
|
|
step = "queue_mail"
|
|
mail_queued = _queue_mail(account)
|
|
step = "audit"
|
|
audit.record(
|
|
user.key,
|
|
"password.changed",
|
|
ip=ip,
|
|
detail={
|
|
"other_sessions_ended": ended,
|
|
"mail": "queued" if mail_queued else "skipped",
|
|
**_threshold_note(policy),
|
|
},
|
|
)
|
|
except PasswordRejected as exc:
|
|
if exc.reason == "wrong_current": # the password changed between the check and the change
|
|
throttle.hit(
|
|
ENDPOINT,
|
|
user=user.key,
|
|
ip=ip or None,
|
|
max_failures=failure_limit(policy),
|
|
pause_seconds=pause_seconds(policy),
|
|
)
|
|
_record_failure(user, ip, "wrong_current", **_threshold_note(policy))
|
|
raise WrongCurrentPassword(policy) from exc
|
|
_record_failure(user, ip, "policy")
|
|
raise PasswordNotAccepted([], policy) from exc
|
|
except DirectoryUnavailable:
|
|
_record_failure(user, ip, "unavailable")
|
|
raise
|
|
except Exception as exc:
|
|
if changed_in_directory:
|
|
# AD-4: the directory changed but the portal's part did not commit.
|
|
_after_unclear_outcome(user, ip, session_key, step, exc)
|
|
raise OutcomeUnknown("The password was changed but not recorded") from exc
|
|
raise
|
|
|
|
throttle.reset(ENDPOINT, user=user.key)
|
|
return Changed(mail_queued=mail_queued, other_sessions_ended=ended)
|
|
|
|
|
|
def _after_unclear_outcome(
|
|
user: SessionUser, ip: str, session_key: str | None, step: str, exc: Exception
|
|
) -> None:
|
|
"""The directory has the new password but the portal's part rolled back.
|
|
|
|
Log it with the change details (AD-4, never the exception text, which could hold input),
|
|
then do what can still be done: end the other sessions and write a failure entry. The
|
|
database may be the very thing that failed, so each attempt stands alone.
|
|
"""
|
|
logger.error(
|
|
"Password changed in the directory but the portal did not record it: "
|
|
"user=%s ip=%s time=%s step=%s error=%s",
|
|
user.key,
|
|
ip,
|
|
timezone.now().isoformat(),
|
|
step,
|
|
type(exc).__name__,
|
|
)
|
|
try:
|
|
end_sessions(user, except_session_key=session_key)
|
|
except Exception:
|
|
logger.error("The other sessions of %s could not be ended", user.key)
|
|
try:
|
|
_record_failure(user, ip, "unclear", step=step)
|
|
except Exception:
|
|
logger.error("The failure entry for %s could not be written", user.key)
|
|
|
|
|
|
def _queue_mail(account) -> bool:
|
|
"""Queue the confirmation e-mail inside the caller's transaction; False if there is none."""
|
|
address = (account.mail if account else "") or ""
|
|
if not mailer.is_valid_address(address):
|
|
return False
|
|
name = account.display_name or " ".join(p for p in (account.given_name, account.surname) if p)
|
|
now = timezone.now().astimezone(ZoneInfo(settings.DISPLAY_TIME_ZONE))
|
|
mailer.enqueue(
|
|
"password_changed",
|
|
address,
|
|
{"name": name or account.sam, "changed_at": now, "zone_name": settings.DISPLAY_TIME_ZONE},
|
|
)
|
|
return True
|
|
|
|
|
|
# -- profile details (FR-9) -------------------------------------------------------------------
|
|
|
|
# Form field -> (directory attribute, DirectoryUser field). Only these five are ever written.
|
|
DETAIL_ATTRIBUTES = {
|
|
"first_name": ("givenName", "given_name"),
|
|
"last_name": ("sn", "surname"),
|
|
"display_name": ("displayName", "display_name"),
|
|
"telephone": ("telephoneNumber", "telephone"),
|
|
"mobile": ("mobile", "mobile"),
|
|
}
|
|
|
|
|
|
class DetailsInvalid(Exception):
|
|
"""Values were refused before anything was written; `errors` maps a field to its message."""
|
|
|
|
def __init__(self, errors: dict[str, str], details: Details) -> None:
|
|
self.errors = errors
|
|
self.details = details # what the one directory read held, for the page
|
|
super().__init__("The details are not valid")
|
|
|
|
|
|
class DetailsNotAccepted(Exception):
|
|
"""The directory refused a value."""
|
|
|
|
def __init__(self, details: Details) -> None:
|
|
self.details = details
|
|
super().__init__("The directory refused a value")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Details:
|
|
username: str
|
|
mail: str
|
|
values: dict[str, str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DetailsUpdated:
|
|
changed: tuple[str, ...] # form field names, in page order
|
|
account: DirectoryUser # the user as the directory now holds them
|
|
|
|
|
|
def _values_of(account: DirectoryUser) -> dict[str, str]:
|
|
return {name: getattr(account, attr) or "" for name, (_attr, attr) in DETAIL_ATTRIBUTES.items()}
|
|
|
|
|
|
def _read(user: SessionUser) -> DirectoryUser:
|
|
account = get_directory().find_user(user.sam)
|
|
if account is None:
|
|
raise DirectoryUnavailable("The account could not be read")
|
|
return account
|
|
|
|
|
|
def load_details(user: SessionUser) -> Details:
|
|
"""What the directory holds about the signed-in user. Raises DirectoryUnavailable."""
|
|
account = _read(user)
|
|
return Details(username=account.sam, mail=account.mail or "", values=_values_of(account))
|
|
|
|
|
|
def _record_details_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
|
|
audit.record_failure(
|
|
user.key, "profile.update_failed", ip=ip, detail={"reason": reason, **detail}
|
|
)
|
|
|
|
|
|
def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> DetailsUpdated:
|
|
"""Save the changed details of the signed-in user.
|
|
|
|
Reads the directory afresh, checks the values that differ and writes only those, with the
|
|
audit entry in the same transaction (AD-4). Raises DetailsInvalid, DetailsNotAccepted,
|
|
DirectoryUnavailable or OutcomeUnknown. Must be called outside an open transaction.
|
|
"""
|
|
directory = get_directory()
|
|
try:
|
|
account = _read(user)
|
|
except DirectoryUnavailable:
|
|
_record_details_failure(user, ip, "unavailable")
|
|
raise
|
|
|
|
current = _values_of(account)
|
|
read = Details(username=account.sam, mail=account.mail or "", values=current)
|
|
cleaned, errors = check_details(values, current)
|
|
if errors:
|
|
raise DetailsInvalid(errors, read)
|
|
changed = tuple(name for name in DETAIL_FIELDS if cleaned[name] != current[name])
|
|
if not changed:
|
|
return DetailsUpdated((), account)
|
|
|
|
changes = {DETAIL_ATTRIBUTES[name][0]: cleaned[name] for name in changed}
|
|
updated = replace(account, **{DETAIL_ATTRIBUTES[name][1]: cleaned[name] for name in changed})
|
|
changed_in_directory = False
|
|
step = "directory"
|
|
try:
|
|
with transaction.atomic():
|
|
directory.update_attributes(user.sam, changes)
|
|
changed_in_directory = True
|
|
step = "audit"
|
|
audit.record(user.key, "profile.updated", ip=ip, detail={"fields": list(changed)})
|
|
step = "commit"
|
|
except ValidationFailed as exc:
|
|
_record_details_failure(user, ip, "refused")
|
|
raise DetailsNotAccepted(read) from exc
|
|
except DirectoryUnavailable:
|
|
_record_details_failure(user, ip, "unavailable")
|
|
raise
|
|
except Forbidden as exc: # the service account lacks the right: the user sees "not possible"
|
|
logger.error(
|
|
"The directory refused a profile write: user=%s ip=%s fields=%s",
|
|
user.key,
|
|
ip,
|
|
",".join(changed),
|
|
)
|
|
_record_details_failure(user, ip, "forbidden")
|
|
raise DirectoryUnavailable("The directory refused the write") from exc
|
|
except Exception as exc:
|
|
if changed_in_directory:
|
|
# AD-4: the directory changed but the portal's part did not commit.
|
|
logger.error(
|
|
"Profile details changed in the directory but the portal did not record them: "
|
|
"user=%s ip=%s time=%s fields=%s step=%s error=%s",
|
|
user.key,
|
|
ip,
|
|
timezone.now().isoformat(),
|
|
",".join(changed),
|
|
step,
|
|
type(exc).__name__,
|
|
)
|
|
try:
|
|
_record_details_failure(user, ip, "unclear", step=step, fields=list(changed))
|
|
except Exception:
|
|
logger.error("The failure entry for %s could not be written", user.key)
|
|
raise OutcomeUnknown("The details were changed but not recorded") from exc
|
|
_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))
|