<?php

/**
 * 
 * IML LDAP CONNECTOR
 *
 * @author axel.hahn@unibe.ch
 * @license GNU GPL v3
 *
 * SOURCE: <https://git-repo.iml.unibe.ch/iml-open-source/ldap-php-class/>
 * DOCS: <https://os-docs.iml.unibe.ch/ldap-php-class/index.html>
 * 
 * 2022-02-22  ah  added objGet(), sanitizeFilter()
 * 2022-08-18  ah  mask password (showing 4 chars only)
 * 2022-08-22  ah  mhash is deprecated
 * 2022-08-26  ah  fix verifyPassword
 * 2024-07-11  ah  php8 only: use variable types; update phpdocs
 * 2024-07-12  ah  remove connection port (use server value "ldaps://<host>:<port>" if needed) 
 */
class imlldap
{

    // ----------------------------------------------------------------------
    // vars
    // ----------------------------------------------------------------------

    /**
     * @var array  options array for an ldap connection including some base settings and DNs
     */
    private array $_aLdap = [
        'server' => false,
        'DnLdapUser' => false, // ldap rdn oder dn
        'PwLdapUser' => false,
        'DnUserNode' => false, // ou=People...
        'DnAppNode' => false, // cn=AppGroup...
        'protoVersion' => 3,
        'debugLevel' => 0,
    ];
    /**
     * @var object  current ldap connection  
     */
    private object|bool $_ldapConn = false;

    /**
     * ldap bind object - bind was done?
     * @var object|bool
     */
    private object|bool $_ldapBind = false;

    /**
     * Flag if debug mode is on
     * @var bool
     */
    var bool $bDebug = false;

    // ----------------------------------------------------------------------
    // functions
    // ----------------------------------------------------------------------

    /**
     * constructor
     * @param array  $aConfig  optional set ldap connection
     */
    public function __construct(array $aConfig = [])
    {
        if (!function_exists("ldap_connect")) {
            die(__CLASS__ . " ERROR: php-ldap module is not installed on this server.");
        }
        if (count($aConfig)) {
            $this->setConfig($aConfig);
        }
    }

    public function __destruct()
    {
        $this->close();
    }

    // ----------------------------------------------------------------------
    // write debug text
    // ----------------------------------------------------------------------

    /**
     * turn debug messages on;
     * if this detail level is not enough, set a value with key debugLevel in 
     * ldap config array
     * @see setConfig()
     */
    public function debugOn(): void
    {
        $this->bDebug = true;
        if ($this->_aLdap['debugLevel']) {
            $this->_w(__FUNCTION__ . ' setting debug level ' . $this->_aLdap['debugLevel']);
            ldap_set_option(NULL, LDAP_OPT_DEBUG_LEVEL, $this->_aLdap['debugLevel']);
        }
    }

    /**
     * turn debug messages off
     */
    public function debugOff(): void
    {
        $this->bDebug = false;
        ldap_set_option(NULL, LDAP_OPT_DEBUG_LEVEL, 0);
    }

    /**
     * write debug message if denugOn() was fired.
     * 
     * @param string  $sText  message text
     * @return boolean
     */
    private function _w(string $sText): bool
    {
        if (!$this->bDebug) {
            return false;
        }
        echo __CLASS__ . ' DEBUG: ' . $sText . "<br>\n";
        return true;
    }

    /**
     * write last ldap error as debug 
     * 
     * @param string  $sText  message text
     * @return boolean
     */
    private function _wLdaperror(string $sText = ''): bool
    {
        $this->_w(($sText ? $sText . ' - ' : '') . 'last LDAP-ERROR: ' . ldap_error($this->_ldapConn));
        return true;
    }

    // ----------------------------------------------------------------------
    // setup
    // ----------------------------------------------------------------------

    /**
     * set a ldap config or modify existing value
     * 
     * @param array  $aConfig   new config items with these keys
     *               'server'       => 'ldaps://ldap.example.com',
     *               'DnLdapUser'   => 'cn=Lookup,ou=ServiceAccounts,dc=org,dc=example.com', // ldap rdn oder dn
     *               'PwLdapUser'   => 'PasswordOfLookupUser',                               // password
     *               'DnUserNode'   => 'ou=People,ou=ORG,dc=org,dc=example.com',
     *               'protoVersion' => 3
     *               'debugLevel'   => 0 // value for LDAP_OPT_DEBUG_LEVEL in debugOn()
     */
    public function setConfig(array $aConfig = []): void
    {
        if (is_array($aConfig)) {
            foreach (array_keys($this->_aLdap) as $sKey) {
                if (array_key_exists($sKey, $aConfig)) {
                    $this->_w(__FUNCTION__ . ' setting ldap ' . $sKey . ' = ' . $aConfig[$sKey]);
                    $this->_aLdap[$sKey] = $aConfig[$sKey];
                }
            }
        }
    }

    // ----------------------------------------------------------------------
    // ldap lowlevel functions
    // ----------------------------------------------------------------------

    /**
     * close an existing ldap connection
     */
    public function close(): void
    {
        if ($this->_ldapConn) {
            $this->_w(__FUNCTION__ . ' closing connection.');
            ldap_close($this->_ldapConn);
        } else {
            $this->_w(__FUNCTION__ . ' SKIP close.');
        }

        $this->_ldapConn = false;
    }

    /**
     * connect to ldap
     */
    public function connect(): void
    {

        if (!array_key_exists('server', $this->_aLdap) || !$this->_aLdap['server']) {
            die(__CLASS__ . " ERROR: no ldap server was setup set. Use setConfig() first.");
        }

        if ($this->_ldapConn) {
            $this->close();
        }

        $this->_w(__FUNCTION__ . ' connect to ' . $this->_aLdap['server']);
        $this->_ldapConn = ldap_connect($this->_aLdap['server']);
        if (!$this->_ldapConn) {
            $this->_wLdaperror(__FUNCTION__);
            die(__CLASS__ . " ERROR: ldap connect failed.");
        }
        $this->_w(__FUNCTION__ . ' OK, connected.');
        ldap_set_option($this->_ldapConn, LDAP_OPT_NETWORK_TIMEOUT, 3);
        ldap_set_option($this->_ldapConn, LDAP_OPT_TIMELIMIT, 3);

        if ($this->_aLdap['protoVersion']) {
            $this->_w(__FUNCTION__ . ' setting protocol version .' . $this->_aLdap['protoVersion']);
            ldap_set_option($this->_ldapConn, LDAP_OPT_PROTOCOL_VERSION, $this->_aLdap['protoVersion']);
        }

        ldap_set_option($this->_ldapConn, LDAP_OPT_REFERRALS, 0); // for AD MS Windows 
    }

    /**
     * ldap bind connects with a ldap user. 
     * If the ldap connection was not opened yet the connection will be established.
     * If a binding exists it will be unbind
     * 
     * @see connect()
     * @see unbind()
     * 
     * @param string  $sUser   optional: username (overrides _aLdap['DnLdapUser'])
     * @param string  $sPw     optional: password (overrides _aLdap['PwLdapUser'])
     */
    public function bind(string $sUser = '', string $sPw = ''): bool
    {
        if (!$sUser) {
            $sUser = $this->_aLdap['DnLdapUser'];
            $sPw = $this->_aLdap['PwLdapUser'];
        }

        if (!$this->_ldapConn) {
            $this->connect();
        }
        if ($this->_ldapBind) {
            $this->unbind();
        }

        if (!$sUser) {
            $this->_w(__FUNCTION__ . ' ERROR: no user was set as first param.');
            die("ERROR: no user was given to connect to ldap.");
        }
        $this->_w(__FUNCTION__ . ' with user ' . $sUser . ' PW ' . substr($sPw, 0, 4) . '**********');

        $this->_ldapBind = @ldap_bind($this->_ldapConn, $sUser, $sPw);
        if (!$this->_ldapBind) {
            $this->_wLdaperror(__FUNCTION__);
            return false;
        }
        $this->_w(__FUNCTION__ . ' OK, successful.');
        return true;
    }

    /**
     * ldap unbind ... if a bind exists
     */
    public function unbind(): void
    {
        if ($this->_ldapBind && !is_bool($this->_ldapBind)) {
            $this->_w(__FUNCTION__ . ' ...');
            ldap_unbind($this->_ldapBind);
        } else {
            $this->_w(__FUNCTION__ . ' SKIP.');
        }
        $this->_ldapBind = false;
    }

    // ----------------------------------------------------------------------
    // ldap highlevel functions
    // ----------------------------------------------------------------------

    /**
     * check if a DN already exists; return is true/ false
     * @param string  $sDn  DN to check
     * @return boolean
     */
    public function DnExists(string $sDn): bool
    {
        $aData = $this->searchDn($sDn, '(&(objectclass=top))', ["*"]);
        return is_array($aData);
    }

    /**
     * get simpler array from ldap_get_entries after ldap_search
     * If the given array doesn't contain the key "dn" it returns "false"
     * 
     * @param array  $aRecord  single result item
     * @return array
     */
    public function normalizeSearchentry(array $aRecord): bool|array
    {
        if (!is_array($aRecord) || !isset($aRecord['dn'])) {
            return false;
        }
        $aItem = [];
        unset($aRecord['count']);
        foreach ($aRecord as $sAttr => $aData) {
            if (!is_integer($sAttr)) {
                $value = $aData;
                if (is_array($aData)) {
                    unset($aData['count']);
                    $bUseArray = count($aData) > 1 || array_search($sAttr, ['hieradata', 'member', 'memberof', 'objectclass']) !== false;
                    if ($bUseArray) {
                        sort($aData);
                    }
                    $value = $bUseArray ? $aData : $aData[0];
                }
                $aItem[$sAttr] = $value;
            }
        }
        return $aItem;
    }

    /**
     * sanitize value to put into a search filter
     * WARNING: the implementation is incomplete! I replaces the first N ascii chars only
     * 
     * source: https://www.rfc-editor.org/rfc/rfc4515.txt
     * 
     * @example:
     * $sCn = 'John Smith (john)';
     * $sSearchFilter = '(cn='.$oLdap->sanitizeFilter($sCn).')';
     * 
     * @param  string   $s  value to sanitize
     * @return string
     */
    static public function sanitizeFilter(string $s): string
    {

        // helper array to replace special chars
        $aReplace = [];
        for ($i = 0; $i < 65; $i++) {
            $val = dechex($i);
            if ($val < 10) {
                $val = "0$val";
            }
            $aReplace[chr($i)] = '\\' . $val;
        }

        $sReturn = $s;
        $sReturn = str_replace(array_keys($aReplace), array_values($aReplace), $sReturn);

        return $sReturn;
    }
    /**
     * search in ldap directory and get result as array.
     * It returns "false" on error:
     * - no ldap connection
     * - search failed
     * 
     * @param string  $sDn               DN to search for
     * @param string  $sSearchFilter     filter in ldap filter syntax
     * @param array   $aAttributesToGet  flat array of attributes to fetch
     * @param boolean $bRecursive        recusrive (uses ldap_search) or not (ldap_list)
     * @return boolean|array
     */
    public function searchDn(string $sDn, string $sSearchFilter = '(objectclass=*)', array $aAttributesToGet = ["*"], bool $bRecursive = true): bool|array
    {
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        $this->_w(__FUNCTION__ . ' DN = ' . $sDn . ' filter = ' . $sSearchFilter . ' attributes = ' . print_r($aAttributesToGet, 1) . ' recursive = ' . ($bRecursive ? 'yes' : 'no'));

        $oLdapSearch = $bRecursive
            ? ldap_search($this->_ldapConn, $sDn, $sSearchFilter, $aAttributesToGet)
            : ldap_list($this->_ldapConn, $sDn, $sSearchFilter, $aAttributesToGet)
        ;

        if (!$oLdapSearch) {
            $this->_w(__FUNCTION__ . " !!!ERROR!!! filter $sSearchFilter failed ");
            return false;
        }
        $aItems = ldap_get_entries($this->_ldapConn, $oLdapSearch);
        $this->_w(__FUNCTION__ . " count of returned items: " . count($aItems));
        // $this->_w(__FUNCTION__ . " <pre>".print_r($aItems,1).'</pre>');
        return $aItems;
    }

    /**
     * search for entries in in ldap user node and get result as array
     * 
     * @param string  $sSearchFilter     filter in ldap filter syntax
     * @param array   $aAttributesToGet  flat array of attributes to fetch
     * @param bool    $bRecursive        flag: recursive search? default: true (=yes, recursive)
     * 
     * @return boolean|array
     */
    public function searchUser(string $sSearchFilter = '', array $aAttributesToGet = ["*"], bool $bRecursive = true): bool|array
    {
        return $this->searchDn($this->_aLdap['DnUserNode'], $sSearchFilter, $aAttributesToGet, $bRecursive);
        /*
        if (!$this->_ldapBind) {
            $this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser']);
        }

        $this->_w(__FUNCTION__ . ' DN = ' . $this->_aLdap['DnUserNode'] . ' filter = ' . $sSearchFilter);

        $oLdapSearch = ldap_search(
                $this->_ldapConn, $this->_aLdap['DnUserNode'], $sSearchFilter, $aAttributesToGet
        );

        $aItems = $oLdapSearch ? ldap_get_entries($this->_ldapConn, $oLdapSearch) : false;
        return $aItems;
         */
    }

    /**
     * search user by a given username or email address. 
     * It returns false if the user does not exist or is
     * not member of the group 'DnAppNode' (if it was set).
     * 
     * @param string  $sUser             user id (uid) or email (mail) to search
     * @param array   $aAttributesToGet  i.e. ["ou", "sn", "vorname", "mail", "uid", "memberOf"]
     * @return boolean|array
     */
    public function getUserInfo(string $sUser, array $aAttributesToGet = ["*"]): bool|array
    {
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }

        // generate search filter
        $sSearchFilter = (strpos($sUser, '@')) ? "(mail=$sUser)" : "(uid=$sUser)";
        if ($this->_aLdap['DnAppNode']) {
            $sSearchFilter .= '(memberof=' . $this->_aLdap['DnAppNode'] . ')';
        }
        // $sSearchFilter .= '(memberof=*)';
        $sSearchFilter = '(&' . $sSearchFilter . ')';
        $aItems = $this->searchUser($sSearchFilter, $aAttributesToGet);

        if (is_array($aItems) && count($aItems) == 2) {
            $this->_w(__FUNCTION__ . ' OK: I got a single result: ' . print_r($aItems[0], 1));
            return $aItems[0];
        } else {
            $this->_w(__FUNCTION__ . ' ERROR: result is: <pre>' . print_r($aItems, 1) . '</pre>');
        }
        return false;
    }

    /**
     * search for a DN entry with the lookup user by a given username or
     * email address. It returns false if the user does not exist or is
     * not member of the group 'DnAppNode' (if it was set).
     * 
     * @param string $sUser
     * @return string
     */
    public function getUserDn(string $sUser): bool|string
    {
        $this->_w(__FUNCTION__ . '(' . $sUser . ')');
        $aItem = $this->getUserInfo($sUser, ["dn"]);
        if (is_array($aItem) && array_key_exists('dn', $aItem)) {
            $this->_w(__FUNCTION__ . ' OK: dn was found ' . $aItem['dn']);
            return $aItem['dn'];
        }
        $this->_w(__FUNCTION__ . ' ERROR: dn was NOT found ' . print_r($aItem));
        return false;
    }

    /**
     * set a password for a given user; 
     * this requires a ldap bind with master/ admin account
     * 
     * @param string  $sUser  username or email
     * @param string  $sPW    password
     * @return boolean
     */
    public function setPassword(string $sUser, string $sPW): bool
    {
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        $sDn = $this->getUserDn($sUser);
        if ($sDn) {
            if (!ldap_mod_replace($this->_ldapConn, $sDn, ['userpassword' => "{MD5}" . base64_encode(pack("H*", md5($sPW)))])) {
                $this->_wLdaperror(__FUNCTION__);
                return false;
            } else {
                return true;
            }
        }
        $this->_w(__FUNCTION__ . ' dn not found (user does not exist in ldap) ' . $sUser);
        return false;
    }

    /**
     * get NTLM hash from a string
     * taken from https://secure.php.net/manual/en/ref.hash.php
     * 
     * @param string   $Input
     * @return string
     */
    private function _getNTLMHash(string $Input): string
    {
        // Convert the password from UTF8 to UTF16 (little endian)
        $Input = iconv('UTF-8', 'UTF-16LE', $Input);

        // Encrypt it with the MD4 hash
        $MD4Hash = hash('md4', $Input);
        // Make it uppercase, not necessary, but it's common to do so with NTLM hashes
        $NTLMHash = strtoupper($MD4Hash);

        // Return the result
        return ($NTLMHash);
    }

    /**
     * set a password for a given user for Samba
     * this requires a ldap bind with master/ admin account
     * see https://msdn.microsoft.com/en-us/library/cc223248.aspx
     * see http://php.net/ldap-modify-batch  - last examle
     * see https://secure.php.net/manual/en/ref.hash.php
     * 
     * @param string  $sUser  username or email
     * @param string  $sPW    password
     * @return boolean
     */
    public function setPasswordSamba(string $sUser, string $sPW): bool
    {
        $sDn = $this->getUserDn($sUser);
        if ($sDn) {
            $sPwField = 'sambaNTPassword';
            $sPwValue = $this->_getNTLMHash($sPW);
            return $this->objUpdate(
                $sDn,
                [
                    $sPwField => $sPwValue,
                    'SambaPwdLastSet' => date('U'),
                ]
            );
        }
        $this->_w(__FUNCTION__ . ' dn not found (user does not exist in ldap) ' . $sUser);
        return false;
    }

    /**
     * update an ldap object
     * this requires a ldap bind with master/ admin account
     * It returns true if the action was successful
     * 
     * @param string  $sDn     dn to update
     * @param array   $aItem   array of new ldap properties
     * @return boolean
     */
    public function objAdd(string $sDn, array $aItem): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", <pre>[' . print_r($aItem, 1) . ']</pre>)');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        if (!ldap_add($this->_ldapConn, $sDn, $aItem)) {
            $this->_wLdaperror(__FUNCTION__);
            return false;
        }
        return true;
    }

    /**
     * update an ldap attribute
     * this requires a ldap bind with master/ admin account
     * 
     * @param string  $sDn     dn to update
     * @param array   $aItem   array of new ldap properties
     * @return boolean
     */
    public function objAddAttr(string $sDn, array $aItem): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", [array])');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        if ($sDn && is_array($aItem)) {
            $this->_w(__FUNCTION__ . ' ' . ($this->_ldapConn ? 'Verbindung da' : 'kein LDAP Connect'));
            $this->_w(__FUNCTION__ . ' ldap_mod_add($this->_ldapConn, "' . $sDn . '", ' . print_r($aItem, 1) . ')');
            if (!ldap_mod_add($this->_ldapConn, $sDn, $aItem)) {
                $this->_w(__FUNCTION__ . ' ldap_mod_add FAILED');
                $this->_wLdaperror(__FUNCTION__);
                return false;
            }
            return true;
        }
        $this->_w(__FUNCTION__ . ' dn not found (item does not exist in ldap) or item was not ann array ' . print_r($aItem, 1));
        return false;
    }

    /**
     * read attributes from ldap node with given DN (using ldap_read)
     * It returns "false" if the action was not successful
     * - no ldap connection
     * - DN or filter didn't match
     * 
     * @param string  $sDn               DN to search for
     * @param string  $sSearchFilter     filter in ldap filter syntax
     * @param array   $aAttributesToGet  flat array of attributes to fetch
     * @return boolean|array
     */
    public function objGet(string $sDn, string $sSearchFilter = '(objectclass=*)', array $aAttributesToGet = ["*"]): bool|array
    {

        $this->_w(__FUNCTION__ . '("' . $sDn . '", filter = ' . $sSearchFilter . ', atttr= ' . print_r($aAttributesToGet, 1) . ' )');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }

        $oLdapResult = ldap_read($this->_ldapConn, $sDn, $sSearchFilter, $aAttributesToGet);

        if (!$oLdapResult) {
            $this->_w(__FUNCTION__ . " !!!ERROR!!! DN or filter did not match.");
            return false;
        }
        return ldap_get_entries($this->_ldapConn, $oLdapResult);
    }

    /**
     * update an ldap object with given key-value array
     * if the attribute (key) does not exist it will be created.
     * this requires a ldap bind with master/ admin account
     * It returns "false" if the action failed
     * 
     * @param string  $sDn    full DN where to update the item
     * @param array   $aItem  updated entry
     * @return boolean
     */
    public function objUpdate(string $sDn, array $aItem): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", ' . print_r($aItem, 1) . ')');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        if ($sDn && is_array($aItem)) {
            if (!ldap_mod_replace($this->_ldapConn, $sDn, $aItem)) {
                $this->_w(__FUNCTION__ . ' ldap_mod_replace FAILED');
                $this->_wLdaperror(__FUNCTION__);
                return false;
            }
            return true;
        }
        $this->_w(__FUNCTION__ . ' dn not found (item does not exist in ldap) ' . print_r($aItem, 1));
        return false;
    }

    /**
     * delete an ldap object
     * this requires a ldap bind with master/ admin account
     * It returns "false" if the action failed
     * 
     * @param string  $sDn    full DN to remove 
     * @return boolean
     */
    public function objDelete(string $sDn): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '")');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }

        if ($sDn) {
            if (!ldap_delete($this->_ldapConn, $sDn)) {
                $this->_wLdaperror(__FUNCTION__);
                return false;
            }
            return true;
        }
        $this->_w(__FUNCTION__ . ' missing parameter for DN');
        return false;
    }

    /**
     * delete attributes of an ldap object
     * this requires a ldap bind with master/ admin account
     * It returns "false" if the action failed
     * 
     * @example:
     * remove attribute "userPassword" of user $sUserDn:
     * <code>$oLdap->objDeleteAttr($sUserDn, ['userPassword'=>[]]</code>
     * 
     * @param string  $sDn    DN
     * @param array   $aItem  item to remove
     * @return boolean
     */
    public function objDeleteAttr(string $sDn, array $aItem): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", [array])');
        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        if ($sDn && is_array($aItem)) {
            $this->_w(__FUNCTION__ . ' ' . ($this->_ldapConn ? 'Verbindung da' : 'kein LDAP Connect'));
            $this->_w(__FUNCTION__ . ' ldap_mod_del($this->_ldapConn, "' . $sDn . '", ' . print_r($aItem, 1) . ')');
            if (!ldap_mod_del($this->_ldapConn, $sDn, $aItem)) {
                $this->_wLdaperror(__FUNCTION__);
                return false;
            }
            return true;
        }
        $this->_w(__FUNCTION__ . ' dn not found (item does not exist in ldap) or item was not an array ' . print_r($aItem, 1));
        return false;
    }

    /**
     * check if an attribute exists in a DN
     * 
     * @param string  $sDn         DN
     * @param string  $sAttribute  attribute name to check
     * @param string  $sAttrValue  value to check
     * @return boolean
     */
    public function objectAttributeExists(string $sDn, string $sAttribute): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", "' . $sAttribute . '")');

        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        $aData = $this->searchDn($sDn, '(&(objectclass=top))', [$sAttribute]);
        $return = (is_array($aData) && isset($aData[0][strtolower($sAttribute)]));
        $this->_w(__FUNCTION__ . '(...) returns ' . ($return ? 'true' : 'false'));
        return $return;
    }

    /**
     * check if an attribute and value exist in a DN
     * 
     * @param string  $sDn         DN
     * @param string  $sAttribute  attribute name to check
     * @param string  $sAttrValue  value to check
     * @return boolean
     */
    public function objectAttributeAndValueExist(string $sDn, string $sAttribute, string $sAttrValue): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", "' . $sAttribute . '", "' . $sAttrValue . '")');

        if (!$this->_ldapBind) {
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])) {
                return false;
            }
        }
        $aData = $this->searchDn($sDn, '(&(objectclass=top))', [$sAttribute]);
        $return = (is_array($aData) && isset($aData[0][strtolower($sAttribute)]) && array_search($sAttrValue, $aData[0][strtolower($sAttribute)]) !== false);
        $this->_w(__FUNCTION__ . '(...) returns ' . ($return ? 'true' : 'false'));
        return $return;
    }

    /**
     * check an attribute and value; it will be created if it does not exist
     * this requires a ldap bind with master/ admin account
     * 
     * @param string  $sDn         dn to update
     * @param string  $sAttribute   attribute name to check
     * @param string  $sAttrValue  value to check
     * @return boolean
     */
    public function objectAttributeAndValueMustExist(string $sDn, string $sAttribute, string $sAttrValue): bool
    {
        $this->_w(__FUNCTION__ . '("' . $sDn . '", "' . $sAttribute . '", "' . $sAttrValue . '")');
        // return if it already exists
        if ($this->objectAttributeAndValueExist($sDn, $sAttribute, $sAttrValue)) {
            return true;
        }

        // create it
        $this->_w(__FUNCTION__ . " create $sAttribute = $sAttrValue");
        $return = $this->objAddAttr($sDn, [$sAttribute => $sAttrValue]);
        return $return;
    }

    /**
     * create a new user item
     * this requires a ldap bind with master/ admin account
     * 
     * @param array   $aItem  ldap properties
     * @param string  $sDn    optional DN where to create the user
     * @return boolean
     */
    public function userAdd(array $aItem, string $sDn = ""): bool
    {
        if (!$sDn) {
            $sDn = 'cn=' . $aItem['cn'] . ',' . $this->_aLdap['DnUserNode'];
        }
        $this->_w(__FUNCTION__ . '([array], "' . $sDn . '")');
        if ($sDn) {
            return $this->objAdd($sDn, $aItem);
        }
        $this->_w(__FUNCTION__ . ' node dn where to put the user was not found; set a value DnUserNode in ldap config or set it as 2nd parameter ' . print_r($aItem, 1));
        return false;
    }

    /**
     * delete a user
     * this requires a ldap bind with master/ admin account
     * 
     * @param string  $sUser  user to update
     * @param string  $sPW    new password to set
     * @return boolean
     */
    public function userDelete(string $sUserDn): bool
    {
        $this->_w(__FUNCTION__ . '(' . $sUserDn . ')');
        return $this->objDelete($sUserDn);
    }

    /**
     * update an ldap object
     * this requires a ldap bind with master/ admin account
     * 
     * @param array   $aItem  new user data to update
     * @return boolean
     */
    public function userUpdate(array $aItem): bool
    {
        $this->_w(__FUNCTION__ . '([array])');
        $sDn = $this->getUserDn($aItem['uid']);
        if ($sDn) {
            if (array_key_exists('cn', $aItem)) {
                $this->_w(__FUNCTION__ . ' deleting cn entry.');
                unset($aItem['cn']);
            }
            return $this->objUpdate($sDn, $aItem);
        }
        $this->_w(__FUNCTION__ . ' dn not found (user does not exist in ldap) ' . $sDn);
        return false;
    }

    /**
     * verify user and password
     * @param string  $sUser  username or email
     * @param string  $sPW    password
     * @return boolean
     */
    public function verifyPassword(string $sUser, string $sPW): bool
    {
        $sDn = $this->getUserDn($sUser);
        if ($sDn) {
            return $this->bind($sDn, $sPW);
            /*
            if (!$this->bind($this->_aLdap['DnLdapUser'], $this->_aLdap['PwLdapUser'])){
                return false;
            }
            */
        }
        $this->_w(__FUNCTION__ . ' dn not found (user does not exist in ldap) ' . $sUser);
        return false;
    }

}