feat: add password change

Password page with the AD policy listed first, ordered service (throttle limit below the lockout threshold, verification, local hints, directory change, ended sessions, audit and confirmation mail in one transaction), plain-language errors, German text and tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-18 10:08:17 +02:00
co-authored by Claude Sonnet 5.5
parent a1a766adef
commit 49c7fd4a0b
22 changed files with 1346 additions and 17 deletions
+254
View File
@@ -0,0 +1,254 @@
"""Password change for the signed-in user (FR-5, FR-6, FR-7; 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
from dataclasses import dataclass
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, PasswordPolicy, PasswordRejected
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from portal.mailer import services as mailer
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 = [
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 = round(age.total_seconds() / 86400)
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, round(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."""
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 _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")
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
try:
with transaction.atomic():
directory.change_password(user.sam, current, new)
changed_in_directory = True
ended = end_sessions(user, except_session_key=session_key)
mail_queued = _queue_mail(account)
audit.record(
user.key,
"password.changed",
ip=ip,
detail={
"other_sessions_ended": ended,
"mail": "queued" if mail_queued else "skipped",
},
)
except PasswordRejected as exc:
if exc.reason == "wrong_current": # the password changed between the check and the change
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.
logger.error("Password changed in the directory but the portal did not record it")
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 _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