ungleich-user/dal/tests/test_flows.py
Sajeyks ab45535f38 Replace django-auth-ldap and python-ldap with an ldap3 backend
python-ldap is a C extension that needs the OpenLDAP headers to build
(and needed a workaround in the Dockerfile). ldap3, which the app
already used for all writes, is pure Python.

dal.backends.LdapBackend keeps the django-auth-ldap behaviour: search
the uid in every LDAPSEARCH base as the admin DN, require exactly one
match, log in with the user's DN, and mirror givenName, sn and mail into
the local Django user. Empty passwords are rejected (LDAP treats them
as anonymous logins, which succeed).

LDAP_USE_TLS now applies StartTLS to every connection, including the
admin connection used for writes, not just logins.
2026-11-06 15:15:03 +03:00

445 lines
20 KiB
Python

"""
End-to-end behaviour of the account webapp against a real LDAP server.
Several tests guard against security bugs found in the 2026 upgrade audit
(account takeover via /reset/, LDAP filter injection, passwords in links,
duplicate usernames across subtrees).
"""
from django.core import mail
from .base import LdapTestCase, unique_uid
REGISTER = "/register/"
RESET = "/resetpassword/"
class LoginTests(LdapTestCase):
def test_landing_page_renders(self):
resp = self.client.get("/")
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "landing.html")
def test_customer_can_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
self.assertIn("_auth_user_id", self.client.session)
def test_staff_in_ou_users_can_log_in(self):
uid = self.make_ldap_user(password="staff-pass-123", ou="users")
resp = self.client.post("/index/", {"username": uid, "password": "staff-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
def test_wrong_password_is_rejected(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "nope"})
self.assertNotIn("_auth_user_id", self.client.session)
self.assertContains(resp, "incorrect")
def test_empty_password_is_rejected(self):
"""An empty password is an anonymous LDAP bind, which succeeds."""
uid = self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username=uid, password=""))
resp = self.client.post("/index/", {"username": uid, "password": ""})
self.assertNotIn("_auth_user_id", self.client.session)
def test_unknown_user_is_rejected(self):
self.assertFalse(self.client.login(username="nobody-here", password="x"))
def test_filter_characters_in_username_do_not_match(self):
self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username="*", password="cust-pass-123"))
def test_uid_in_two_subtrees_cannot_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.make_ldap_user(uid=uid, password="cust-pass-123", ou="users")
self.assertFalse(self.client.login(username=uid, password="cust-pass-123"))
def test_login_mirrors_ldap_attributes(self):
from django.contrib.auth.models import User
uid = self.make_ldap_user(password="cust-pass-123", email="mirror@example.com")
self.login(uid, "cust-pass-123")
user = User.objects.get(username=uid)
self.assertEqual((user.first_name, user.last_name, user.email),
("Tina", "Tester", "mirror@example.com"))
self.assertFalse(user.has_usable_password())
def test_logged_in_user_gets_options_page(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/")
self.assertTemplateUsed(resp, "useroptions.html")
self.assertIn("no-store", resp["Cache-Control"])
def test_logout(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/logout/")
self.assertEqual(resp.status_code, 200)
self.assertNotIn("_auth_user_id", self.client.session)
class RegisterTests(LdapTestCase):
def form(self, **overrides):
data = {"username": unique_uid("reg"), "password1": "reg-pass-12345",
"password2": "reg-pass-12345", "email": "reg@example.com",
"firstname": "Rita", "lastname": "Register"}
data.update(overrides)
return data
def assert_error(self, data, text):
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "error.html")
self.assertContains(resp, text)
self.assertEqual(len(mail.outbox), 0)
def test_form_renders(self):
self.assertTemplateUsed(self.client.get(REGISTER), "registeruser.html")
def test_validation_errors(self):
self.assert_error(self.form(username=""), "Please supply a username")
self.assert_error(self.form(username="Bad Name"), "lowercase letters")
self.assert_error(self.form(username="pipe|name"), "lowercase letters")
self.assert_error(self.form(password2="different"), "match")
self.assert_error(self.form(email="not-an-email"), "email address is invalid")
self.assert_error(self.form(lastname=""), "firstname and lastname")
def test_full_registration_and_activation(self):
data = self.form()
uid = data["username"]
self.track(uid)
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "confirm_email.html")
self.assertEqual(len(mail.outbox), 1)
self.assertIsNone(self.ldap_entry(uid), "user must not exist before activation")
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "usercreated.html")
entry = self.ldap_entry(uid)
self.assertIsNotNone(entry)
self.assertEqual(entry.mail.value, "reg@example.com")
self.assertEqual(entry.givenName.value, "Rita")
self.assertEqual(int(entry.gidNumber.value), 2000)
self.assertIn("ldapPublicKey", entry.objectClass.values)
self.assertTrue(entry.userPassword.value.startswith(b"{SSHA}"))
self.assertTrue(self.can_bind(uid, "reg-pass-12345"))
self.otp_post.assert_called_once() # OTP seed requested for new user
# Activation link is single use
self.assertEqual(self.client.get(link).status_code, 404)
def test_tampered_activation_token_is_rejected(self):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.assertEqual(self.client.get("/activate/not-the-token/").status_code, 404)
self.assertIsNone(self.ldap_entry(data["username"]))
def test_uid_numbers_are_unique(self):
uids = []
for _ in range(2):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.client.get(self.last_mail_link())
uids.append(int(self.ldap_entry(data["username"]).uidNumber.value))
self.assertNotEqual(uids[0], uids[1])
def test_uid_number_is_above_every_existing_one(self):
from .base import admin_conn
self.make_ldap_user(ou="users") # random high uidNumber
conn = admin_conn()
conn.search("dc=ungleich,dc=ch", "(uidNumber=*)", attributes=["uidNumber"])
highest = max(int(e.uidNumber.value) for e in conn.entries)
conn.unbind()
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.client.get(self.last_mail_link())
self.assertEqual(int(self.ldap_entry(data["username"]).uidNumber.value), highest + 1)
def test_password_not_in_activation_link_or_db(self):
"""The plaintext password must never be stored or put in a URL."""
from dal.models import PendingRegistration
import base64
data = self.form(password1="Very-Secret-pw-1", password2="Very-Secret-pw-1")
self.track(data["username"])
self.client.post(REGISTER, data)
link = self.last_mail_link()
self.assertNotIn(base64.b64encode(b"Very-Secret-pw-1").decode(), link)
self.assertNotIn("Very-Secret-pw-1", mail.outbox[-1].body)
stored = PendingRegistration.objects.get(username=data["username"])
self.assertTrue(stored.password_hash.startswith("{SSHA}"))
self.assertNotIn("Very-Secret-pw-1", stored.password_hash)
def test_cannot_register_name_taken_by_staff(self):
"""Username in ou=users must not be re-registrable under ou=customers."""
staff = self.make_ldap_user(ou="users")
self.track(staff) # in case the bug creates a customer duplicate
self.client.post(REGISTER, self.form(username=staff))
if mail.outbox:
self.client.get(self.last_mail_link())
self.assertIsNone(
self.ldap_entry_in(staff, "ou=customers,dc=ungleich,dc=ch"))
def test_taken_username_rejected_at_form(self):
existing = self.make_ldap_user()
self.assert_error(self.form(username=existing), "already taken")
def test_name_taken_between_signup_and_activation(self):
data = self.form()
uid = data["username"]
self.client.post(REGISTER, data)
link = self.last_mail_link()
self.make_ldap_user(uid=uid, ou="users") # someone else got it first
resp = self.client.get(link)
self.assertTemplateUsed(resp, "error.html")
self.assertIsNone(self.ldap_entry_in(uid, "ou=customers,dc=ungleich,dc=ch"))
def ldap_entry_in(self, uid, base):
from .base import admin_conn
conn = admin_conn()
conn.search(base, "(uid={})".format(uid), attributes=["uid"])
found = list(conn.entries)
conn.unbind()
return found[0] if found else None
class ChangeDataTests(LdapTestCase):
def test_requires_login(self):
resp = self.client.get("/changedata/")
self.assertEqual(resp.status_code, 302)
def test_shows_and_updates_data(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/changedata/")
self.assertTemplateUsed(resp, "changeuserdata.html")
self.assertContains(resp, "Tina")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "new@example.com"})
self.assertTemplateUsed(resp, "changeddata.html")
entry = self.ldap_entry(uid)
self.assertEqual(entry.givenName.value, "Neo")
self.assertEqual(entry.sn.value, "Name")
self.assertEqual(entry.mail.value, "new@example.com")
def test_rejects_invalid_email(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "broken"})
self.assertTemplateUsed(resp, "error.html")
self.assertEqual(self.ldap_entry(uid).mail.value, uid + "@example.com")
class ChangePasswordTests(LdapTestCase):
def setUp(self):
super().setUp()
self.uid = self.make_ldap_user(password="old-pass-1234")
self.login(self.uid, "old-pass-1234")
def post(self, old, new1, new2=None):
return self.client.post("/changepassword/", {
"oldpassword": old, "password1": new1, "password2": new2 or new1})
def test_wrong_old_password(self):
resp = self.post("wrong", "new-pass-1234")
self.assertContains(resp, "Wrong password")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_mismatch_and_too_short(self):
self.assertContains(self.post("old-pass-1234", "new-pass-1234", "other-pass-1234"),
"same password")
self.assertContains(self.post("old-pass-1234", "short"), "too short")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_success_changes_ldap_password_and_logs_out(self):
resp = self.post("old-pass-1234", "new-pass-1234")
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(self.uid, "new-pass-1234"))
self.assertFalse(self.can_bind(self.uid, "old-pass-1234"))
self.assertNotIn("_auth_user_id", self.client.session)
class ResetPasswordTests(LdapTestCase):
def test_unknown_user(self):
resp = self.client.post(RESET, {"user": "does-not-exist"})
self.assertContains(resp, "does not exist")
self.assertEqual(len(mail.outbox), 0)
def test_full_reset_flow(self):
uid = self.make_ldap_user(password="old-pass-1234")
resp = self.client.post(RESET, {"user": uid})
self.assertTemplateUsed(resp, "send_resetrequest.html")
self.assertEqual(mail.outbox[0].to, ["{0} <{0}@example.com>".format(uid)])
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "resetpasswordnew.html")
self.assertEqual(self.client.get(link).status_code, 404, "link must be single use")
resp = self.client.post("/reset/", {
"user": uid, "password1": "brand-new-1234", "password2": "brand-new-1234"})
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(uid, "brand-new-1234"))
def test_verified_link_cannot_reset_another_user(self):
mine = self.make_ldap_user(password="old-pass-1234")
victim = self.make_ldap_user(password="victim-pass-1234")
self.client.post(RESET, {"user": mine})
self.client.get(self.last_mail_link())
self.client.post("/reset/", {
"user": victim, "password1": "attacker-1234", "password2": "attacker-1234"})
self.assertTrue(self.can_bind(victim, "victim-pass-1234"))
self.assertTrue(self.can_bind(mine, "attacker-1234"))
def test_verified_link_is_single_use(self):
uid = self.make_ldap_user(password="old-pass-1234")
self.client.post(RESET, {"user": uid})
self.client.get(self.last_mail_link())
self.client.post("/reset/", {
"user": uid, "password1": "first-new-1234", "password2": "first-new-1234"})
resp = self.client.post("/reset/", {
"user": uid, "password1": "second-new-1234", "password2": "second-new-1234"})
self.assertContains(resp, "invalid or has expired")
self.assertTrue(self.can_bind(uid, "first-new-1234"))
def test_garbage_reset_link_is_404(self):
self.assertEqual(self.client.get("/reset/%%%/abc/").status_code, 404)
def test_rejects_short_new_password(self):
uid = self.make_ldap_user(password="old-pass-1234")
self.client.post(RESET, {"user": uid})
self.client.get(self.last_mail_link())
resp = self.client.post("/reset/", {
"user": uid, "password1": "short", "password2": "short"})
self.assertContains(resp, "too short")
def test_reset_post_requires_a_valid_token(self):
"""POST /reset/ must not set a password without a verified link."""
victim = self.make_ldap_user(password="victim-pass-1234", ou="users")
self.client.post("/reset/", {
"user": victim, "password1": "attacker-1234", "password2": "attacker-1234"})
self.assertFalse(self.can_bind(victim, "attacker-1234", ou="users"),
"account takeover without any token")
def test_ldap_filter_injection_in_reset(self):
"""user='*' must not match the first entry in the whole tree."""
self.make_ldap_user(password="victim-pass-1234")
self.client.post("/reset/", {
"user": "*", "password1": "attacker-1234", "password2": "attacker-1234"})
from .base import admin_conn
conn = admin_conn()
conn.search("dc=ungleich,dc=ch", "(uid=*)", attributes=["uid"])
uids = [e.uid.value for e in conn.entries]
conn.unbind()
hijacked = [u for u in uids for ou in ("customers", "users")
if self.can_bind(u, "attacker-1234", ou=ou)]
self.assertEqual(hijacked, [])
class DeleteAccountTests(LdapTestCase):
def test_requires_login(self):
self.assertEqual(self.client.get("/deleteaccount/").status_code, 302)
def test_wrong_password_keeps_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "nope"})
self.assertContains(resp, "Wrong password")
self.assertIsNotNone(self.ldap_entry(uid))
def test_cannot_delete_someone_else(self):
me = self.make_ldap_user(password="cust-pass-123")
other = self.make_ldap_user(password="other-pass-123")
self.login(me, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": other, "password": "other-pass-123"})
self.assertContains(resp, "Unknown user")
self.assertIsNotNone(self.ldap_entry(other))
def test_delete_own_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "deleteduser.html")
self.assertIsNone(self.ldap_entry(uid))
self.assertNotIn("_auth_user_id", self.client.session)
class UserCreateApiTests(LdapTestCase):
def test_validation(self):
self.assertEqual(self.client.post("/create/", {}).status_code, 400)
taken = self.make_ldap_user()
self.assertEqual(self.client.post("/create/", {
"username": taken, "email": "a@example.com", "firstname": "a", "lastname": "b"}
).status_code, 400)
self.assertEqual(self.client.post("/create/", {
"username": "ok-name", "email": "bad", "firstname": "a", "lastname": "b"}
).status_code, 400)
def test_creates_account_via_emailed_link(self):
uid = unique_uid("api")
self.track(uid)
resp = self.client.post("/create/", {
"username": uid, "email": "api@example.com",
"firstname": "Api", "lastname": "User"})
self.assertEqual(resp.status_code, 200)
body = mail.outbox[0].body
import re
password = re.search(r"Password: (\S+)", body).group(1)
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
self.assertTrue(self.can_bind(uid, password))
class OtpSeedTests(LdapTestCase):
def test_seed_list_shows_only_own_seeds(self):
import json
uid = self.make_ldap_user(password="cust-pass-123")
self.otp_get.return_value.text = json.dumps([
{"name": uid, "realm": "ungleich-auth", "seed": "MINE"},
{"name": "someone-else", "realm": "ungleich-auth", "seed": "THEIRS"},
])
self.login(uid, "cust-pass-123")
resp = self.client.get("/seeds/")
self.assertContains(resp, "MINE")
self.assertNotContains(resp, "THEIRS")
def test_api_does_not_print_password(self):
import io
from contextlib import redirect_stdout
uid = self.make_ldap_user(password="cust-pass-123")
out = io.StringIO()
with redirect_stdout(out), self.assertLogs("dal", "DEBUG") as logs:
import logging
logging.getLogger("dal").debug("sentinel")
self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertNotIn("cust-pass-123", out.getvalue())
self.assertNotIn("cust-pass-123", "\n".join(logs.output))
def test_api_rejects_bad_credentials(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "x", "realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 400)
def test_api_creates_seed_in_allowed_realm_only(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "evil-realm"})
self.assertEqual(resp.status_code, 403)
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 201)