Browse Source

Ticket 50136 - Allow resetting passwords on the CLI

Bug Description: This allows resetting passwords on the CLI for
accounts, as well as allowing accounts to self-change their
passwords.

Fix Description: Add reset and change password functions, and
fix a number of issues with non-DM bind in the server, regrissions
in tls enable during tests.

https://pagure.io/389-ds-base/issue/50136

Author: William Brown <[email protected]>

Review by: ???
William Brown 6 years ago
parent
commit
b5e4fca0b9

+ 6 - 1
src/lib389/lib389/__init__.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2015 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -933,6 +934,10 @@ class DirSrv(SimpleLDAPObject, object):
             backends = [userroot,]
 
         # Go!
+        self.log.debug("DEBUG: creating with parameters:")
+        self.log.debug(general)
+        self.log.debug(slapd)
+        self.log.debug(backends)
         sds.create_from_args(general, slapd, backends, None)
 
     def create(self, pyinstall=False, version=INSTALL_LATEST_CONFIG):
@@ -1153,7 +1158,7 @@ class DirSrv(SimpleLDAPObject, object):
                 self.log.debug("Cannot connect to %r", uri)
                 raise e
             except ldap.LDAPError as e:
-                self.log.debug("Error: Failed to authenticate: %s", e)
+                self.log.debug("Error: Failed to authenticate as %s: %s" % (self.binddn, e))
                 raise e
 
         """

+ 13 - 0
src/lib389/lib389/_mapped_object.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2016 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -312,6 +313,18 @@ class DSLdapObject(DSLogging):
         if not self.present(attr, value):
             self.add(attr, value)
 
+    def ensure_removed(self, attr, value):
+        """Ensure that a attribute and value has been removed and not present
+        or remove it.
+
+        :param key: an attribute name
+        :type key: str
+        :param value: an attribute value
+        :type value: str
+        """
+        if self.present(attr, value):
+            self.remove(attr, value)
+
     # maybe this could be renamed?
     def set(self, key, value, action=ldap.MOD_REPLACE):
         """Perform a specified action on a key with value

+ 12 - 0
src/lib389/lib389/cli_base/__init__.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2016 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -97,7 +98,10 @@ def connect_instance(dsrc_inst, verbose, args):
         else:
             # The instance name does not match any instances
             raise ValueError("Could not find configuration for instance: " + dsargs['ldapurl'])
+
     ds = DirSrv(verbose=verbose)
+    # We do an empty allocate here to determine if we can autobind ... (really
+    # we should actually be inspect the URL ...)
     ds.allocate(dsargs)
 
     if args.pwdfile is not None or args.bindpw is not None or args.prompt is True:
@@ -110,6 +114,7 @@ def connect_instance(dsrc_inst, verbose, args):
                 raise ValueError("Failed to open password file: " + str(e))
         elif args.bindpw is not None:
             # Password provided
+            # This shouldn't be needed? dsrc already inherits the args ...
             dsargs[SER_ROOT_PW] = args.bindpw
         else:
             # No password or we chose to prompt
@@ -118,6 +123,13 @@ def connect_instance(dsrc_inst, verbose, args):
         # No LDAPI, prompt for password
         dsargs[SER_ROOT_PW] = getpass("Enter password for {} on {}: ".format(dsrc_inst['binddn'], dsrc_inst['uri']))
 
+    if 'binddn' in dsrc_inst:
+        # Allocate is an awful interface that we should stop using, but for now
+        # just directly map the dsrc_inst args in (remember, dsrc_inst DOES
+        # overlay cli args into the map ...)
+        dsargs[SER_ROOT_DN] = dsrc_inst['binddn']
+
+    ds = DirSrv(verbose=verbose)
     ds.allocate(dsargs)
     ds.open(saslmethod=dsrc_inst['saslmech'],
             certdir=dsrc_inst['tls_cacertdir'],

+ 2 - 1
src/lib389/lib389/cli_conf/backend.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2018 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -594,7 +595,7 @@ def create_parser(subparsers):
     #####################################################
     # Suffix parser
     #####################################################
-    suffix_parser = subcommands.add_parser('suffix', help="Manage a backend suffix")
+    suffix_parser = subcommands.add_parser('suffix', help="Manage a backend suffix, including creating backends")
     suffix_subcommands = suffix_parser.add_subparsers(help="action")
 
     # List backends/suffixes

+ 34 - 1
src/lib389/lib389/cli_idm/account.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2017, Red Hat inc,
+# Copyright (C) 2018, William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -9,7 +10,7 @@
 import argparse
 
 from lib389.idm.account import Account, Accounts
-from lib389.cli_idm import (
+from lib389.cli_base import (
     _generic_list,
     _get_arg,
     )
@@ -41,6 +42,26 @@ def unlock(inst, basedn, log, args):
     acct.unlock()
     log.info('unlocked %s' % dn)
 
+def reset_password(inst, basedn, log, args):
+    dn = _get_arg(args.dn, msg="Enter dn to reset password")
+    new_password = _get_arg(args.new_password, hidden=True, confirm=True,
+        msg="Enter new password for %s" % dn)
+    accounts = Accounts(inst, basedn)
+    acct = accounts.get(dn=dn)
+    acct.reset_password(new_password)
+    log.info('reset password for %s' % dn)
+
+def change_password(inst, basedn, log, args):
+    dn = _get_arg(args.dn, msg="Enter dn to change password")
+    cur_password = _get_arg(args.current_password, hidden=True, confirm=False,
+        msg="Enter current password for %s" % dn)
+    new_password = _get_arg(args.new_password, hidden=True, confirm=True,
+        msg="Enter new password for %s" % dn)
+    accounts = Accounts(inst, basedn)
+    acct = accounts.get(dn=dn)
+    acct.change_password(cur_password, new_password)
+    log.info('changed password for %s' % dn)
+
 
 def create_parser(subparsers):
     account_parser = subparsers.add_parser('account', help='Manage generic accounts IE account locking and unlocking.')
@@ -62,3 +83,15 @@ def create_parser(subparsers):
     unlock_parser.set_defaults(func=unlock)
     unlock_parser.add_argument('dn', nargs='?', help='The dn to unlock')
 
+    reset_pw_parser = subcommands.add_parser('reset_password', help='Reset the password of an account. This should be performed by a directory admin.')
+    reset_pw_parser.set_defaults(func=reset_password)
+    reset_pw_parser.add_argument('dn', nargs='?', help='The dn to reset the password for')
+    reset_pw_parser.add_argument('new_password', nargs='?', help='The new password to set')
+
+    change_pw_parser = subcommands.add_parser('change_password', help='Change the password of an account. This can be performed by any user (with correct rights)')
+    change_pw_parser.set_defaults(func=change_password)
+    change_pw_parser.add_argument('dn', nargs='?', help='The dn to change the password for')
+    change_pw_parser.add_argument('new_password', nargs='?', help='The new password to set')
+    change_pw_parser.add_argument('current_password', nargs='?', help='The accounts current password')
+
+

+ 27 - 1
src/lib389/lib389/idm/account.py

@@ -37,7 +37,7 @@ class Account(DSLdapObject):
     def unlock(self):
         """Unset nsAccountLock"""
 
-        self.remove('nsAccountLock', None)
+        self.ensure_removed('nsAccountLock', None)
 
     # If the account can be bound to, this will attempt to do so. We don't check
     # for exceptions, just pass them back!
@@ -108,6 +108,32 @@ class Account(DSLdapObject):
             crt = f.read()
         self.add('usercertificate;binary', crt)
 
+    def reset_password(self, new_password):
+        """Set the password of the account: This requires write permission to
+        the userPassword attribute, so likely is only possible as an administrator
+        of the directory.
+
+        :param new_password: The new password value to set
+        :type new_password: str
+        """
+        self.set('userPassword', new_password)
+
+    def change_password(self, current_password, new_password):
+        """Using the accounts current bind password, performan an ldap passwd
+        change extended operation. This does not required elevated permissions
+        to read/write the userPassword field, so is the way that most accounts
+        would change their password. This doesn't work on all classes of objects
+        so it could error.
+
+        :param current_password: The existing password value
+        :type current_password: str
+        :param new_password: The new password value to set
+        :type new_password: str
+        """
+        # Please see _mapped_object.py and DSLdapObject for why this is structured
+        # in this way re-controls.
+        self._instance.passwd_s(self._dn, current_password, new_password,
+            serverctrls=self._server_controls, clientctrls=self._client_controls)
 
 class Accounts(DSLdapObjects):
     """DSLdapObjects that represents Account entry

+ 23 - 0
src/lib389/lib389/idm/user.py

@@ -104,6 +104,29 @@ class nsUserAccounts(DSLdapObjects):
         else:
             self._basedn = '{},{}'.format(rdn, basedn)
 
+    def create_test_user(self, uid=1000, gid=2000):
+        """Create a test user with uid=test_user_UID rdn
+
+        :param uid: User id
+        :type uid: int
+        :param gid: Group id
+        :type gid: int
+
+        :returns: DSLdapObject of the created entry
+        """
+
+        rdn_value = "test_user_{}".format(uid)
+        rdn = "uid={}".format(rdn_value)
+        properties = {
+            'uid': rdn_value,
+            'cn': rdn_value,
+            'displayName': rdn_value,
+            'uidNumber': str(uid),
+            'gidNumber': str(gid),
+            'homeDirectory': '/home/{}'.format(rdn_value),
+        }
+        return super(nsUserAccounts, self).create(rdn, properties)
+
 
 #### Traditional style userAccounts.
 

+ 6 - 1
src/lib389/lib389/instance/setup.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2016 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -828,8 +829,12 @@ class SetupDs(object):
         base_config_inst.apply_config(install=True)
 
         # Setup TLS with the instance.
+
+        # We *ALWAYS* set secure port, even if security is off, because it breaks
+        # tests with standalone.enable_tls if we do not. It's only when security; on
+        # that we actually start listening on it.
+        ds_instance.config.set('nsslapd-secureport', '%s' % slapd['secure_port'])
         if slapd['self_sign_cert']:
-            ds_instance.config.set('nsslapd-secureport', '%s' % slapd['secure_port'])
             ds_instance.config.set('nsslapd-security', 'on')
 
         # Create the backends as listed

+ 32 - 20
src/lib389/lib389/tests/idm/account_test.py

@@ -1,5 +1,6 @@
 # --- BEGIN COPYRIGHT BLOCK ---
 # Copyright (C) 2017 Red Hat, Inc.
+# Copyright (C) 2019 William Brown <[email protected]>
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -9,35 +10,17 @@
 
 
 import os
-import logging
 import pytest
 import ldap
 
-from lib389.idm.user import UserAccounts
+from lib389.idm.user import UserAccounts, nsUserAccounts
 from lib389.topologies import topology_st as topology
 from lib389._constants import DEFAULT_SUFFIX
 
-DEBUGGING = os.getenv('DEBUGGING', False)
-
-if DEBUGGING is not False:
-    DEBUGGING = True
-
-if DEBUGGING:
-    logging.getLogger(__name__).setLevel(logging.DEBUG)
-else:
-    logging.getLogger(__name__).setLevel(logging.INFO)
-
-log = logging.getLogger(__name__)
-
-
 def test_account_locking(topology):
     """
     Ensure that user and group management works as expected.
     """
-    if DEBUGGING:
-        # Add debugging steps(if any)...
-        pass
-
     users = UserAccounts(topology.standalone, DEFAULT_SUFFIX)
 
     user_properties = {
@@ -67,4 +50,33 @@ def test_account_locking(topology):
     conn = testuser.bind('password')
     conn.unbind_s()
 
-    log.info('Test PASSED')
+def test_account_reset_pw(topology):
+    users = nsUserAccounts(topology.standalone, DEFAULT_SUFFIX)
+    testuser = users.create_test_user(uid=1001)
+
+    # Make sure they are unlocked.
+    testuser.unlock()
+
+    testuser.reset_password("test_password")
+
+    # Assert we can bind as the new PW
+    c = testuser.bind('test_password')
+    c.unbind_s()
+
+
+def test_account_change_pw(topology):
+    # This test requires a secure connection
+    topology.standalone.enable_tls()
+
+    users = nsUserAccounts(topology.standalone, DEFAULT_SUFFIX)
+    testuser = users.create_test_user(uid=1002)
+
+    # Make sure they are unlocked.
+    testuser.unlock()
+
+    testuser.reset_password('password')
+    testuser.change_password('password', "test_password")
+
+    # Assert we can bind as the new PW
+    c = testuser.bind('test_password')
+    c.unbind_s()