The uid was formatted straight into search filters, so a password reset for user '*' matched and changed the first entry in the tree (staff accounts included). Escape filter values with escape_filter_chars and DN values with escape_rdn. Also fix the username pattern: [a-z|0-9|\-|_] allowed '|'.
347 lines
15 KiB
Python
347 lines
15 KiB
Python
"""
|
|
End-to-end behaviour of the account webapp against a real LDAP server.
|
|
|
|
Tests decorated with @expectedFailure document known bugs found during the
|
|
upgrade audit. When a bug is fixed the test starts passing, Django reports an
|
|
"unexpected success", and the decorator must be removed.
|
|
"""
|
|
from unittest import expectedFailure
|
|
|
|
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_logged_in_user_gets_options_page(self):
|
|
uid = self.make_ldap_user(password="cust-pass-123")
|
|
self.login(uid, "cust-pass-123")
|
|
self.assertTemplateUsed(self.client.get("/"), "useroptions.html")
|
|
|
|
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)
|
|
link = self.last_mail_link().rstrip("/")
|
|
bad = link.rsplit("/", 1)[0] + "/not-the-token/"
|
|
self.assertEqual(self.client.get(bad).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])
|
|
|
|
@expectedFailure
|
|
def test_bug_password_not_in_activation_link(self):
|
|
"""SECURITY: link carries base64(password); it lands in mail/proxy logs."""
|
|
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)
|
|
|
|
@expectedFailure
|
|
def test_bug_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 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_rejects_short_new_password(self):
|
|
uid = self.make_ldap_user(password="old-pass-1234")
|
|
resp = self.client.post("/reset/", {
|
|
"user": uid, "password1": "short", "password2": "short"})
|
|
self.assertContains(resp, "too short")
|
|
|
|
@expectedFailure
|
|
def test_bug_reset_post_requires_a_valid_token(self):
|
|
"""CRITICAL: POST /reset/ sets any user's password without a token."""
|
|
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)
|
|
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_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)
|