summaryrefslogtreecommitdiffstats
path: root/source4/dsdb/tests/python/password_settings.py
diff options
context:
space:
mode:
Diffstat (limited to 'source4/dsdb/tests/python/password_settings.py')
-rw-r--r--source4/dsdb/tests/python/password_settings.py876
1 files changed, 876 insertions, 0 deletions
diff --git a/source4/dsdb/tests/python/password_settings.py b/source4/dsdb/tests/python/password_settings.py
new file mode 100644
index 0000000..cdaad05
--- /dev/null
+++ b/source4/dsdb/tests/python/password_settings.py
@@ -0,0 +1,876 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Tests for Password Settings Objects.
+#
+# This also tests the default password complexity (i.e. pwdProperties),
+# minPwdLength, pwdHistoryLength settings as a side-effect.
+#
+# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+#
+# Usage:
+# export SERVER_IP=target_dc
+# export SUBUNITRUN=$samba4srcdir/scripting/bin/subunitrun
+# PYTHONPATH="$PYTHONPATH:$samba4srcdir/dsdb/tests/python" $SUBUNITRUN \
+# password_settings -U"$DOMAIN/$DC_USERNAME"%"$DC_PASSWORD"
+#
+
+import samba.tests
+import ldb
+from ldb import FLAG_MOD_DELETE, FLAG_MOD_ADD, FLAG_MOD_REPLACE
+from samba import dsdb
+import time
+from samba.tests.password_test import PasswordTestCase
+from samba.tests.pso import TestUser
+from samba.tests.pso import PasswordSettings
+from samba.tests import env_get_var_value
+from samba.credentials import Credentials
+from samba import gensec
+import base64
+
+
+class PasswordSettingsTestCase(PasswordTestCase):
+ def setUp(self):
+ super(PasswordSettingsTestCase, self).setUp()
+
+ self.host_url = "ldap://%s" % env_get_var_value("SERVER_IP")
+ self.ldb = samba.tests.connect_samdb(self.host_url)
+
+ # create a temp OU to put this test's users into
+ self.ou = samba.tests.create_test_ou(self.ldb, "password_settings")
+
+ # update DC to allow password changes for the duration of this test
+ self.allow_password_changes()
+
+ # store the current password-settings for the domain
+ self.pwd_defaults = PasswordSettings(None, self.ldb)
+ self.test_objs = []
+
+ def tearDown(self):
+ super(PasswordSettingsTestCase, self).tearDown()
+
+ # remove all objects under the top-level OU
+ self.ldb.delete(self.ou, ["tree_delete:1"])
+
+ # PSOs can't reside within an OU so they get cleaned up separately
+ for obj in self.test_objs:
+ self.ldb.delete(obj)
+
+ def add_obj_cleanup(self, dn_list):
+ """Handles cleanup of objects outside of the test OU in the tearDown"""
+ self.test_objs.extend(dn_list)
+
+ def add_group(self, group_name):
+ """Creates a new group"""
+ dn = "CN=%s,%s" % (group_name, self.ou)
+ self.ldb.add({"dn": dn, "objectclass": "group"})
+ return dn
+
+ def set_attribute(self, dn, attr, value, operation=FLAG_MOD_ADD,
+ samdb=None):
+ """Modifies an attribute for an object"""
+ if samdb is None:
+ samdb = self.ldb
+ m = ldb.Message()
+ m.dn = ldb.Dn(samdb, dn)
+ m[attr] = ldb.MessageElement(value, operation, attr)
+ samdb.modify(m)
+
+ def add_user(self, username):
+ # add a new user to the DB under our top-level OU
+ userou = "ou=%s" % self.ou.get_component_value(0)
+ return TestUser(username, self.ldb, userou=userou)
+
+ def assert_password_invalid(self, user, password):
+ """
+ Check we can't set a password that violates complexity or length
+ constraints
+ """
+ try:
+ user.set_password(password)
+ # fail the test if no exception was encountered
+ self.fail("Password '%s' should have been rejected" % password)
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_CONSTRAINT_VIOLATION, msg)
+ self.assertTrue('0000052D' in msg, msg)
+
+ def assert_password_valid(self, user, password):
+ """Checks that we can set a password successfully"""
+ try:
+ user.set_password(password)
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ # fail the test (rather than throw an error)
+ self.fail("Password '%s' unexpectedly rejected: %s" % (password,
+ msg))
+
+ def assert_PSO_applied(self, user, pso):
+ """
+ Asserts the expected PSO is applied by checking the msDS-ResultantPSO
+ attribute, as well as checking the corresponding password-length,
+ complexity, and history are enforced correctly
+ """
+ resultant_pso = user.get_resultant_PSO()
+ self.assertTrue(resultant_pso == pso.dn,
+ "Expected PSO %s, not %s" % (pso.name,
+ str(resultant_pso)))
+
+ # we're mirroring the pwd_history for the user, so make sure this is
+ # up-to-date, before we start making password changes
+ if user.last_pso:
+ user.pwd_history_change(user.last_pso.history_len, pso.history_len)
+ user.last_pso = pso
+
+ # check if we can set a sufficiently long, but non-complex, password.
+ # (We use the history-size to generate a unique password for each
+ # assertion - otherwise, if the password is already in the history,
+ # then it'll be rejected)
+ unique_char = chr(ord('a') + len(user.all_old_passwords))
+ noncomplex_pwd = "%cabcdefghijklmnopqrst" % unique_char
+
+ if pso.complexity:
+ self.assert_password_invalid(user, noncomplex_pwd)
+ else:
+ self.assert_password_valid(user, noncomplex_pwd)
+
+ # use a unique and sufficiently complex base-string to check pwd-length
+ pass_phrase = "%d#AaBbCcDdEeFfGgHhIi" % len(user.all_old_passwords)
+
+ # check that passwords less than the specified length are rejected
+ for i in range(3, pso.password_len):
+ self.assert_password_invalid(user, pass_phrase[:i])
+
+ # check we can set a password that's exactly the minimum length
+ self.assert_password_valid(user, pass_phrase[:pso.password_len])
+
+ # check the password history is enforced correctly.
+ # first, check the last n items in the password history are invalid
+ invalid_passwords = user.old_invalid_passwords(pso.history_len)
+ for pwd in invalid_passwords:
+ self.assert_password_invalid(user, pwd)
+
+ # next, check any passwords older than the history-len can be re-used
+ valid_passwords = user.old_valid_passwords(pso.history_len)
+ for pwd in valid_passwords:
+ self.assert_set_old_password(user, pwd, pso)
+
+ def password_is_complex(self, password):
+ # non-complex passwords used in the tests are all lower-case letters
+ # If it's got a number in the password, assume it's complex
+ return any(c.isdigit() for c in password)
+
+ def assert_set_old_password(self, user, password, pso):
+ """
+ Checks a user password can be set (if the password conforms to the PSO
+ settings). Used to check an old password that falls outside the history
+ length, but might still be invalid for other reasons.
+ """
+ if self.password_is_complex(password):
+ # check password conforms to length requirements
+ if len(password) < pso.password_len:
+ self.assert_password_invalid(user, password)
+ else:
+ self.assert_password_valid(user, password)
+ else:
+ # password is not complex, check PSO handles it appropriately
+ if pso.complexity:
+ self.assert_password_invalid(user, password)
+ else:
+ self.assert_password_valid(user, password)
+
+ def test_pso_basics(self):
+ """Simple tests that a PSO takes effect when applied to a group/user"""
+
+ # create some PSOs that vary in priority and basic password-len
+ best_pso = PasswordSettings("highest-priority-PSO", self.ldb,
+ precedence=5, password_len=16,
+ history_len=6)
+ medium_pso = PasswordSettings("med-priority-PSO", self.ldb,
+ precedence=15, password_len=10,
+ history_len=4)
+ worst_pso = PasswordSettings("lowest-priority-PSO", self.ldb,
+ precedence=100, complexity=False,
+ password_len=4, history_len=2)
+
+ # handle PSO clean-up (as they're outside the top-level test OU)
+ self.add_obj_cleanup([worst_pso.dn, medium_pso.dn, best_pso.dn])
+
+ # create some groups and apply the PSOs to the groups
+ group1 = self.add_group("Group-1")
+ group2 = self.add_group("Group-2")
+ group3 = self.add_group("Group-3")
+ group4 = self.add_group("Group-4")
+ worst_pso.apply_to(group1)
+ medium_pso.apply_to(group2)
+ best_pso.apply_to(group3)
+ worst_pso.apply_to(group4)
+
+ # create a user and check the default settings apply to it
+ user = self.add_user("testuser")
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ # add user to a group. Check that the group's PSO applies to the user
+ self.set_attribute(group1, "member", user.dn)
+ self.assert_PSO_applied(user, worst_pso)
+
+ # add the user to a group with a higher precedence PSO and and check
+ # that now trumps the previous PSO
+ self.set_attribute(group2, "member", user.dn)
+ self.assert_PSO_applied(user, medium_pso)
+
+ # add the user to the remaining groups. The highest precedence PSO
+ # should now take effect
+ self.set_attribute(group3, "member", user.dn)
+ self.set_attribute(group4, "member", user.dn)
+ self.assert_PSO_applied(user, best_pso)
+
+ # delete a group membership and check the PSO changes
+ self.set_attribute(group3, "member", user.dn,
+ operation=FLAG_MOD_DELETE)
+ self.assert_PSO_applied(user, medium_pso)
+
+ # apply the low-precedence PSO directly to the user
+ # (directly applied PSOs should trump higher precedence group PSOs)
+ worst_pso.apply_to(user.dn)
+ self.assert_PSO_applied(user, worst_pso)
+
+ # remove applying the PSO directly to the user and check PSO changes
+ worst_pso.unapply(user.dn)
+ self.assert_PSO_applied(user, medium_pso)
+
+ # remove all appliesTo and check we have the default settings again
+ worst_pso.unapply(group1)
+ medium_pso.unapply(group2)
+ worst_pso.unapply(group4)
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ def test_pso_nested_groups(self):
+ """PSOs operate correctly when applied to nested groups"""
+
+ # create some PSOs that vary in priority and basic password-len
+ group1_pso = PasswordSettings("group1-PSO", self.ldb, precedence=50,
+ password_len=12, history_len=3)
+ group2_pso = PasswordSettings("group2-PSO", self.ldb, precedence=25,
+ password_len=10, history_len=5,
+ complexity=False)
+ group3_pso = PasswordSettings("group3-PSO", self.ldb, precedence=10,
+ password_len=6, history_len=2)
+
+ # create some groups and apply the PSOs to the groups
+ group1 = self.add_group("Group-1")
+ group2 = self.add_group("Group-2")
+ group3 = self.add_group("Group-3")
+ group4 = self.add_group("Group-4")
+ group1_pso.apply_to(group1)
+ group2_pso.apply_to(group2)
+ group3_pso.apply_to(group3)
+
+ # create a PSO and apply it to a group that the user is not a member
+ # of - it should not have any effect on the user
+ unused_pso = PasswordSettings("unused-PSO", self.ldb, precedence=1,
+ password_len=20)
+ unused_pso.apply_to(group4)
+
+ # handle PSO clean-up (as they're outside the top-level test OU)
+ self.add_obj_cleanup([group1_pso.dn, group2_pso.dn, group3_pso.dn,
+ unused_pso.dn])
+
+ # create a user and check the default settings apply to it
+ user = self.add_user("testuser")
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ # add user to a group. Check that the group's PSO applies to the user
+ self.set_attribute(group1, "member", user.dn)
+ self.set_attribute(group2, "member", group1)
+ self.assert_PSO_applied(user, group2_pso)
+
+ # add another level to the group hierarchy & check this PSO takes effect
+ self.set_attribute(group3, "member", group2)
+ self.assert_PSO_applied(user, group3_pso)
+
+ # invert the PSO precedence and check the new lowest value takes effect
+ group1_pso.set_precedence(3)
+ group2_pso.set_precedence(13)
+ group3_pso.set_precedence(33)
+ self.assert_PSO_applied(user, group1_pso)
+
+ # delete a PSO and check it no longer applies
+ self.ldb.delete(group1_pso.dn)
+ self.test_objs.remove(group1_pso.dn)
+ self.assert_PSO_applied(user, group2_pso)
+
+ def get_guid(self, dn):
+ res = self.ldb.search(base=dn, attrs=["objectGUID"],
+ scope=ldb.SCOPE_BASE)
+ return res[0]['objectGUID'][0]
+
+ def guid_string(self, guid):
+ return self.ldb.schema_format_value("objectGUID", guid)
+
+ def PSO_with_lowest_GUID(self, pso_list):
+ """Returns the PSO object in the list with the lowest GUID"""
+ # go through each PSO and fetch its GUID
+ guid_list = []
+ mapping = {}
+ for pso in pso_list:
+ guid = self.get_guid(pso.dn)
+ guid_list.append(guid)
+ # remember which GUID maps to what PSO
+ mapping[guid] = pso
+
+ # sort the GUID list to work out the lowest/best GUID
+ guid_list.sort()
+ best_guid = guid_list[0]
+
+ # sanity-check the mapping between GUID and DN is correct
+ best_pso_dn = mapping[best_guid].dn
+ self.assertEqual(self.guid_string(self.get_guid(best_pso_dn)),
+ self.guid_string(best_guid))
+
+ # return the PSO that this GUID corresponds to
+ return mapping[best_guid]
+
+ def test_pso_equal_precedence(self):
+ """Tests expected PSO wins when several have the same precedence"""
+
+ # create some PSOs that vary in priority and basic password-len
+ pso1 = PasswordSettings("PSO-1", self.ldb, precedence=5, history_len=1,
+ password_len=11)
+ pso2 = PasswordSettings("PSO-2", self.ldb, precedence=5, history_len=2,
+ password_len=8)
+ pso3 = PasswordSettings("PSO-3", self.ldb, precedence=5, history_len=3,
+ password_len=5, complexity=False)
+ pso4 = PasswordSettings("PSO-4", self.ldb, precedence=5, history_len=4,
+ password_len=13, complexity=False)
+
+ # handle PSO clean-up (as they're outside the top-level test OU)
+ self.add_obj_cleanup([pso1.dn, pso2.dn, pso3.dn, pso4.dn])
+
+ # create some groups and apply the PSOs to the groups
+ group1 = self.add_group("Group-1")
+ group2 = self.add_group("Group-2")
+ group3 = self.add_group("Group-3")
+ group4 = self.add_group("Group-4")
+ pso1.apply_to(group1)
+ pso2.apply_to(group2)
+ pso3.apply_to(group3)
+ pso4.apply_to(group4)
+
+ # create a user and check the default settings apply to it
+ user = self.add_user("testuser")
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ # add the user to all the groups
+ self.set_attribute(group1, "member", user.dn)
+ self.set_attribute(group2, "member", user.dn)
+ self.set_attribute(group3, "member", user.dn)
+ self.set_attribute(group4, "member", user.dn)
+
+ # precedence is equal, so the PSO with lowest GUID gets applied
+ pso_list = [pso1, pso2, pso3, pso4]
+ best_pso = self.PSO_with_lowest_GUID(pso_list)
+ self.assert_PSO_applied(user, best_pso)
+
+ # excluding the winning PSO, apply the other PSOs directly to the user
+ pso_list.remove(best_pso)
+ for pso in pso_list:
+ pso.apply_to(user.dn)
+
+ # we should now have a different PSO applied (the 2nd lowest GUID)
+ next_best_pso = self.PSO_with_lowest_GUID(pso_list)
+ self.assertTrue(next_best_pso is not best_pso)
+ self.assert_PSO_applied(user, next_best_pso)
+
+ # bump the precedence of another PSO and it should now win
+ pso_list.remove(next_best_pso)
+ best_pso = pso_list[0]
+ best_pso.set_precedence(4)
+ self.assert_PSO_applied(user, best_pso)
+
+ def test_pso_invalid_location(self):
+ """Tests that PSOs in an invalid location have no effect"""
+
+ # PSOs should only be able to be created within a Password Settings
+ # Container object. Trying to create one under an OU should fail
+ try:
+ rogue_pso = PasswordSettings("rogue-PSO", self.ldb, precedence=1,
+ complexity=False, password_len=20,
+ container=self.ou)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_NAMING_VIOLATION, msg)
+ # Windows returns 2099 (Illegal superior), Samba returns 2037
+ # (Naming violation - "not a valid child class")
+ self.assertTrue('00002099' in msg or '00002037' in msg, msg)
+
+ # we can't create Password Settings Containers under an OU either
+ try:
+ rogue_psc = "CN=Rogue-PSO-container,%s" % self.ou
+ self.ldb.add({"dn": rogue_psc,
+ "objectclass": "msDS-PasswordSettingsContainer"})
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_NAMING_VIOLATION, msg)
+ self.assertTrue('00002099' in msg or '00002037' in msg, msg)
+
+ base_dn = self.ldb.get_default_basedn()
+ rogue_psc = "CN=Rogue-PSO-container,CN=Computers,%s" % base_dn
+ self.ldb.add({"dn": rogue_psc,
+ "objectclass": "msDS-PasswordSettingsContainer"})
+
+ rogue_pso = PasswordSettings("rogue-PSO", self.ldb, precedence=1,
+ container=rogue_psc, password_len=20)
+ self.add_obj_cleanup([rogue_pso.dn, rogue_psc])
+
+ # apply the PSO to a group and check it has no effect on the user
+ user = self.add_user("testuser")
+ group = self.add_group("Group-1")
+ rogue_pso.apply_to(group)
+ self.set_attribute(group, "member", user.dn)
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ # apply the PSO directly to the user and check it has no effect
+ rogue_pso.apply_to(user.dn)
+ self.assert_PSO_applied(user, self.pwd_defaults)
+
+ # the PSOs created in these test-cases all use a default min-age of zero.
+ # This is the only test case that checks the PSO's min-age is enforced
+ def test_pso_min_age(self):
+ """Tests that a PSO's min-age is enforced"""
+ pso = PasswordSettings("min-age-PSO", self.ldb, password_len=10,
+ password_age_min=2, complexity=False)
+ self.add_obj_cleanup([pso.dn])
+
+ # create a user and apply the PSO
+ user = self.add_user("testuser")
+ pso.apply_to(user.dn)
+ self.assertTrue(user.get_resultant_PSO() == pso.dn)
+
+ # changing the password immediately should fail, even if the password
+ # is valid
+ valid_password = "min-age-passwd"
+ self.assert_password_invalid(user, valid_password)
+ # then trying the same password later should succeed
+ time.sleep(pso.password_age_min + 0.5)
+ self.assert_password_valid(user, valid_password)
+
+ def test_pso_max_age(self):
+ """Tests that a PSO's max-age is used"""
+
+ # create PSOs that use the domain's max-age +/- 1 day
+ domain_max_age = self.pwd_defaults.password_age_max
+ day_in_secs = 60 * 60 * 24
+ higher_max_age = domain_max_age + day_in_secs
+ lower_max_age = domain_max_age - day_in_secs
+ longer_pso = PasswordSettings("longer-age-PSO", self.ldb, precedence=5,
+ password_age_max=higher_max_age)
+ shorter_pso = PasswordSettings("shorter-age-PSO", self.ldb,
+ precedence=1,
+ password_age_max=lower_max_age)
+ self.add_obj_cleanup([longer_pso.dn, shorter_pso.dn])
+
+ user = self.add_user("testuser")
+
+ # we can't wait around long enough for the max-age to expire, so
+ # instead just check the msDS-UserPasswordExpiryTimeComputed for
+ # the user
+ attrs = ['msDS-UserPasswordExpiryTimeComputed']
+ res = self.ldb.search(user.dn, attrs=attrs)
+ domain_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+
+ # apply the longer PSO and check the expiry-time becomes longer
+ longer_pso.apply_to(user.dn)
+ self.assertTrue(user.get_resultant_PSO() == longer_pso.dn)
+ res = self.ldb.search(user.dn, attrs=attrs)
+ new_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+
+ # use timestamp diff of 1 day - 1 minute. The new expiry should still
+ # be greater than this, without getting into nano-second granularity
+ approx_timestamp_diff = (day_in_secs - 60) * (1e7)
+ self.assertTrue(new_expiry > domain_expiry + approx_timestamp_diff)
+
+ # apply the shorter PSO and check the expiry-time is shorter
+ shorter_pso.apply_to(user.dn)
+ self.assertTrue(user.get_resultant_PSO() == shorter_pso.dn)
+ res = self.ldb.search(user.dn, attrs=attrs)
+ new_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+ self.assertTrue(new_expiry < domain_expiry - approx_timestamp_diff)
+
+ def test_pso_special_groups(self):
+ """Checks applying a PSO to built-in AD groups takes effect"""
+
+ # create some PSOs that will apply to special groups
+ default_pso = PasswordSettings("default-PSO", self.ldb, precedence=20,
+ password_len=8, complexity=False)
+ guest_pso = PasswordSettings("guest-PSO", self.ldb, history_len=4,
+ precedence=5, password_len=5)
+ builtin_pso = PasswordSettings("builtin-PSO", self.ldb, history_len=9,
+ precedence=1, password_len=9)
+ admin_pso = PasswordSettings("admin-PSO", self.ldb, history_len=0,
+ precedence=2, password_len=10)
+ self.add_obj_cleanup([default_pso.dn, guest_pso.dn, admin_pso.dn,
+ builtin_pso.dn])
+ base_dn = self.ldb.domain_dn()
+ domain_users = "CN=Domain Users,CN=Users,%s" % base_dn
+ domain_guests = "CN=Domain Guests,CN=Users,%s" % base_dn
+ admin_users = "CN=Domain Admins,CN=Users,%s" % base_dn
+
+ # if we apply a PSO to Domain Users (which all users are a member of)
+ # then that PSO should take effect on a new user
+ default_pso.apply_to(domain_users)
+ user = self.add_user("testuser")
+ self.assert_PSO_applied(user, default_pso)
+
+ # Apply a PSO to a builtin group. 'Domain Users' should be a member of
+ # Builtin/Users, but builtin groups should be excluded from the PSO
+ # calculation, so this should have no effect
+ builtin_pso.apply_to("CN=Users,CN=Builtin,%s" % base_dn)
+ builtin_pso.apply_to("CN=Administrators,CN=Builtin,%s" % base_dn)
+ self.assert_PSO_applied(user, default_pso)
+
+ # change the user's primary group to another group (the primaryGroupID
+ # is a little odd in that there's no memberOf backlink for it)
+ self.set_attribute(domain_guests, "member", user.dn)
+ user.set_primary_group(domain_guests)
+ # No PSO is applied to the Domain Guests yet, so the default PSO should
+ # still apply
+ self.assert_PSO_applied(user, default_pso)
+
+ # now apply a PSO to the guests group, which should trump the default
+ # PSO (because the guest PSO has a better precedence)
+ guest_pso.apply_to(domain_guests)
+ self.assert_PSO_applied(user, guest_pso)
+
+ # create a new group that's a member of Admin Users
+ nested_group = self.add_group("nested-group")
+ self.set_attribute(admin_users, "member", nested_group)
+ # set the user's primary-group to be the new group
+ self.set_attribute(nested_group, "member", user.dn)
+ user.set_primary_group(nested_group)
+ # we've only changed group membership so far, not the PSO
+ self.assert_PSO_applied(user, guest_pso)
+
+ # now apply the best-precedence PSO to Admin Users and check it applies
+ # to the user (via the nested-group's membership)
+ admin_pso.apply_to(admin_users)
+ self.assert_PSO_applied(user, admin_pso)
+
+ # restore the default primaryGroupID so we can safely delete the group
+ user.set_primary_group(domain_users)
+
+ def test_pso_none_applied(self):
+ """Tests cases where no Resultant PSO should be returned"""
+
+ # create a PSO that we will check *doesn't* get returned
+ dummy_pso = PasswordSettings("dummy-PSO", self.ldb, password_len=20)
+ self.add_obj_cleanup([dummy_pso.dn])
+
+ # you can apply a PSO to other objects (like OUs), but the resultantPSO
+ # attribute should only be returned for users
+ dummy_pso.apply_to(str(self.ou))
+ res = self.ldb.search(self.ou, attrs=['msDS-ResultantPSO'])
+ self.assertFalse('msDS-ResultantPSO' in res[0])
+
+ # create a dummy user and apply the PSO
+ user = self.add_user("testuser")
+ dummy_pso.apply_to(user.dn)
+ self.assertTrue(user.get_resultant_PSO() == dummy_pso.dn)
+
+ try:
+ # now clear the ADS_UF_NORMAL_ACCOUNT flag for the user, which should
+ # mean a resultant PSO is no longer returned (we're essentially turning
+ # the user into a DC here, which is a little overkill but tests
+ # behaviour as per the Windows specification)
+ self.set_attribute(user.dn, "userAccountControl",
+ str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT),
+ operation=FLAG_MOD_REPLACE)
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.fail(f"Failed to change user into a workstation: {msg}")
+ self.assertIsNone(user.get_resultant_PSO())
+
+ try:
+ # reset it back to a normal user account
+ self.set_attribute(user.dn, "userAccountControl",
+ str(dsdb.UF_NORMAL_ACCOUNT),
+ operation=FLAG_MOD_REPLACE)
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.fail(f"Failed to change user back into a user: {msg}")
+ self.assertTrue(user.get_resultant_PSO() == dummy_pso.dn)
+
+ # no PSO should be returned if RID is equal to DOMAIN_USER_RID_KRBTGT
+ # (note this currently fails against Windows due to a Windows bug)
+ krbtgt_user = "CN=krbtgt,CN=Users,%s" % self.ldb.domain_dn()
+ dummy_pso.apply_to(krbtgt_user)
+ res = self.ldb.search(krbtgt_user, attrs=['msDS-ResultantPSO'])
+ self.assertFalse('msDS-ResultantPSO' in res[0])
+
+ def get_ldb_connection(self, username, password, ldaphost):
+ """Returns an LDB connection using the specified user's credentials"""
+ creds = self.get_credentials()
+ creds_tmp = Credentials()
+ creds_tmp.set_username(username)
+ creds_tmp.set_password(password)
+ creds_tmp.set_domain(creds.get_domain())
+ creds_tmp.set_realm(creds.get_realm())
+ creds_tmp.set_workstation(creds.get_workstation())
+ features = creds_tmp.get_gensec_features() | gensec.FEATURE_SEAL
+ creds_tmp.set_gensec_features(features)
+ return samba.tests.connect_samdb(ldaphost, credentials=creds_tmp)
+
+ def test_pso_permissions(self):
+ """Checks that regular users can't modify/view PSO objects"""
+
+ user = self.add_user("testuser")
+
+ # get an ldb connection with the new user's privileges
+ user_ldb = self.get_ldb_connection("testuser", user.get_password(),
+ self.host_url)
+
+ # regular users should not be able to create a PSO (at least, not in
+ # the default Password Settings container)
+ try:
+ priv_pso = PasswordSettings("priv-PSO", user_ldb, password_len=20)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+
+ # create a PSO as the admin user
+ priv_pso = PasswordSettings("priv-PSO", self.ldb, password_len=20)
+ self.add_obj_cleanup([priv_pso.dn])
+
+ # regular users should not be able to apply a PSO to a user
+ try:
+ self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+ samdb=user_ldb)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+ self.assertTrue('00002098' in msg, msg)
+
+ self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+ samdb=self.ldb)
+
+ # regular users should not be able to change a PSO's precedence
+ try:
+ priv_pso.set_precedence(100, samdb=user_ldb)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+ self.assertTrue('00002098' in msg, msg)
+
+ priv_pso.set_precedence(100, samdb=self.ldb)
+
+ # regular users should not be able to view a PSO's settings
+ pso_attrs = ["msDS-PSOAppliesTo", "msDS-PasswordSettingsPrecedence",
+ "msDS-PasswordHistoryLength", "msDS-LockoutThreshold",
+ "msDS-PasswordComplexityEnabled"]
+
+ # users can see the PSO object's DN, but not its attributes
+ res = user_ldb.search(priv_pso.dn, scope=ldb.SCOPE_BASE,
+ attrs=pso_attrs)
+ self.assertTrue(str(priv_pso.dn) == str(res[0].dn))
+ for attr in pso_attrs:
+ self.assertFalse(attr in res[0])
+
+ # whereas admin users can see everything
+ res = self.ldb.search(priv_pso.dn, scope=ldb.SCOPE_BASE,
+ attrs=pso_attrs)
+ for attr in pso_attrs:
+ self.assertTrue(attr in res[0])
+
+ # check replace/delete operations can't be performed by regular users
+ operations = [FLAG_MOD_REPLACE, FLAG_MOD_DELETE]
+
+ for oper in operations:
+ try:
+ self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+ samdb=user_ldb, operation=oper)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+ self.assertTrue('00002098' in msg, msg)
+
+ # ...but can be performed by the admin user
+ self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+ samdb=self.ldb, operation=oper)
+
+ def format_password_for_ldif(self, password):
+ """Encodes/decodes the password so that it's accepted in an LDIF"""
+ pwd = '"{0}"'.format(password)
+ return base64.b64encode(pwd.encode('utf-16-le')).decode('utf8')
+
+ # The 'user add' case is a bit more complicated as you can't really query
+ # the msDS-ResultantPSO attribute on a user that doesn't exist yet (it
+ # won't have any group membership or PSOs applied directly against it yet).
+ # In theory it's possible to still get an applicable PSO via the user's
+ # primaryGroupID (i.e. 'Domain Users' by default). However, testing against
+ # Windows shows that the PSO doesn't take effect during the user add
+ # operation. (However, the Windows GUI tools presumably adds the user in 2
+ # steps, because it does enforce the PSO for users added via the GUI).
+ def test_pso_add_user(self):
+ """Tests against a 'Domain Users' PSO taking effect on a new user"""
+
+ # create a PSO that will apply to users by default
+ default_pso = PasswordSettings("default-PSO", self.ldb, precedence=20,
+ password_len=12, complexity=False)
+ self.add_obj_cleanup([default_pso.dn])
+
+ # apply the PSO to Domain Users (which all users are a member of). In
+ # theory, this PSO *could* take effect on a new user (but it doesn't)
+ domain_users = "CN=Domain Users,CN=Users,%s" % self.ldb.domain_dn()
+ default_pso.apply_to(domain_users)
+
+ # first try to add a user with a password that doesn't meet the domain
+ # defaults, to prove that the DC will reject bad passwords during a
+ # user add
+ userdn = "CN=testuser,%s" % self.ou
+ password = self.format_password_for_ldif('abcdef')
+
+ # Note we use an LDIF operation to ensure that the password gets set
+ # as part of the 'add' operation (whereas self.add_user() adds the user
+ # first, then sets the password later in a 2nd step)
+ try:
+ ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser
+unicodePwd:: %s
+""" % (userdn, password)
+ self.ldb.add_ldif(ldif)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ # error codes differ between Samba and Windows
+ self.assertTrue(num == ldb.ERR_UNWILLING_TO_PERFORM or
+ num == ldb.ERR_CONSTRAINT_VIOLATION, msg)
+ self.assertTrue('0000052D' in msg, msg)
+
+ # now use a password that meets the domain defaults, but doesn't meet
+ # the PSO requirements. Note that Windows allows this, i.e. it doesn't
+ # honour the PSO during the add operation
+ password = self.format_password_for_ldif('abcde12#')
+ ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser
+unicodePwd:: %s
+""" % (userdn, password)
+ self.ldb.add_ldif(ldif)
+
+ # Now do essentially the same thing, but set the password in a 2nd step
+ # which proves that the same password doesn't meet the PSO requirements
+ userdn = "CN=testuser2,%s" % self.ou
+ ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser2
+""" % userdn
+ self.ldb.add_ldif(ldif)
+
+ # now that the user exists, assert that the PSO is honoured
+ try:
+ ldif = """
+dn: %s
+changetype: modify
+delete: unicodePwd
+add: unicodePwd
+unicodePwd:: %s
+""" % (userdn, password)
+ self.ldb.modify_ldif(ldif)
+ self.fail()
+ except ldb.LdbError as e:
+ (num, msg) = e.args
+ self.assertEqual(num, ldb.ERR_CONSTRAINT_VIOLATION, msg)
+ self.assertTrue('0000052D' in msg, msg)
+
+ # check setting a password that meets the PSO settings works
+ password = self.format_password_for_ldif('abcdefghijkl')
+ ldif = """
+dn: %s
+changetype: modify
+delete: unicodePwd
+add: unicodePwd
+unicodePwd:: %s
+""" % (userdn, password)
+ self.ldb.modify_ldif(ldif)
+
+ def set_domain_pwdHistoryLength(self, value):
+ m = ldb.Message()
+ m.dn = ldb.Dn(self.ldb, self.ldb.domain_dn())
+ m["pwdHistoryLength"] = ldb.MessageElement(value,
+ ldb.FLAG_MOD_REPLACE,
+ "pwdHistoryLength")
+ self.ldb.modify(m)
+
+ def test_domain_pwd_history(self):
+ """Non-PSO test for domain's pwdHistoryLength setting"""
+
+ # restore the current pwdHistoryLength setting after the test completes
+ curr_hist_len = str(self.pwd_defaults.history_len)
+ self.addCleanup(self.set_domain_pwdHistoryLength, curr_hist_len)
+
+ self.set_domain_pwdHistoryLength("4")
+ user = self.add_user("testuser")
+
+ initial_pwd = user.get_password()
+ passwords = ["First12#", "Second12#", "Third12#", "Fourth12#"]
+
+ # we should be able to set the password to new values OK
+ for pwd in passwords:
+ self.assert_password_valid(user, pwd)
+
+ # the 2nd time round it should fail because they're in the history now
+ for pwd in passwords:
+ self.assert_password_invalid(user, pwd)
+
+ # but the initial password is now outside the history, so should be OK
+ self.assert_password_valid(user, initial_pwd)
+
+ # if we set the history to zero, all the old passwords should now be OK
+ self.set_domain_pwdHistoryLength("0")
+ for pwd in passwords:
+ self.assert_password_valid(user, pwd)
+
+ def test_domain_pwd_history_zero(self):
+ """Non-PSO test for pwdHistoryLength going from zero to non-zero"""
+
+ # restore the current pwdHistoryLength setting after the test completes
+ curr_hist_len = str(self.pwd_defaults.history_len)
+ self.addCleanup(self.set_domain_pwdHistoryLength, curr_hist_len)
+
+ self.set_domain_pwdHistoryLength("0")
+ user = self.add_user("testuser")
+
+ self.assert_password_valid(user, "NewPwd12#")
+ # we can set the exact same password again because there's no history
+ self.assert_password_valid(user, "NewPwd12#")
+
+ # When going from zero to non-zero password-history, Windows treats
+ # the current user's password as invalid (even though the password has
+ # not been altered since the setting changed).
+ self.set_domain_pwdHistoryLength("1")
+ self.assert_password_invalid(user, "NewPwd12#")