"""Password change and profile details of the signed-in user (FR-5..FR-9; AD-3, AD-4, AD-8). 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 logging import math 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 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)