<?php

/**
 * 
 * IML LDAP CONNECTOR
 *
 * 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
 * 
 * @author axel.hahn@iml.unibe.ch
 */
class imlldap {

    // ----------------------------------------------------------------------
    // vars
    // ----------------------------------------------------------------------
    
    /**
     * @var array  options array for an ldap connection including some base settings and DNs
     */
    private $_aLdap = array(
        'server' => false,
        'port' => 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 $_ldapConn = false;
    /**
     * @var bool  bind was done?
     */
    private $_ldapBind = false;
    var $bDebug = false;

    // ----------------------------------------------------------------------
    // functions
    // ----------------------------------------------------------------------
    
    /**
     * constructor
     * @param array  $aConfig  optional set ldap connection
     */
    public function __construct($aConfig = array()) {
        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() {
        $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() {
        $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($sText) {
        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($sText = '') {
        $this->_w(($sText ? $sText . ' - ' : '' ) . 'last LDAP-ERROR: ' . ldap_error($this->_ldapConn));
        return true;
    }

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

    /**
     * set a ldap config 
     * 
     * @param array  $aConfig   new config items
     *             'server'       => 'ldaps://ldap.example.com',
     *             'port'         => 636,
     *             'DnLdapUser' => 'cn=Lookup,ou=ServiceAccounts,dc=org,dc=example.com',     // ldap rdn oder dn
     *             'PwLdapUser' => 'IkHEFFzlZ...99j0h8WdI0LrLhxU',  // password
     *             'DnUserNode'   => 'ou=People,ou=ORG,dc=org,dc=example.com',
     *             'DnAppNode'    => '' optional dn ... if a user must be member of a given group
     *             'protoVersion' => 3
     *             'debugLevel'   => 0 // for debugging set higher 0 AND call debugOn()
     */
    public function setConfig($aConfig = array()) {
        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() {
        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() {

        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->_aLdap['port']);
        $this->_ldapConn = ldap_connect($this->_aLdap['server'], $this->_aLdap['port']);
        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($sUser = '', $sPw = '') {
        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() {
        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($sDn) {
        $aData = $this->searchDn($sDn, '(&(objectclass=top))', array("*"));
        return is_array($aData);
    }

    /**
     * get simpler array from ldap_get_entries after ldap_search
     * 
     * @param array  $aRecord  singel result item
     * @return array
     */
    public function normalizeSearchentry($aRecord) {
        if (!is_array($aRecord) || !isset($aRecord['dn'])){
            return false;
        }
        $aItem = array();
        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, array('hieradata', 'member', 'memberof', 'objectclass'))!==false;
                    if($bUseArray){
                        sort($aData);
                    }
                    $value = $bUseArray ? $aData : $aData[0];
                }
                $aItem[$sAttr] = $value;
            }
        }
        return $aItem;
    }
    /**
     * get simpler array from ldap_get_entries after ldap_search
     * 
     * @param array  $aRecord  singel result item
     * @return array

    public function normalizeSearchresult($aLdapSearchresult) {
        if (!is_array($aLdapSearchresult)){
            return false;
        }
        $aReturn = array();
        unset($aRecord['count']);
        foreach ($aLdapSearchresult as $aRecord) {
            $aReturn[]=$this->normalizeSearchentry($aRecord);
        }
        return $aReturn;
    }
     */

    /**
     * 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($s){

        // helper array to replace special chars
        $aReplace=array();
        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
     * 
     * @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 array
     */
    public function searchDn($sDn, $sSearchFilter='(objectclass=*)', $aAttributesToGet = array("*"), $bRecursive=true) {
        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 array
     */
    public function searchUser($sSearchFilter='', $aAttributesToGet = array("*"), $bRecursive=true) {
        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 type $sUser             user id (uid) or email (mail) to search
     * @param type $aAttributesToGet  i.e. array("ou", "sn", "vorname", "mail", "uid", "memberOf")
     * @return boolean|array
     */
    public function getUserInfo($sUser, $aAttributesToGet = 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 type $sUser
     * @return string
     */
    public function getUserDn($sUser) {
        $this->_w(__FUNCTION__ . '(' . $sUser . ')');
        $aItem = $this->getUserInfo($sUser, array("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($sUser, $sPW) {
        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, array('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($Input) {
        // 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($sUser, $sPW) {
        $sDn = $this->getUserDn($sUser);
        if ($sDn) {
            $sPwField = 'sambaNTPassword';
            $sPwValue = $this->_getNTLMHash($sPW);
            return $this->objUpdate($sDn, array(
                        $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
     * 
     * @param string  $sDn     dn to update
     * @param string  $aItem   array of new ldap properties
     * @return boolean
     */
    public function objAdd($sDn, $aItem) {
        $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 string  $aItem   array of new ldap properties
     * @return boolean
     */
    public function objAddAttr($sDn, $aItem) {
        $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)
     * 
     * @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 array
     */
    public function objGet($sDn, $sSearchFilter='(objectclass=*)', $aAttributesToGet = 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
     * 
     * @param string  $sDn    full DN where to update the item
     * @param array   $aItem  updated entry
     * @return boolean
     */
    public function objUpdate($sDn, $aItem) {
        $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
     * 
     * @param string  $sDn    full DN to remove 
     * @return boolean
     */
    public function objDelete($sDn) {
        $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
     * 
     * TODO: Test me
     * 
     * @param string  $sDn    DN
     * @param string  $aItem  item to remove
     * @return boolean
     */
    public function objDeleteAttr($sDn, $aItem) {
        $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($sDn, $sAttribute) {
        $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))', array($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($sDn, $sAttribute, $sAttrValue) {
        $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))', array($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($sDn, $sAttribute, $sAttrValue) {
        $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, array($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($aItem, $sDn = false) {
        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($sUserDn) {
        $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($aItem) {
        $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($sUser, $sPW) {
        $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;
    }

}