fix: harden password change after code review
Count and audit a wrong current password found at change time, log the unclear outcome with its details and end the other sessions as far as possible, note a lockout threshold of 1 in the audit, skip the local hints when the fine-grained policy cannot be read, round the minimum age up, reset the form after back navigation, give error messages unique ids, and fix the German rejection text. Adds tests for the block length, the masked password fields and the new cases; the review findings are recorded in the plan and the deferred work. 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
49c7fd4a0b
commit
ae4b30ec22
+69
-12
@@ -9,6 +9,7 @@ stored or put into an exception.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -62,14 +63,16 @@ class Changed:
|
||||
|
||||
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,
|
||||
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}
|
||||
)
|
||||
% {"n": policy.min_length}
|
||||
]
|
||||
if policy.complexity:
|
||||
rules.append(
|
||||
_(
|
||||
@@ -104,13 +107,13 @@ def describe_policy(policy: PasswordPolicy) -> list[str]:
|
||||
|
||||
def _min_age_rule(age: timedelta) -> str:
|
||||
if age >= timedelta(days=1):
|
||||
days = round(age.total_seconds() / 86400)
|
||||
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, round(age.total_seconds() / 3600))
|
||||
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",
|
||||
@@ -133,7 +136,13 @@ def _character_groups(password: str) -> int:
|
||||
|
||||
|
||||
def local_hints(new: str, policy: PasswordPolicy) -> list[str]:
|
||||
"""Rule keys the portal can check itself; the directory stays the authority."""
|
||||
"""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")
|
||||
@@ -161,6 +170,13 @@ def pause_seconds(policy: PasswordPolicy) -> int:
|
||||
# -- 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}
|
||||
@@ -192,7 +208,7 @@ def change_password(
|
||||
max_failures=failure_limit(policy),
|
||||
pause_seconds=pause_seconds(policy),
|
||||
)
|
||||
_record_failure(user, ip, "wrong_current")
|
||||
_record_failure(user, ip, "wrong_current", **_threshold_note(policy))
|
||||
raise WrongCurrentPassword(policy)
|
||||
account = directory.find_user(user.sam)
|
||||
except DirectoryUnavailable:
|
||||
@@ -205,12 +221,16 @@ def change_password(
|
||||
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",
|
||||
@@ -218,10 +238,19 @@ def change_password(
|
||||
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
|
||||
@@ -231,7 +260,7 @@ def change_password(
|
||||
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")
|
||||
_after_unclear_outcome(user, ip, session_key, step, exc)
|
||||
raise OutcomeUnknown("The password was changed but not recorded") from exc
|
||||
raise
|
||||
|
||||
@@ -239,6 +268,34 @@ def change_password(
|
||||
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 ""
|
||||
|
||||
Reference in New Issue
Block a user