slapd refuses to remove an RDN value, and every legacy account is cn=<name>,ou=People, so a name edit there always failed. Those accounts now see their name read-only (the view rejects the profile action and update_profile refuses as a backstop); uid-named accounts from website registration can still edit it. README documents the cn/mail self-write ACL and mail uniqueness overlay now applied in production. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
761 lines
44 KiB
Python
Executable File
761 lines
44 KiB
Python
Executable File
"""Offline self-service profile tests; never bind LDAP or deliver real email.
|
|
|
|
Run only this module with config.test_settings. Credentials are
|
|
ephemeral test data, not service credentials. Directory writes use mocks; LDAP
|
|
reads additionally exercise ldap3's in-memory strategy.
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import secrets
|
|
from datetime import timedelta
|
|
from unittest.mock import MagicMock, patch
|
|
from urllib.parse import urlsplit
|
|
|
|
from django.conf import settings
|
|
from django.core import mail, signing
|
|
from django.core.exceptions import ValidationError
|
|
from django.test import Client, SimpleTestCase, TestCase, override_settings
|
|
from django.urls import NoReverseMatch, Resolver404, resolve, reverse
|
|
from django.utils import timezone
|
|
from ldap3 import BASE, MOCK_SYNC, MODIFY_REPLACE, Connection, Server
|
|
from ldap3.core.exceptions import LDAPException
|
|
|
|
from members import profiles
|
|
from members.models import Account, ProfileEmailChange
|
|
from members.profile_forms import (
|
|
MAX_INPUT_LENGTH,
|
|
CurrentPasswordForm,
|
|
ProfileEmailForm,
|
|
ProfileForm,
|
|
ProfilePasswordForm,
|
|
ProfileValuesField,
|
|
)
|
|
|
|
|
|
ALICE_DN = "uid=alice,ou=people,dc=example,dc=org"
|
|
SEARCH_DN = "ou=people,dc=example,dc=org"
|
|
|
|
|
|
def profile_values(**overrides):
|
|
"""The complete READ_ATTRIBUTES shape returned by the real reader."""
|
|
values = {name: [] for name in profiles.READ_ATTRIBUTES}
|
|
values.update({
|
|
"cn": ["Alice Example"],
|
|
"mail": ["alice@example.org", "alice.other@example.org"],
|
|
"entryUUID": ["uuid-alice"],
|
|
"uid": ["alice"],
|
|
"modifyTimestamp": ["20261029120000Z"],
|
|
})
|
|
values.update(overrides)
|
|
return values
|
|
|
|
|
|
class ProfileFormTests(SimpleTestCase):
|
|
def setUp(self):
|
|
self.password = " " + secrets.token_urlsafe(24) + " "
|
|
|
|
def form(self, data=None):
|
|
return ProfileForm(data={"cn": "Alice Example", "current_password": self.password, **(data or {})})
|
|
|
|
def test_single_name_value_published_by_dex(self):
|
|
form = self.form()
|
|
self.assertTrue(form.is_valid(), form.errors)
|
|
self.assertEqual(form.fields["cn"].label, "Name")
|
|
self.assertEqual(form.cleaned_data["cn"], ["Alice Example"])
|
|
for name in ("First\nSecond", "x" * 257):
|
|
with self.subTest(length=len(name)):
|
|
invalid = self.form({"cn": name})
|
|
self.assertFalse(invalid.is_valid())
|
|
self.assertIn("cn", invalid.errors)
|
|
|
|
def test_only_dex_claim_attributes_are_editable(self):
|
|
# Dex's LDAP connector publishes uid, cn and mail; mail has its own
|
|
# verified flow and uid is immutable, so only cn is on this form.
|
|
self.assertEqual(profiles.TEXT_ATTRIBUTES, ("cn",))
|
|
self.assertEqual(set(ProfileForm.base_fields), {"cn", "current_password"})
|
|
for name in ("displayName", "givenName", "sn", "jpegPhoto", "sshPublicKey", "postalAddress", "telephoneNumber", "description"):
|
|
self.assertNotIn(name, profiles.READ_ATTRIBUTES)
|
|
|
|
def test_initial_uses_first_name_value_like_dex(self):
|
|
self.assertEqual(profiles.initial(profile_values(cn=["Alice", "Alice Example"])), {"cn": "Alice"})
|
|
self.assertEqual(profiles.initial(profile_values(cn=[])), {"cn": ""})
|
|
|
|
def test_name_editable_only_when_cn_does_not_name_the_entry(self):
|
|
for dn, editable in (
|
|
(ALICE_DN, True), ("uid=bob,ou=People,dc=eom,dc=dev", True),
|
|
("uid=bob,cn=Group,dc=eom,dc=dev", True),
|
|
("cn=Logan Fick,ou=People,dc=eom,dc=dev", False), ("CN=x,dc=eom,dc=dev", False),
|
|
("uid=bob+cn=Bob,ou=People,dc=eom,dc=dev", False), ("2.5.4.3=x,dc=eom,dc=dev", False),
|
|
("", False), ("not a dn", False),
|
|
):
|
|
with self.subTest(dn=dn):
|
|
self.assertIs(profiles.name_editable(dn), editable)
|
|
|
|
def test_field_groups_and_unique_password_ids(self):
|
|
form = self.form()
|
|
grouped = [*form.identity_fields(), *form.authorization_fields()]
|
|
self.assertEqual(len({field.name for field in grouped}), len(grouped))
|
|
ids = [cls()["current_password"].auto_id for cls in (
|
|
ProfileForm, ProfileEmailForm, ProfilePasswordForm, CurrentPasswordForm,
|
|
)]
|
|
self.assertEqual(len(set(ids)), 4)
|
|
|
|
def test_multivalues_deduplicate_and_strip_control_characters(self):
|
|
field = ProfileValuesField()
|
|
self.assertEqual(field.clean(" Alice\r\nBob\rAlice\n\tCarol\x00\u200b "), ["Alice", "Bob", "Carol"])
|
|
self.assertEqual(field.clean(None), [])
|
|
self.assertEqual(field.clean(" \n "), [])
|
|
|
|
def test_value_count_length_and_total_input_limits(self):
|
|
field = ProfileValuesField(value_length=3)
|
|
self.assertEqual(field.clean("abc"), ["abc"])
|
|
for value in ("abcd", "\n".join(str(i) for i in range(17)), "x" * (MAX_INPUT_LENGTH + 1)):
|
|
with self.subTest(length=len(value)), self.assertRaises(ValidationError):
|
|
field.clean(value)
|
|
self.assertEqual(len(ProfileValuesField().clean("\n".join(str(i) for i in range(16)))), 16)
|
|
|
|
def test_name_required_even_when_only_unsafe_or_blank_text(self):
|
|
for name in ("", " \n ", "\x00\u200b"):
|
|
with self.subTest(name=repr(name)):
|
|
form = self.form({"cn": name})
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("cn", form.errors)
|
|
|
|
def test_current_and_new_password_whitespace_preserved_not_rendered(self):
|
|
new_password = " " + secrets.token_urlsafe(24) + " "
|
|
form = ProfilePasswordForm(data={
|
|
"current_password": self.password,
|
|
"password": new_password, "confirm_password": new_password,
|
|
})
|
|
self.assertTrue(form.is_valid(), form.errors)
|
|
self.assertEqual(form.cleaned_data["current_password"], self.password)
|
|
self.assertEqual(form.cleaned_data["password"], new_password)
|
|
self.assertEqual(form.cleaned_data["confirm_password"], new_password)
|
|
self.assertNotIn(self.password, form.as_p())
|
|
self.assertNotIn(new_password, form.as_p())
|
|
|
|
def test_password_required_length_and_confirmation(self):
|
|
for data, field in (
|
|
({"current_password": ""}, "current_password"),
|
|
({"current_password": "x" * 1025}, "current_password"),
|
|
):
|
|
with self.subTest(field=field, length=len(data[field])):
|
|
form = CurrentPasswordForm(data=data)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn(field, form.errors)
|
|
for password, confirm in (("short", "short"), (self.password, self.password.strip())):
|
|
form = ProfilePasswordForm(data={"current_password": self.password, "password": password, "confirm_password": confirm})
|
|
self.assertFalse(form.is_valid())
|
|
|
|
def test_email_casefold_and_current_password_required(self):
|
|
form = ProfileEmailForm(data={"email": "Alice.New@EXAMPLE.ORG", "current_password": self.password})
|
|
self.assertTrue(form.is_valid(), form.errors)
|
|
self.assertEqual(form.cleaned_data["email"], "alice.new@example.org")
|
|
form = ProfileEmailForm(data={"email": "alice.new@example.org"})
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("current_password", form.errors)
|
|
|
|
|
|
class ProfileViewTests(TestCase):
|
|
def setUp(self):
|
|
self.account = Account.objects.create(
|
|
uid="alice", email="alice@example.org", oidc_issuer=settings.DEX_ISSUER,
|
|
oidc_sub="sub-alice", ldap_uuid="uuid-alice", ldap_dn=ALICE_DN,
|
|
)
|
|
self.password = " " + secrets.token_urlsafe(24) + " "
|
|
self.values = profile_values()
|
|
self.sign_in(self.client, self.account)
|
|
self.profile_url = reverse("members:profile")
|
|
self.confirm_url = reverse("members:confirm_profile_email")
|
|
# Unlike a loose read mock, this checks the saved pin seen by the view.
|
|
self.read = self.start_patch("members.views.profiles.read_profile", side_effect=self.read_profile)
|
|
self.update = self.start_patch("members.views.profiles.update_profile")
|
|
self.change_password = self.start_patch("members.views.profiles.change_password")
|
|
self.verify_password = self.start_patch("members.views.profiles.verify_password")
|
|
self.change_email = self.start_patch("members.views.profiles.change_email")
|
|
self.start_patch("members.directory.connection", side_effect=AssertionError("Offline test attempted service LDAP"))
|
|
self.start_patch("members.directory._connection", side_effect=AssertionError("Offline test attempted user LDAP"))
|
|
|
|
def start_patch(self, name, **kwargs):
|
|
patcher = patch(name, **kwargs)
|
|
mock = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return mock
|
|
|
|
def sign_in(self, client, account):
|
|
session = client.session
|
|
session["account_id"] = str(account.pk)
|
|
session.save()
|
|
|
|
def read_profile(self, account):
|
|
self.assertEqual(account.pk, self.account.pk)
|
|
saved = Account.objects.get(pk=account.pk)
|
|
self.assertEqual((account.uid, account.ldap_uuid, account.ldap_dn), (saved.uid, "uuid-alice", ALICE_DN))
|
|
return copy.deepcopy(self.values)
|
|
|
|
def profile_data(self, **overrides):
|
|
return {
|
|
**profiles.initial(self.values), "action": "profile",
|
|
"revision": profiles.revision(self.account, self.values),
|
|
"current_password": self.password, **overrides,
|
|
}
|
|
|
|
def assert_no_writes(self):
|
|
for operation in (self.update, self.change_password, self.verify_password, self.change_email):
|
|
operation.assert_not_called()
|
|
|
|
def pending(self, *, account=None, expires_at=None, ldap_uuid="uuid-alice"):
|
|
token = secrets.token_urlsafe(32)
|
|
pending = ProfileEmailChange.objects.create(
|
|
token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
|
account=account or self.account, email="alice.new@example.org",
|
|
ldap_uuid=ldap_uuid, expires_at=expires_at or timezone.now() + timedelta(hours=1),
|
|
)
|
|
return token, pending
|
|
|
|
def exchange(self, token):
|
|
return self.client.get(reverse("members:verify_profile_email", args=[token]))
|
|
|
|
def test_anonymous_endpoints_redirect_to_sign_in_page(self):
|
|
client = Client()
|
|
urls = (self.profile_url, self.confirm_url, reverse("members:verify_profile_email", args=[secrets.token_urlsafe(32)]))
|
|
for url in urls:
|
|
with self.subTest(url=url):
|
|
self.assertRedirects(client.get(url), reverse("members:home"), fetch_redirect_response=False)
|
|
self.read.assert_not_called()
|
|
self.assert_no_writes()
|
|
|
|
def test_get_loads_full_profile_and_revision_for_signed_in_account_only(self):
|
|
response = self.client.get(self.profile_url, {"uid": "mallory", "dn": "cn=admin", "account_id": str(secrets.token_hex(16))})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.context["profile_loaded"])
|
|
self.assertEqual(response.context["email_values"], self.values["mail"])
|
|
self.assertEqual(response.context["form"].initial, profiles.initial(self.values))
|
|
proof = signing.loads(response.context["revision"], salt="ldap-profile", max_age=3600)
|
|
self.assertEqual(proof["account"], str(self.account.pk))
|
|
self.assertEqual(proof["uuid"], "uuid-alice")
|
|
self.assertContains(response, 'name="csrfmiddlewaretoken"')
|
|
self.assertContains(response, 'name="cn" value="Alice Example"')
|
|
for removed in ('name="sshPublicKey"', 'name="jpegPhoto"', 'name="displayName"', 'name="postalAddress"', "multipart/form-data"):
|
|
self.assertNotContains(response, removed)
|
|
self.assert_no_writes()
|
|
|
|
def test_csrf_required_for_each_mutating_action_and_email_confirmation(self):
|
|
client = Client(enforce_csrf_checks=True)
|
|
self.sign_in(client, self.account)
|
|
token, pending = self.pending()
|
|
self.assertEqual(client.get(reverse("members:verify_profile_email", args=[token])).status_code, 302)
|
|
for action in ("profile", "email", "password"):
|
|
with self.subTest(action=action):
|
|
self.assertEqual(client.post(self.profile_url, self.profile_data(action=action)).status_code, 403)
|
|
self.assertEqual(client.post(self.confirm_url, {"current_password": self.password}).status_code, 403)
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
self.assert_no_writes()
|
|
# A real cookie/token pair passes middleware; unknown action reaches 400.
|
|
client.get(self.profile_url)
|
|
csrf = client.cookies[settings.CSRF_COOKIE_NAME].value
|
|
self.assertEqual(client.post(self.profile_url, {"action": "unknown", "csrfmiddlewaretoken": csrf}).status_code, 400)
|
|
|
|
def test_unknown_or_missing_action_is_bad_request(self):
|
|
for data in ({}, {"action": "delete"}, {"action": "userPassword"}):
|
|
with self.subTest(data=data):
|
|
self.assertEqual(self.client.post(self.profile_url, data).status_code, 400)
|
|
self.assert_no_writes()
|
|
|
|
def test_load_failure_disables_all_editing_get_and_post_and_redacts_logs(self):
|
|
self.read.side_effect = LDAPException(self.password)
|
|
with self.assertLogs("members.views", level="ERROR") as captured:
|
|
for method in (self.client.get, self.client.post):
|
|
response = method(self.profile_url, self.profile_data())
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertFalse(response.context["profile_loaded"])
|
|
self.assertNotContains(response, "<form", status_code=503)
|
|
for key in ("form", "email_form", "password_form", "revision"):
|
|
self.assertNotIn(key, response.context.flatten())
|
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
|
self.assert_no_writes()
|
|
|
|
def test_invalid_profile_preserves_revision_and_does_not_write_or_echo_password(self):
|
|
response = self.client.post(self.profile_url, self.profile_data(cn="", revision="original-revision"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn("cn", response.context["form"].errors)
|
|
self.assertEqual(response.context["revision"], "original-revision")
|
|
self.assertNotContains(response, self.password)
|
|
self.assert_no_writes()
|
|
|
|
def test_invalid_email_and_password_do_not_authorize_or_write(self):
|
|
for data, key in (
|
|
({"action": "email", "email": "not-email", "current_password": self.password}, "email_form"),
|
|
({"action": "email", "email": "new@example.org"}, "email_form"),
|
|
({"action": "password", "current_password": self.password, "password": "short", "confirm_password": "short"}, "password_form"),
|
|
):
|
|
with self.subTest(action=data["action"]):
|
|
response = self.client.post(self.profile_url, data)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.context[key].errors)
|
|
self.assert_no_writes()
|
|
self.assertFalse(ProfileEmailChange.objects.exists())
|
|
|
|
def test_profile_save_uses_cleaned_allowlist_current_account_and_exact_revision(self):
|
|
data = self.profile_data(cn="New Name", displayName="NewGamerTag", sshPublicKey="ssh-ed25519 AAAA", uid="mallory", dn="cn=admin", ldap_uuid="uuid-other", userPassword="injected", mail="attacker@example.org")
|
|
with self.assertLogs("members.views", level="INFO") as captured:
|
|
response = self.client.post(self.profile_url, data)
|
|
self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False)
|
|
account, cleaned, revision = self.update.call_args.args
|
|
self.assertEqual((account.pk, account.ldap_uuid, account.ldap_dn), (self.account.pk, "uuid-alice", ALICE_DN))
|
|
self.assertEqual(revision, data["revision"])
|
|
self.assertEqual(cleaned["cn"], ["New Name"])
|
|
self.assertEqual(cleaned["current_password"], self.password)
|
|
for name in ("uid", "dn", "ldap_uuid", "userPassword", "mail", "displayName", "sshPublicKey"):
|
|
self.assertNotIn(name, cleaned)
|
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.uid, "alice")
|
|
self.assertEqual(self.account.email, "alice@example.org")
|
|
|
|
def test_profile_write_failure_is_generic_and_password_never_logged(self):
|
|
self.update.side_effect = LDAPException("private server diagnostic " + self.password)
|
|
with self.assertLogs("members.views", level="ERROR") as captured:
|
|
response = self.client.post(self.profile_url, self.profile_data())
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.context["form"].non_field_errors())
|
|
output = "\n".join(captured.output)
|
|
self.assertIn("kind=LDAPException", output)
|
|
self.assertNotIn(self.password, output)
|
|
self.assertNotIn("private server diagnostic", output)
|
|
self.assertNotContains(response, self.password)
|
|
|
|
def test_password_change_uses_current_password_then_flushes_local_session(self):
|
|
new_password = " " + secrets.token_urlsafe(24) + " "
|
|
old_key = self.client.session.session_key
|
|
with self.assertLogs("members.views", level="INFO") as captured:
|
|
response = self.client.post(self.profile_url, {
|
|
"action": "password", "current_password": self.password,
|
|
"password": new_password, "confirm_password": new_password,
|
|
"uid": "mallory", "dn": "cn=admin",
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
account, current, new = self.change_password.call_args.args
|
|
self.assertEqual(account.pk, self.account.pk)
|
|
self.assertEqual((current, new), (self.password, new_password))
|
|
self.assertNotIn("account_id", self.client.session)
|
|
self.assertNotEqual(self.client.session.session_key, old_key)
|
|
from django.contrib.sessions.models import Session
|
|
self.assertFalse(Session.objects.filter(session_key=old_key).exists())
|
|
for secret in (self.password, new_password):
|
|
self.assertNotIn(secret, "\n".join(captured.output))
|
|
self.assertNotContains(response, secret)
|
|
self.update.assert_not_called()
|
|
self.change_email.assert_not_called()
|
|
|
|
def test_failed_password_change_retains_session(self):
|
|
self.change_password.side_effect = ValueError(self.password)
|
|
new_password = secrets.token_urlsafe(24)
|
|
with self.assertLogs("members.views", level="ERROR") as captured:
|
|
response = self.client.post(self.profile_url, {
|
|
"action": "password", "current_password": self.password,
|
|
"password": new_password, "confirm_password": new_password,
|
|
})
|
|
self.assertTrue(response.context["password_form"].non_field_errors())
|
|
self.assertEqual(self.client.session["account_id"], str(self.account.pk))
|
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
|
|
|
def test_email_request_verifies_password_stores_only_hash_and_sends_link_not_write(self):
|
|
before = timezone.now()
|
|
response = self.client.post(self.profile_url, {"action": "email", "email": "Alice.New@EXAMPLE.ORG", "current_password": self.password})
|
|
self.assertRedirects(response, self.profile_url + "?email_sent=1", fetch_redirect_response=False)
|
|
account, password = self.verify_password.call_args.args
|
|
self.assertEqual(account.pk, self.account.pk)
|
|
self.assertEqual(password, self.password)
|
|
pending = ProfileEmailChange.objects.get(account=self.account)
|
|
self.assertEqual((pending.email, pending.ldap_uuid), ("alice.new@example.org", "uuid-alice"))
|
|
self.assertGreaterEqual(pending.expires_at, before + timedelta(hours=1))
|
|
self.assertLessEqual(pending.expires_at, timezone.now() + timedelta(hours=1))
|
|
self.assertEqual(len(mail.outbox), 1)
|
|
message = mail.outbox[0]
|
|
self.assertEqual(message.to, ["alice.new@example.org"])
|
|
link = next(line for line in message.body.splitlines() if line.startswith(settings.PUBLIC_URL))
|
|
token = resolve(urlsplit(link).path).kwargs["token"]
|
|
self.assertEqual(pending.token_hash, hashlib.sha256(token.encode()).hexdigest())
|
|
self.assertNotEqual(pending.token_hash, token)
|
|
self.assertNotIn(token, repr(list(ProfileEmailChange.objects.values())))
|
|
self.assertNotIn(self.password, message.body)
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.email, "alice@example.org")
|
|
self.change_email.assert_not_called()
|
|
self.update.assert_not_called()
|
|
|
|
def test_new_email_request_replaces_previous_pending_token(self):
|
|
_, old = self.pending()
|
|
self.client.post(self.profile_url, {"action": "email", "email": "next@example.org", "current_password": self.password})
|
|
self.assertFalse(ProfileEmailChange.objects.filter(pk=old.pk).exists())
|
|
self.assertEqual(ProfileEmailChange.objects.get().email, "next@example.org")
|
|
|
|
def test_bad_current_password_does_not_create_email_token_or_send_mail(self):
|
|
self.verify_password.side_effect = ValueError(self.password)
|
|
with patch("members.views.send_mail") as send, self.assertLogs("members.views", level="ERROR"):
|
|
response = self.client.post(self.profile_url, {"action": "email", "email": "new@example.org", "current_password": self.password})
|
|
self.assertTrue(response.context["email_form"].non_field_errors())
|
|
self.assertFalse(ProfileEmailChange.objects.exists())
|
|
send.assert_not_called()
|
|
self.change_email.assert_not_called()
|
|
|
|
def test_smtp_failure_rolls_back_new_token_and_redacts_exception(self):
|
|
with patch("members.views.send_mail", side_effect=OSError(self.password)), self.assertLogs("members.views", level="ERROR") as captured:
|
|
response = self.client.post(self.profile_url, {"action": "email", "email": "new@example.org", "current_password": self.password})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.context["email_form"].non_field_errors())
|
|
self.assertFalse(ProfileEmailChange.objects.exists())
|
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
|
self.change_email.assert_not_called()
|
|
|
|
def test_email_token_get_only_exchanges_for_server_side_hash(self):
|
|
token, pending = self.pending()
|
|
response = self.exchange(token)
|
|
self.assertRedirects(response, self.confirm_url, fetch_redirect_response=False)
|
|
self.assertNotIn(token, response.url)
|
|
self.assertEqual(self.client.session["profile_email_hash"], pending.pk)
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
self.assert_no_writes()
|
|
response = self.client.get(self.confirm_url)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, pending.email)
|
|
self.assert_no_writes()
|
|
|
|
def test_confirmation_requires_current_password_and_consumes_token_once(self):
|
|
token, pending = self.pending()
|
|
self.exchange(token)
|
|
response = self.client.post(self.confirm_url, {})
|
|
self.assertIn("current_password", response.context["confirmation_form"].errors)
|
|
self.change_email.assert_not_called()
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
old_key = self.client.session.session_key
|
|
response = self.client.post(self.confirm_url, {"current_password": self.password, "email": "attacker@example.org", "uid": "mallory"})
|
|
self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False)
|
|
account, password, email = self.change_email.call_args.args
|
|
self.assertEqual((account.pk, account.ldap_uuid, password, email), (self.account.pk, "uuid-alice", self.password, pending.email))
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.email, pending.email)
|
|
self.assertFalse(ProfileEmailChange.objects.filter(account=self.account).exists())
|
|
self.assertNotIn("profile_email_hash", self.client.session)
|
|
self.assertEqual(self.client.session["account_id"], str(self.account.pk))
|
|
self.assertNotEqual(self.client.session.session_key, old_key)
|
|
self.assertContains(self.exchange(token), "Link unavailable")
|
|
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
|
|
self.change_email.assert_called_once()
|
|
|
|
def test_confirmation_rejects_another_signed_in_account_even_with_session_hash(self):
|
|
token, pending = self.pending()
|
|
bob = Account.objects.create(uid="bob", email="bob@example.org", oidc_issuer=settings.DEX_ISSUER, oidc_sub="sub-bob", ldap_uuid="uuid-bob", ldap_dn="uid=bob," + SEARCH_DN)
|
|
self.sign_in(self.client, bob)
|
|
self.assertContains(self.exchange(token), "Link unavailable")
|
|
session = self.client.session
|
|
session["profile_email_hash"] = pending.pk
|
|
session.save()
|
|
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
self.read.assert_not_called()
|
|
self.assert_no_writes()
|
|
|
|
def test_expired_unknown_and_expired_after_exchange_tokens_cannot_write(self):
|
|
expired_token, _ = self.pending(expires_at=timezone.now() - timedelta(seconds=1))
|
|
self.assertContains(self.exchange(expired_token), "Link unavailable")
|
|
self.assertContains(self.exchange(secrets.token_urlsafe(32)), "Link unavailable")
|
|
token, pending = self.pending()
|
|
self.exchange(token)
|
|
ProfileEmailChange.objects.filter(pk=pending.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
|
|
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
|
|
self.assert_no_writes()
|
|
|
|
def test_confirmation_rejects_changed_uuid_and_keeps_old_email(self):
|
|
token, pending = self.pending(ldap_uuid="uuid-before-recreation")
|
|
self.exchange(token)
|
|
with self.assertLogs("members.views", level="ERROR"):
|
|
response = self.client.post(self.confirm_url, {"current_password": self.password})
|
|
self.assertTrue(response.context["confirmation_form"].non_field_errors())
|
|
self.change_email.assert_not_called()
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.email, "alice@example.org")
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
|
|
def test_confirmation_ldap_failure_preserves_pending_and_redacts_logs(self):
|
|
token, pending = self.pending()
|
|
self.exchange(token)
|
|
self.change_email.side_effect = ValueError(self.password)
|
|
with self.assertLogs("members.views", level="ERROR") as captured:
|
|
response = self.client.post(self.confirm_url, {"current_password": self.password})
|
|
self.assertTrue(response.context["confirmation_form"].non_field_errors())
|
|
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
|
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
|
self.assertNotContains(response, self.password)
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.email, "alice@example.org")
|
|
|
|
def test_cn_named_account_sees_read_only_name_and_cannot_post(self):
|
|
Account.objects.filter(pk=self.account.pk).update(ldap_dn="cn=Alice Example," + SEARCH_DN)
|
|
self.read.side_effect = lambda account: copy.deepcopy(self.values)
|
|
response = self.client.get(self.profile_url)
|
|
self.assertFalse(response.context["name_editable"])
|
|
self.assertContains(response, "<strong>Alice Example</strong>")
|
|
self.assertNotContains(response, 'name="cn"')
|
|
self.assertNotContains(response, 'value="profile"')
|
|
self.assertContains(response, 'value="email"')
|
|
self.assertContains(response, 'value="password"')
|
|
self.assertEqual(self.client.post(self.profile_url, self.profile_data(cn="New Name")).status_code, 400)
|
|
self.assert_no_writes()
|
|
|
|
def test_photo_endpoint_removed(self):
|
|
with self.assertRaises(NoReverseMatch):
|
|
reverse("members:profile_photo")
|
|
with self.assertRaises(Resolver404):
|
|
resolve(self.profile_url + "photo/")
|
|
self.assertEqual(self.client.post(self.profile_url, {**self.profile_data(), "jpegPhoto": "x"}).status_code, 302)
|
|
self.assertNotIn("jpegPhoto", self.update.call_args.args[1])
|
|
|
|
def test_get_only_endpoints_reject_post(self):
|
|
token, _ = self.pending()
|
|
for url in (reverse("members:verify_profile_email", args=[token]),):
|
|
self.assertEqual(self.client.post(url).status_code, 405)
|
|
self.assert_no_writes()
|
|
|
|
|
|
@override_settings(LDAP_SEARCH_BASE_DN=SEARCH_DN, LDAP_GROUP_PLACEHOLDER_DN="cn=sentinel,dc=example,dc=org")
|
|
class ProfileDirectoryTests(TestCase):
|
|
def setUp(self):
|
|
self.account = Account.objects.create(
|
|
uid="alice", email="alice@example.org", oidc_issuer=settings.DEX_ISSUER,
|
|
oidc_sub="sub-alice", ldap_uuid="uuid-alice", ldap_dn=ALICE_DN,
|
|
)
|
|
self.password = secrets.token_urlsafe(24)
|
|
self.values = profile_values()
|
|
self.conn = Connection(Server("offline.invalid"), client_strategy=MOCK_SYNC)
|
|
self.conn.strategy.add_entry(ALICE_DN, {**self.values, "objectClass": ["top", "person", "organizationalPerson", "inetOrgPerson"]})
|
|
self.conn.bind()
|
|
self.addCleanup(self.conn.unbind)
|
|
# No network access can escape this module, even if a test regresses.
|
|
patcher = patch("members.directory.Connection", side_effect=AssertionError("Real LDAP prohibited"))
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def service(self):
|
|
manager = MagicMock()
|
|
manager.__enter__.return_value = self.conn
|
|
return patch("members.profiles.directory.connection", return_value=manager)
|
|
|
|
def authorized(self, *, values=None):
|
|
conn = MagicMock()
|
|
conn.modify.return_value = True
|
|
conn.extend.standard.modify_password.return_value = True
|
|
conn.entries = []
|
|
authorized = MagicMock()
|
|
authorized.return_value.__enter__.return_value = (conn, self.values if values is None else values)
|
|
return conn, authorized
|
|
|
|
def cleaned(self, **changes):
|
|
return {
|
|
**{name: list(self.values[name]) for name in profiles.TEXT_ATTRIBUTES},
|
|
"current_password": self.password,
|
|
**changes,
|
|
}
|
|
|
|
def assert_critical_assertion(self, call):
|
|
controls = call.kwargs["controls"]
|
|
self.assertEqual(len(controls), 1)
|
|
self.assertEqual(str(controls[0]["controlType"]), "1.3.6.1.1.12")
|
|
self.assertTrue(bool(controls[0]["criticality"]))
|
|
encoded = bytes(controls[0]["controlValue"])
|
|
self.assertIn(b"entryUUID", encoded)
|
|
self.assertIn(b"uuid-alice", encoded)
|
|
self.assertIn(b"modifyTimestamp", encoded)
|
|
self.assertIn(b"20261029120000Z", encoded)
|
|
|
|
def test_read_uses_uuid_filter_fixed_attributes_and_persists_renamed_dn(self):
|
|
self.account.ldap_dn = "uid=old," + SEARCH_DN
|
|
self.account.save(update_fields=["ldap_dn"])
|
|
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
|
|
values = profiles.read_profile(self.account)
|
|
self.assertEqual(values["uid"], ["alice"])
|
|
self.assertEqual(values["entryUUID"], ["uuid-alice"])
|
|
self.assertEqual(set(values), set(profiles.READ_ATTRIBUTES))
|
|
search.assert_called_once_with(SEARCH_DN, "(entryUUID=uuid-alice)", attributes=list(profiles.READ_ATTRIBUTES))
|
|
self.assertNotIn("userPassword", search.call_args.kwargs["attributes"])
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.ldap_dn, ALICE_DN)
|
|
|
|
def test_unpinned_verified_identity_resolves_uid_and_saves_uuid(self):
|
|
self.account.ldap_uuid = None
|
|
self.account.ldap_dn = ""
|
|
self.account.save(update_fields=["ldap_uuid", "ldap_dn"])
|
|
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
|
|
profiles.read_profile(self.account)
|
|
self.assertEqual(search.call_args.args[1], "(&(objectClass=inetOrgPerson)(uid=alice))")
|
|
self.account.refresh_from_db()
|
|
self.assertEqual((self.account.ldap_uuid, self.account.ldap_dn), ("uuid-alice", ALICE_DN))
|
|
|
|
def test_missing_pinned_uuid_never_falls_back_to_matching_uid(self):
|
|
self.account.ldap_uuid = "uuid-deleted"
|
|
self.account.save(update_fields=["ldap_uuid"])
|
|
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
|
|
with self.assertRaisesRegex(ValueError, "missing or ambiguous"):
|
|
profiles.read_profile(self.account)
|
|
search.assert_called_once_with(SEARCH_DN, "(entryUUID=uuid-deleted)", attributes=list(profiles.READ_ATTRIBUTES))
|
|
self.account.refresh_from_db()
|
|
self.assertEqual(self.account.ldap_uuid, "uuid-deleted")
|
|
|
|
def test_unverified_subject_or_wrong_issuer_refused_before_directory_read(self):
|
|
for changes in ({"oidc_sub": None}, {"oidc_issuer": "https://other.example.org/"}):
|
|
with self.subTest(changes=changes):
|
|
Account.objects.filter(pk=self.account.pk).update(**changes)
|
|
with patch("members.profiles.directory.connection") as service:
|
|
with self.assertRaisesRegex(ValueError, "verified Dex"):
|
|
profiles.read_profile(self.account)
|
|
service.assert_not_called()
|
|
Account.objects.filter(pk=self.account.pk).update(oidc_sub="sub-alice", oidc_issuer=settings.DEX_ISSUER)
|
|
|
|
def test_uid_mismatch_duplicate_uuid_and_sentinel_refused(self):
|
|
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"mallory"]
|
|
with self.service(), self.assertRaisesRegex(ValueError, "does not match"):
|
|
profiles.read_profile(self.account)
|
|
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"alice"]
|
|
self.account.ldap_uuid = None
|
|
self.account.save(update_fields=["ldap_uuid"])
|
|
bob = Account.objects.create(uid="bob", email="bob@example.org", ldap_uuid="uuid-alice")
|
|
with self.service(), self.assertRaisesRegex(ValueError, "another account"):
|
|
profiles.read_profile(self.account)
|
|
bob.delete()
|
|
with self.service(), override_settings(LDAP_GROUP_PLACEHOLDER_DN=ALICE_DN.upper()), self.assertRaisesRegex(ValueError, "System entries"):
|
|
profiles.read_profile(self.account)
|
|
|
|
def test_authorized_uses_user_bind_base_scope_and_fixed_read_allowlist(self):
|
|
manager = MagicMock()
|
|
manager.__enter__.return_value = self.conn
|
|
with patch("members.profiles.directory._connection", return_value=manager) as connection, patch.object(self.conn, "search", wraps=self.conn.search) as search:
|
|
profiles.verify_password(self.account, self.password)
|
|
connection.assert_called_once_with(ALICE_DN, self.password)
|
|
search.assert_called_once_with(ALICE_DN, "(entryUUID=uuid-alice)", search_scope=BASE, attributes=list(profiles.READ_ATTRIBUTES))
|
|
|
|
def test_authorized_requires_current_password_uuid_dn_and_matching_identity(self):
|
|
for attribute in ("ldap_uuid", "ldap_dn"):
|
|
saved = getattr(self.account, attribute)
|
|
setattr(self.account, attribute, "")
|
|
with self.subTest(attribute=attribute), patch("members.profiles.directory._connection") as connection:
|
|
with self.assertRaises(ValueError):
|
|
profiles.verify_password(self.account, self.password)
|
|
connection.assert_not_called()
|
|
setattr(self.account, attribute, saved)
|
|
with patch("members.profiles.directory._connection") as connection:
|
|
with self.assertRaises(ValueError):
|
|
profiles.verify_password(self.account, "")
|
|
connection.assert_not_called()
|
|
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"mallory"]
|
|
manager = MagicMock()
|
|
manager.__enter__.return_value = self.conn
|
|
with patch("members.profiles.directory._connection", return_value=manager), self.assertRaisesRegex(ValueError, "identity changed"):
|
|
profiles.verify_password(self.account, self.password)
|
|
|
|
def test_profile_update_allowlist_and_atomic_critical_assertion(self):
|
|
conn, authorized = self.authorized()
|
|
cleaned = self.cleaned(cn=["Changed"], displayName=["Ignored"], jpegPhoto=b"ignored", uid=["mallory"], mail=["bad@example.org"], userPassword=["injected"], objectClass=["admin"])
|
|
revision = profiles.revision(self.account, self.values)
|
|
with patch("members.profiles._authorized", authorized):
|
|
profiles.update_profile(self.account, cleaned, revision)
|
|
authorized.assert_called_once_with(self.account, self.password)
|
|
self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"cn": [(MODIFY_REPLACE, ["Changed"])]}))
|
|
self.assert_critical_assertion(conn.modify.call_args)
|
|
|
|
def test_profile_update_refuses_cn_named_entry(self):
|
|
conn, authorized = self.authorized()
|
|
self.account.ldap_dn = "cn=Alice Example," + SEARCH_DN
|
|
with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "entry name"):
|
|
profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, self.values))
|
|
conn.modify.assert_not_called()
|
|
|
|
def test_profile_edit_without_timestamp_fails_closed(self):
|
|
values = profile_values(modifyTimestamp=[])
|
|
conn, authorized = self.authorized(values=values)
|
|
with patch("members.profiles._authorized", authorized), self.assertRaises(ValueError):
|
|
profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, values))
|
|
conn.modify.assert_not_called()
|
|
|
|
def test_noop_does_not_write_and_extra_names_collapse_to_one(self):
|
|
conn, authorized = self.authorized()
|
|
with patch("members.profiles._authorized", authorized):
|
|
profiles.update_profile(self.account, self.cleaned(), profiles.revision(self.account, self.values))
|
|
conn.modify.assert_not_called()
|
|
values = profile_values(cn=["Alice", "Alice Example"])
|
|
conn, authorized = self.authorized(values=values)
|
|
with patch("members.profiles._authorized", authorized):
|
|
profiles.update_profile(self.account, self.cleaned(cn=["Alice"]), profiles.revision(self.account, values))
|
|
self.assertEqual(conn.modify.call_args.args[1], {"cn": [(MODIFY_REPLACE, ["Alice"])]})
|
|
|
|
def test_revision_bound_to_account_uuid_and_all_read_values(self):
|
|
valid = profiles.revision(self.account, self.values)
|
|
variants = [valid + "tampered", "", signing.dumps({"account": "other", "uuid": "uuid-alice", "digest": profiles._digest(self.values)}, salt="ldap-profile")]
|
|
with patch("members.profiles.signing.time.time", return_value=timezone.now().timestamp() - 3601):
|
|
variants.append(profiles.revision(self.account, self.values))
|
|
for revision in variants:
|
|
with self.subTest(revision=revision):
|
|
conn, authorized = self.authorized()
|
|
with patch("members.profiles._authorized", authorized), self.assertRaises((ValueError, signing.BadSignature)):
|
|
profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), revision)
|
|
conn.modify.assert_not_called()
|
|
for changed in (profile_values(cn=["Concurrent edit"]), profile_values(mail=["moved@example.org"]), profile_values(modifyTimestamp=["20261029120100Z"])):
|
|
conn, authorized = self.authorized(values=changed)
|
|
with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "profile changed"):
|
|
profiles.update_profile(self.account, self.cleaned(), valid)
|
|
conn.modify.assert_not_called()
|
|
self.account.ldap_uuid = "uuid-recreated"
|
|
conn, authorized = self.authorized()
|
|
with patch("members.profiles._authorized", authorized), self.assertRaises(ValueError):
|
|
profiles.update_profile(self.account, self.cleaned(), valid)
|
|
conn.modify.assert_not_called()
|
|
|
|
def test_password_uses_rfc3062_not_userpassword_attribute_or_local_hashing(self):
|
|
conn, authorized = self.authorized()
|
|
new_password = secrets.token_urlsafe(24)
|
|
with patch("members.profiles._authorized", authorized):
|
|
profiles.change_password(self.account, self.password, new_password)
|
|
authorized.assert_called_once_with(self.account, self.password)
|
|
call = conn.extend.standard.modify_password.call_args
|
|
self.assertEqual(call.args, ())
|
|
self.assertEqual(call.kwargs["old_password"], self.password)
|
|
self.assertEqual(call.kwargs["new_password"], new_password)
|
|
self.assertNotIn("controls", call.kwargs)
|
|
conn.modify.assert_not_called()
|
|
|
|
def test_email_change_replaces_mail_with_critical_assertion(self):
|
|
conn, authorized = self.authorized()
|
|
with patch("members.profiles._authorized", authorized):
|
|
profiles.change_email(self.account, self.password, "new@example.org")
|
|
conn.search.assert_called_once_with(SEARCH_DN, "(mail=new@example.org)", attributes=["entryUUID"])
|
|
self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"mail": [(MODIFY_REPLACE, ["new@example.org"])]}))
|
|
self.assert_critical_assertion(conn.modify.call_args)
|
|
|
|
def test_email_uniqueness_rejects_other_uuid_but_allows_current_entry(self):
|
|
for uuid, allowed in (("uuid-bob", False), ("uuid-alice", True)):
|
|
conn, authorized = self.authorized()
|
|
entry = MagicMock()
|
|
entry.entryUUID.value = uuid
|
|
conn.entries = [entry]
|
|
with self.subTest(uuid=uuid), patch("members.profiles._authorized", authorized):
|
|
if allowed:
|
|
profiles.change_email(self.account, self.password, "new@example.org")
|
|
conn.modify.assert_called_once()
|
|
else:
|
|
with self.assertRaises(ValueError):
|
|
profiles.change_email(self.account, self.password, "new@example.org")
|
|
conn.modify.assert_not_called()
|
|
|
|
def test_rejected_ldap_writes_raise_instead_of_reporting_success(self):
|
|
conn, authorized = self.authorized()
|
|
conn.modify.return_value = False
|
|
conn.extend.standard.modify_password.return_value = False
|
|
with patch("members.profiles._authorized", authorized):
|
|
with self.assertRaises(LDAPException):
|
|
profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, self.values))
|
|
with self.assertRaises(LDAPException):
|
|
profiles.change_password(self.account, self.password, secrets.token_urlsafe(24))
|
|
with self.assertRaises(LDAPException):
|
|
profiles.change_email(self.account, self.password, "new@example.org")
|