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:
Ebbe Baß
2026-10-19 08:23:33 +02:00
co-authored by Claude Sonnet 5.5
parent 49c7fd4a0b
commit ae4b30ec22
7 changed files with 218 additions and 20 deletions
+77 -5
View File
@@ -6,6 +6,8 @@ from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from django.views.debug import SafeExceptionReporterFilter
from portal.accounts.models import ThrottleCounter, UserSession
from portal.adapters.directory import DirectoryUnavailable, PasswordPolicy, PasswordRejected
@@ -238,6 +240,13 @@ def test_errors_are_next_to_the_field_and_in_a_summary(people, adapters):
assert 'class="error-summary"' in html and "The password was not changed." in html
assert 'href="#id_confirm_password"' in html and 'id="id_confirm_password-error"' in html
assert 'aria-invalid="true"' in html and "data-focus-on-load" in html
assert 'maxlength="256"' in html
def test_two_errors_on_one_field_share_one_description(people, adapters):
html = change(signed_in(adapters), new="a").content.decode() # too short and too few groups
assert html.count('id="id_new_password-error"') == 1
assert html.count('class="error"') >= 2
# -- wrong current password and the limit -----------------------------------------
@@ -272,11 +281,21 @@ def test_the_limit_stays_below_the_directory_lockout_threshold(people, adapters)
assert "password.throttled" in audit_actions()
def test_the_pause_is_over_after_the_lockout_window(people, adapters):
def test_the_block_lasts_as_long_as_the_lockout_window(people, adapters):
client = signed_in(adapters)
for _ in range(4):
change(client, current=WRONG)
ThrottleCounter.objects.update(blocked_until=None, count=0) # the pause is over
# the fake domain forgets failed attempts after 30 minutes: the pause must not end sooner
for subject in ("user:alice", "ip:127.0.0.1"):
left = ThrottleCounter.objects.get(subject=subject).blocked_until - timezone.now()
assert timedelta(minutes=29) < left <= timedelta(minutes=30), subject
def test_a_user_can_try_again_once_the_block_is_cleared(people, adapters):
client = signed_in(adapters)
for _ in range(4):
change(client, current=WRONG)
ThrottleCounter.objects.update(blocked_until=None, count=0) # as if the pause had run out
assert change(client).status_code == 302
@@ -296,6 +315,18 @@ def test_the_limit_for_other_thresholds(people):
assert services.pause_seconds(policy(0, None)) == 15 * 60
def test_a_lockout_threshold_of_one_is_noted_in_the_audit(people, adapters):
adapters.directory.policy = PasswordPolicy(
8, True, 0, timedelta(0), None, lockout_threshold=1, lockout_window=timedelta(minutes=30)
)
change(signed_in(adapters), current=WRONG)
entry = AuditEntry.objects.get(action="password.change_failed")
assert entry.detail == {"reason": "wrong_current", "lockout_protection": "none"}
ThrottleCounter.objects.all().delete()
change(signed_in(adapters))
assert AuditEntry.objects.get(action="password.changed").detail["lockout_protection"] == "none"
def test_the_limit_is_per_ip_too(people, adapters):
client = signed_in(adapters)
for _ in range(4):
@@ -322,6 +353,16 @@ def test_rules_the_portal_can_name_are_named_without_asking_the_directory(
assert AuditEntry.objects.get(action="password.change_failed").detail["reason"] == "policy"
def test_without_a_readable_policy_the_portal_leaves_the_rules_to_the_directory(people, adapters):
adapters.directory.policy = PasswordPolicy(
8, True, 0, timedelta(0), None, fine_grained=True, fine_grained_readable=False
)
spy = Spy(adapters.directory)
response = change(signed_in(adapters), new="Ab1")
assert spy.count("change_password") == 1 # not refused by domain values that may not apply
assert "too short" not in response.content.decode()
def test_a_rule_failure_does_not_count_as_a_wrong_password(people, adapters):
client = signed_in(adapters)
for _ in range(8):
@@ -351,6 +392,10 @@ def test_the_password_changed_by_someone_else_in_between_counts_as_wrong(
monkeypatch.setattr(adapters.directory, "change_password", raced)
response = change(signed_in(adapters))
assert response.status_code == 400 and "not correct" in response.content.decode()
assert ThrottleCounter.objects.get(subject="user:alice").count == 1
entry = AuditEntry.objects.get(action="password.change_failed")
assert entry.detail == {"reason": "wrong_current"}
assert "password.changed" not in audit_actions()
# -- failures after the first directory call -----------------------------------------
@@ -393,14 +438,34 @@ def test_a_failure_after_the_directory_changed_is_reported_as_unclear(
response = change(client)
assert response.status_code == 500 and "Result unclear" in response.content.decode()
assert adapters.directory.verify_password("alice", NEW) # the directory did change
assert other.get("/").status_code == 200 # the portal's part rolled back
assert "password.changed" not in audit_actions()
assert "changed in the directory but the portal did not record it" in caplog.text
assert client.get("/").status_code == 200 # this session stays
assert other.get("/").status_code == 302 # the other one is ended as far as possible
assert "password.changed" not in audit_actions() and OutboxMail.objects.count() == 0
entry = AuditEntry.objects.get(action="password.change_failed")
assert entry.detail == {"reason": "unclear", "step": "queue_mail"}
record = next(r for r in caplog.records if "did not record it" in r.getMessage())
assert record.levelno == logging.ERROR
assert "user=alice" in record.getMessage() and "step=queue_mail" in record.getMessage()
assert "RuntimeError" in record.getMessage() and "database trouble" not in caplog.text
# -- no secrets anywhere -----------------------------------------
def test_the_password_fields_are_masked_in_error_reports(people, adapters, monkeypatch):
def crash(*args, **kwargs):
raise RuntimeError("crash")
monkeypatch.setattr(services, "change_password", crash)
client = signed_in(adapters)
client.raise_request_exception = False
response = change(client)
assert response.status_code == 500
shown = SafeExceptionReporterFilter().get_post_parameters(response.wsgi_request)
for name in ("current_password", "new_password", "confirm_password"):
assert shown[name] == SafeExceptionReporterFilter.cleansed_substitute, name
def test_no_password_appears_in_logs_audit_session_database_or_pages(people, adapters, caplog):
client = signed_in(adapters)
other = signed_in(adapters)
@@ -423,6 +488,13 @@ def test_no_password_appears_in_logs_audit_session_database_or_pages(people, ada
# -- the rules in words -----------------------------------------
def test_the_minimum_age_is_rounded_up_and_a_minimum_length_of_zero_is_not_listed():
policy = PasswordPolicy(0, False, 0, timedelta(days=1, hours=10), None)
assert services.describe_policy(policy) == ["A new password can be changed again after 2 days"]
soon = PasswordPolicy(0, False, 0, timedelta(minutes=90), None)
assert services.describe_policy(soon) == ["A new password can be changed again after 2 hours"]
def test_the_rules_are_written_out_for_each_part_of_the_policy():
policy = PasswordPolicy(1, False, 1, timedelta(hours=1), None)
assert services.describe_policy(policy) == [