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
438 lines
17 KiB
Python
438 lines
17 KiB
Python
"""Password change: the page, the ordered service, limits, sessions, audit and mail (FR-5..FR-7)."""
|
|
|
|
import logging
|
|
import re
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.test import Client
|
|
|
|
from portal.accounts.models import ThrottleCounter, UserSession
|
|
from portal.adapters.directory import DirectoryUnavailable, PasswordPolicy, PasswordRejected
|
|
from portal.audit.models import AuditEntry
|
|
from portal.mailer.models import OutboxMail
|
|
from portal.profile import services
|
|
from portal.tests.helpers import sign_in
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True)
|
|
|
|
CURRENT = "Correct-Horse-9"
|
|
NEW = "Brand-New-Secret-77"
|
|
WRONG = "Not-The-Password-1"
|
|
|
|
|
|
class Spy:
|
|
"""Counts calls to the directory methods the order of the flow is about."""
|
|
|
|
def __init__(self, directory):
|
|
self.calls = []
|
|
for name in ("password_policy", "verify_password", "change_password", "find_user"):
|
|
self._wrap(directory, name)
|
|
|
|
def _wrap(self, directory, name):
|
|
original = getattr(directory, name)
|
|
|
|
def wrapper(*args, **kwargs):
|
|
self.calls.append(name)
|
|
return original(*args, **kwargs)
|
|
|
|
setattr(directory, name, wrapper)
|
|
|
|
def count(self, name):
|
|
return self.calls.count(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def people(adapters):
|
|
d = adapters.directory
|
|
d.add_user(
|
|
"alice",
|
|
password=CURRENT,
|
|
given_name="Alice",
|
|
surname="Anders",
|
|
display_name="Alice Anders",
|
|
mail="alice@example.test",
|
|
)
|
|
d.add_user("bob", mail="bob@example.test", display_name="Bob Brown")
|
|
d.add_user("nomail", password=CURRENT, display_name="No Mail")
|
|
return d
|
|
|
|
|
|
def change(client, current=CURRENT, new=NEW, confirm=None, **extra):
|
|
data = {
|
|
"current_password": current,
|
|
"new_password": new,
|
|
"confirm_password": new if confirm is None else confirm,
|
|
**extra,
|
|
}
|
|
return client.post("/password", data)
|
|
|
|
|
|
def signed_in(adapters, name="alice"):
|
|
client, _ = sign_in(adapters, name)
|
|
return client
|
|
|
|
|
|
def audit_actions():
|
|
return list(AuditEntry.objects.values_list("action", flat=True))
|
|
|
|
|
|
# -- the page -----------------------------------------
|
|
|
|
|
|
def test_the_page_lists_the_rules_before_the_fields_and_states_the_effect(people, adapters):
|
|
html = signed_in(adapters).get("/password").content.decode()
|
|
assert "At least 8 characters" in html and "at least three of these groups" in html
|
|
assert "Not one of your last 24 passwords" in html and "valid for 90 days" in html
|
|
assert "signed out on your other devices" in html and "confirmation by e-mail" in html
|
|
assert html.index("At least 8 characters") < html.index('name="new_password"')
|
|
assert 'autocomplete="current-password"' in html
|
|
assert html.count('autocomplete="new-password"') == 2
|
|
|
|
|
|
def test_the_page_speaks_german_with_the_chosen_register(people, adapters):
|
|
client = signed_in(adapters)
|
|
client.cookies["pm_lang"] = "de"
|
|
assert "Mindestens 8 Zeichen" in client.get("/password").content.decode()
|
|
assert "Ihr neues Passwort muss" in client.get("/password").content.decode()
|
|
client.cookies["pm_form"] = "du"
|
|
assert "Dein neues Passwort muss" in client.get("/password").content.decode()
|
|
|
|
|
|
def test_a_fine_grained_policy_that_cannot_be_read_is_flagged(people, adapters):
|
|
adapters.directory.policy = PasswordPolicy(
|
|
8, True, 0, timedelta(0), None, fine_grained=True, fine_grained_readable=False
|
|
)
|
|
html = signed_in(adapters).get("/password").content.decode()
|
|
assert "A stricter rule may also apply" in html
|
|
|
|
|
|
def test_the_menu_has_the_entry_and_marks_it_current(people, adapters):
|
|
html = signed_in(adapters).get("/password").content.decode()
|
|
assert re.search(r'<a href="/password" class="current" aria-current="page">', html)
|
|
|
|
|
|
def test_the_page_is_unavailable_when_the_directory_is_down(people, adapters):
|
|
client = signed_in(adapters)
|
|
adapters.directory.unavailable = True
|
|
assert client.get("/password").status_code == 503
|
|
|
|
|
|
def test_the_page_needs_a_sign_in_and_only_get_and_post(people, adapters):
|
|
anonymous = Client()
|
|
assert anonymous.get("/password").status_code == 302
|
|
assert anonymous.post("/password", {"current_password": "x"}).status_code == 302
|
|
assert signed_in(adapters).put("/password").status_code == 405
|
|
|
|
|
|
def test_the_form_needs_a_csrf_token(people, adapters):
|
|
client = signed_in(adapters)
|
|
strict = Client(enforce_csrf_checks=True)
|
|
strict.cookies = client.cookies
|
|
data = {"current_password": CURRENT, "new_password": NEW, "confirm_password": NEW}
|
|
assert strict.post("/password", data).status_code == 403
|
|
assert adapters.directory.verify_password("alice", CURRENT)
|
|
|
|
|
|
# -- success -----------------------------------------
|
|
|
|
|
|
def test_a_change_updates_the_directory_and_ends_only_the_other_sessions(people, adapters):
|
|
first = signed_in(adapters)
|
|
second = signed_in(adapters)
|
|
bob = signed_in(adapters, "bob")
|
|
response = change(first)
|
|
assert response.status_code == 302 and response["Location"] == "/password/done"
|
|
assert adapters.directory.verify_password("alice", NEW)
|
|
assert not adapters.directory.verify_password("alice", CURRENT)
|
|
assert first.get("/").status_code == 200 # this session stays
|
|
assert second.get("/").status_code == 302 # the other one is gone
|
|
assert bob.get("/").status_code == 200 # other users are untouched
|
|
assert UserSession.objects.filter(username="alice").count() == 1
|
|
|
|
|
|
def test_a_change_is_audited_with_the_address_and_no_secrets(people, adapters):
|
|
signed_in(adapters)
|
|
client = signed_in(adapters)
|
|
change(client)
|
|
entry = AuditEntry.objects.get(action="password.changed")
|
|
assert entry.actor == "alice" and entry.ip == "127.0.0.1"
|
|
assert entry.detail == {"other_sessions_ended": 1, "mail": "queued"}
|
|
|
|
|
|
def test_a_confirmation_mail_is_queued_in_german_with_the_time_and_no_password(people, adapters):
|
|
change(signed_in(adapters))
|
|
mail = OutboxMail.objects.get()
|
|
assert mail.recipient == "alice@example.test" and mail.template == "password_changed"
|
|
assert mail.subject == "Ihr Passwort wurde geändert"
|
|
assert "Guten Tag Alice Anders," in mail.body_text
|
|
assert re.search(r"am \d{1,2}\. \w+ \d{4}, \d{2}:\d{2} \(CES?T\) geändert", mail.body_text)
|
|
assert NEW not in mail.body_text and CURRENT not in mail.body_text
|
|
|
|
|
|
def test_the_done_page_reports_once_and_mentions_the_mail(people, adapters):
|
|
client = signed_in(adapters)
|
|
response = change(client)
|
|
page = client.get(response["Location"])
|
|
assert "Your password was changed." in page.content.decode()
|
|
assert "confirmation was sent" in page.content.decode()
|
|
assert client.get("/password/done")["Location"] == "/password" # nothing left to report
|
|
|
|
|
|
def test_without_a_mail_address_the_password_still_changes(people, adapters):
|
|
client = signed_in(adapters, "nomail")
|
|
response = change(client)
|
|
assert response.status_code == 302 and adapters.directory.verify_password("nomail", NEW)
|
|
assert OutboxMail.objects.count() == 0
|
|
assert AuditEntry.objects.get(action="password.changed").detail["mail"] == "skipped"
|
|
assert "confirmation was sent" not in client.get(response["Location"]).content.decode()
|
|
|
|
|
|
def test_a_success_clears_the_failed_attempts_of_the_user(people, adapters):
|
|
client = signed_in(adapters)
|
|
change(client, current=WRONG)
|
|
assert ThrottleCounter.objects.filter(subject="user:alice").exists()
|
|
change(client)
|
|
assert not ThrottleCounter.objects.filter(subject="user:alice").exists()
|
|
|
|
|
|
def test_the_user_is_always_the_session_user(people, adapters):
|
|
client = signed_in(adapters)
|
|
change(client, username="bob", sam="bob", user="bob")
|
|
assert adapters.directory.verify_password("alice", NEW)
|
|
assert adapters.directory.verify_password("bob", "Correct-Horse-9")
|
|
|
|
|
|
# -- mistakes in the form -----------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fields", "message"),
|
|
[
|
|
({"confirm": "Something-Else-12"}, "not the same"),
|
|
({"new": CURRENT}, "must be different"),
|
|
({"current": ""}, "Please fill in this field."),
|
|
({"new": "", "confirm": ""}, "Please fill in this field."),
|
|
({"new": "A" * 257, "confirm": "A" * 257}, "too long"),
|
|
],
|
|
)
|
|
def test_form_mistakes_are_explained_and_never_reach_the_directory(
|
|
people, adapters, fields, message
|
|
):
|
|
client = signed_in(adapters)
|
|
spy = Spy(adapters.directory)
|
|
response = change(
|
|
client,
|
|
current=fields.get("current", CURRENT),
|
|
new=fields.get("new", NEW),
|
|
confirm=fields.get("confirm"),
|
|
)
|
|
assert response.status_code == 400 and message in response.content.decode()
|
|
assert spy.count("verify_password") == 0 and spy.count("change_password") == 0
|
|
assert not ThrottleCounter.objects.exists()
|
|
assert adapters.directory.verify_password("alice", CURRENT)
|
|
|
|
|
|
def test_errors_are_next_to_the_field_and_in_a_summary(people, adapters):
|
|
html = change(signed_in(adapters), confirm="Other-Password-12").content.decode()
|
|
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
|
|
|
|
|
|
# -- wrong current password and the limit -----------------------------------------
|
|
|
|
|
|
def test_a_wrong_current_password_changes_nothing_and_counts(people, adapters):
|
|
spy = Spy(adapters.directory)
|
|
client = signed_in(adapters)
|
|
other = signed_in(adapters)
|
|
response = change(client, current=WRONG)
|
|
assert response.status_code == 400
|
|
assert "The current password is not correct." in response.content.decode()
|
|
assert spy.count("change_password") == 0
|
|
assert adapters.directory.verify_password("alice", CURRENT)
|
|
assert other.get("/").status_code == 200 and OutboxMail.objects.count() == 0
|
|
assert ThrottleCounter.objects.get(subject="user:alice").count == 1
|
|
entry = AuditEntry.objects.get(action="password.change_failed")
|
|
assert entry.detail == {"reason": "wrong_current"}
|
|
|
|
|
|
def test_the_limit_stays_below_the_directory_lockout_threshold(people, adapters):
|
|
client = signed_in(adapters)
|
|
for _ in range(3): # the fake domain locks after 5, so 4 are allowed
|
|
assert change(client, current=WRONG).status_code == 400
|
|
assert change(client, current=WRONG).status_code == 400 # the 4th wrong one blocks
|
|
spy = Spy(adapters.directory)
|
|
response = change(client) # even the right password is refused now
|
|
assert response.status_code == 429
|
|
assert "Too many attempts. Please wait a few minutes." in response.content.decode()
|
|
assert spy.calls == [] # refused before any directory call
|
|
assert adapters.directory.verify_password("alice", CURRENT)
|
|
assert "password.throttled" in audit_actions()
|
|
|
|
|
|
def test_the_pause_is_over_after_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
|
|
assert change(client).status_code == 302
|
|
|
|
|
|
def test_the_limit_for_other_thresholds(people):
|
|
def policy(threshold, window=timedelta(minutes=30)):
|
|
return PasswordPolicy(
|
|
8, True, 0, timedelta(0), None, lockout_threshold=threshold, lockout_window=window
|
|
)
|
|
|
|
assert services.failure_limit(policy(0)) == 5 # the domain never locks
|
|
assert services.failure_limit(policy(1)) == 1
|
|
assert services.failure_limit(policy(3)) == 2
|
|
assert services.failure_limit(policy(5)) == 4
|
|
assert services.failure_limit(policy(10)) == 5
|
|
assert services.pause_seconds(policy(5, timedelta(minutes=30))) == 30 * 60
|
|
assert services.pause_seconds(policy(5, timedelta(minutes=5))) == 15 * 60
|
|
assert services.pause_seconds(policy(0, None)) == 15 * 60
|
|
|
|
|
|
def test_the_limit_is_per_ip_too(people, adapters):
|
|
client = signed_in(adapters)
|
|
for _ in range(4):
|
|
change(client, current=WRONG)
|
|
other_user = signed_in(adapters, "bob") # same address, different user
|
|
assert change(other_user).status_code == 429
|
|
|
|
|
|
# -- the directory refuses the new password -----------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("new", "message"),
|
|
[("Ab1", "too short"), ("alllowercase-only", "more groups")],
|
|
)
|
|
def test_rules_the_portal_can_name_are_named_without_asking_the_directory(
|
|
people, adapters, new, message
|
|
):
|
|
spy = Spy(adapters.directory)
|
|
response = change(signed_in(adapters), new=new)
|
|
assert response.status_code == 400 and message in response.content.decode()
|
|
assert spy.count("change_password") == 0
|
|
assert adapters.directory.verify_password("alice", CURRENT)
|
|
assert AuditEntry.objects.get(action="password.change_failed").detail["reason"] == "policy"
|
|
|
|
|
|
def test_a_rule_failure_does_not_count_as_a_wrong_password(people, adapters):
|
|
client = signed_in(adapters)
|
|
for _ in range(8):
|
|
assert change(client, new="Ab1").status_code == 400
|
|
assert not ThrottleCounter.objects.exists()
|
|
assert change(client).status_code == 302
|
|
|
|
|
|
def test_a_rejection_by_the_directory_is_explained_and_changes_nothing(people, adapters):
|
|
client = signed_in(adapters)
|
|
other = signed_in(adapters)
|
|
adapters.directory.change_password("alice", CURRENT, NEW) # CURRENT is now an old password
|
|
response = change(client, current=NEW, new=CURRENT)
|
|
html = response.content.decode()
|
|
assert response.status_code == 400 and "does not meet the rules" in html
|
|
assert other.get("/").status_code == 200 and OutboxMail.objects.count() == 0
|
|
assert not ThrottleCounter.objects.exists()
|
|
assert "password.changed" not in audit_actions()
|
|
|
|
|
|
def test_the_password_changed_by_someone_else_in_between_counts_as_wrong(
|
|
people, adapters, monkeypatch
|
|
):
|
|
def raced(*args):
|
|
raise PasswordRejected("wrong_current")
|
|
|
|
monkeypatch.setattr(adapters.directory, "change_password", raced)
|
|
response = change(signed_in(adapters))
|
|
assert response.status_code == 400 and "not correct" in response.content.decode()
|
|
|
|
|
|
# -- failures after the first directory call -----------------------------------------
|
|
|
|
|
|
def test_the_directory_going_down_before_the_change_changes_nothing(people, adapters):
|
|
client = signed_in(adapters)
|
|
adapters.directory.unavailable = True
|
|
assert change(client).status_code == 503
|
|
assert AuditEntry.objects.get(action="password.change_failed").detail["reason"] == "unavailable"
|
|
|
|
|
|
def test_the_directory_failing_during_the_change_rolls_everything_back(
|
|
people, adapters, monkeypatch
|
|
):
|
|
client = signed_in(adapters)
|
|
other = signed_in(adapters)
|
|
|
|
def down(*args):
|
|
raise DirectoryUnavailable("down")
|
|
|
|
monkeypatch.setattr(adapters.directory, "change_password", down)
|
|
assert change(client).status_code == 503
|
|
assert other.get("/").status_code == 200
|
|
assert OutboxMail.objects.count() == 0 and "password.changed" not in audit_actions()
|
|
assert AuditEntry.objects.get(action="password.change_failed").detail["reason"] == "unavailable"
|
|
|
|
|
|
def test_a_failure_after_the_directory_changed_is_reported_as_unclear(
|
|
people, adapters, monkeypatch, caplog
|
|
):
|
|
client = signed_in(adapters)
|
|
other = signed_in(adapters)
|
|
|
|
def broken(*args, **kwargs):
|
|
raise RuntimeError("database trouble")
|
|
|
|
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
|
with caplog.at_level(logging.ERROR):
|
|
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
|
|
|
|
|
|
# -- no secrets anywhere -----------------------------------------
|
|
|
|
|
|
def test_no_password_appears_in_logs_audit_session_database_or_pages(people, adapters, caplog):
|
|
client = signed_in(adapters)
|
|
other = signed_in(adapters)
|
|
pages = []
|
|
with caplog.at_level(logging.DEBUG):
|
|
pages.append(change(client, current=WRONG, new=NEW + "x").content.decode())
|
|
pages.append(change(client, new="Ab1").content.decode())
|
|
pages.append(change(client, confirm="Different-One-99").content.decode())
|
|
done = change(client)
|
|
pages.append(client.get(done["Location"]).content.decode())
|
|
secrets = (CURRENT, NEW, NEW + "x", WRONG, "Different-One-99")
|
|
stored = " ".join(str(e.detail) + e.action + e.target for e in AuditEntry.objects.all())
|
|
stored += " ".join(m.body_text + m.subject for m in OutboxMail.objects.all())
|
|
stored += str(dict(client.session)) + str(dict(other.session))
|
|
for secret in secrets:
|
|
assert secret not in caplog.text and secret not in stored, secret
|
|
assert all(secret not in page for page in pages), secret
|
|
|
|
|
|
# -- the rules in words -----------------------------------------
|
|
|
|
|
|
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) == [
|
|
"At least 1 character",
|
|
"Not your last 1 password",
|
|
"A new password can be changed again after 1 hour",
|
|
]
|
|
long_policy = PasswordPolicy(12, True, 5, timedelta(days=2), timedelta(days=42))
|
|
rules = services.describe_policy(long_policy)
|
|
assert "At least 12 characters" in rules and "Not one of your last 5 passwords" in rules
|
|
assert "A new password can be changed again after 2 days" in rules
|
|
assert "A password is valid for 42 days" in rules
|