Files
server/usr/share/psa-pear/pear/php/Horde/Ldap.php
2026-01-07 20:52:11 +01:00

1635 lines
60 KiB
PHP

<?php
/**
* The main Horde_Ldap class.
*
* Copyright 2003-2007 Tarjej Huse, Jan Wagner, Del Elson, Benedikt Hallinger
* Copyright 2009-2017 Horde LLC (http://www.horde.org/)
*
* @package Ldap
* @author Tarjej Huse <tarjei@bergfald.no>
* @author Jan Wagner <wagner@netsols.de>
* @author Del <del@babel.com.au>
* @author Benedikt Hallinger <beni@php.net>
* @author Ben Klang <ben@alkaloid.net>
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Jan Schneider <jan@horde.org>
* @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3
*/
class Horde_Ldap
{
/**
* Class configuration array.
*
* - hostspec: The LDAP host to connect to (may be an array of
* several hosts to try).
* - port: The server port.
* - version: LDAP version (defaults to 3).
* - tls: When set, ldap_start_tls() is run after connecting.
* - binddn: The DN to bind as when searching.
* - bindpw: Password to use when searching LDAP.
* - basedn: LDAP base.
* - options: Hash of LDAP options to set.
* - filter: Default search filter.
* - scope: Default search scope.
* - user: Configuration parameters for {@link findUserDN()},
* must contain 'uid', and may contain 'basedn'
* entries.
* - timeout: Connection timeout in seconds (defaults to 5).
* - auto_reconnect: If true, the class will automatically
* attempt to reconnect to the LDAP server in certain
* failure conditions when attempting a search, or other
* LDAP operations. Defaults to false. Note that if you
* set this to true, calls to search() may block
* indefinitely if there is a catastrophic server failure.
* - min_backoff: Minimum reconnection delay period (in seconds).
* - current_backof: Initial reconnection delay period (in seconds).
* - max_backoff: Maximum reconnection delay period (in seconds).
* - cache: A Horde_Cache instance for caching schema requests.
*
* @var array
*/
protected $_config = array(
'hostspec' => 'localhost',
'port' => 389,
'version' => 3,
'tls' => false,
'binddn' => '',
'bindpw' => '',
'basedn' => '',
'options' => array(),
'filter' => '(objectClass=*)',
'scope' => 'sub',
'user' => array(),
'timeout' => 5,
'auto_reconnect' => false,
'min_backoff' => 1,
'current_backoff' => 1,
'max_backoff' => 32,
'cache' => false,
'cache_root_dse' => false,
'cachettl' => 3600);
/**
* List of hosts we try to establish a connection to.
*
* @var array
*/
protected $_hostList = array();
/**
* List of hosts that are known to be down.
*
* @var array
*/
protected $_downHostList = array();
/**
* LDAP resource link.
*
* @var resource
*/
protected $_link;
/**
* Schema object.
*
* @see schema()
* @var Horde_Ldap_Schema
*/
protected $_schema;
/**
* Cache for attribute encoding checks.
*
* @var array Hash with attribute names as key and boolean value
* to determine whether they should be utf8 encoded or not.
*/
protected $_schemaAttrs = array();
/**
* Cache for rootDSE objects.
*
* Hash with requested rootDSE attr names as key and rootDSE
* object as value.
*
* Since the RootDSE object itself may request a rootDSE object,
* {@link rootDSE()} caches successful requests.
* Internally, Horde_Ldap needs several lookups to this object, so
* caching increases performance significally.
*
* @var array
*/
protected $_rootDSE = array();
/**
* Constructor.
*
* @see $_config
*
* @param array $config Configuration array.
*/
public function __construct($config = array())
{
if (!Horde_Util::loadExtension('ldap')) {
throw new Horde_Ldap_Exception('No PHP LDAP extension');
}
$this->setConfig($config);
$this->bind();
}
/**
* Destructor.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Sets the internal configuration array.
*
* @param array $config Configuration hash.
*/
protected function setConfig($config)
{
/* Parameter check -- probably should raise an error here if
* config is not an array. */
if (!is_array($config)) {
return;
}
foreach ($config as $k => $v) {
if (isset($this->_config[$k])) {
$this->_config[$k] = $v;
}
}
/* Ensure the host list is an array. */
if (is_array($this->_config['hostspec'])) {
$this->_hostList = $this->_config['hostspec'];
} else {
if (strlen($this->_config['hostspec'])) {
$this->_hostList = array($this->_config['hostspec']);
} else {
$this->_hostList = array();
/* This will cause an error in _connect(), so
* the user is notified about the failure. */
}
}
/* Reset the down host list, which seems like a sensible thing
* to do if the config is being reset for some reason. */
$this->_downHostList = array();
}
/**
* Bind or rebind to the LDAP server.
*
* This function binds with the given DN and password to the
* server. In case no connection has been made yet, it will be
* started and STARTTLS issued if appropiate.
*
* The internal bind configuration is not being updated, so if you
* call bind() without parameters, you can rebind with the
* credentials provided at first connecting to the server.
*
* @param string $dn DN for binding.
* @param string $password Password for binding.
*
* @throws Horde_Ldap_Exception
*/
public function bind($dn = null, $password = null)
{
/* Fetch current bind credentials. */
if (is_null($dn)) {
$dn = $this->_config['binddn'];
}
if (is_null($password)) {
$password = $this->_config['bindpw'];
}
/* Connect first, if we haven't so far. This will also bind
* us to the server. */
if (!$this->_link) {
/* Store old credentials so we can revert them later, then
* overwrite config with new bind credentials. */
$olddn = $this->_config['binddn'];
$oldpw = $this->_config['bindpw'];
/* Overwrite bind credentials in config so
* _connect() knows about them. */
$this->_config['binddn'] = $dn;
$this->_config['bindpw'] = $password;
/* Try to connect with provided credentials. */
$msg = $this->_connect();
/* Reset to previous config. */
$this->_config['binddn'] = $olddn;
$this->_config['bindpw'] = $oldpw;
return;
}
/* Do the requested bind as we are asked to bind manually. */
if (empty($dn)) {
/* Anonymous bind. */
$msg = @ldap_bind($this->_link);
} else {
/* Privileged bind. */
$msg = @ldap_bind($this->_link, $dn, $password);
}
if (!$msg) {
throw new Horde_Ldap_Exception('Bind failed: ' . @ldap_error($this->_link),
@ldap_errno($this->_link));
}
}
/**
* Connects to the LDAP server.
*
* This function connects to the LDAP server specified in the
* configuration, binds and set up the LDAP protocol as needed.
*
* @throws Horde_Ldap_Exception
*/
protected function _connect()
{
/* Connecting is briefly described in RFC1777. Basicly it works like
* this:
* 1. set up TCP connection
* 2. secure that connection if neccessary
* 3a. setVersion to tell server which version we want to speak
* 3b. perform bind
* 3c. setVersion to tell server which version we want to speak
* together with a test for supported versions
* 4. set additional protocol options */
/* Return if we are already connected. */
if ($this->_link) {
return;
}
/* Connnect to the LDAP server if we are not connected. Note that
* ldap_connect() may return a link value even if no connection is
* made. We need to do at least one anonymous bind to ensure that a
* connection is actually valid.
*
* See: http://www.php.net/manual/en/function.ldap-connect.php */
/* Default error message in case all connection attempts fail but no
* message is set. */
$current_error = new Horde_Ldap_Exception('Unknown connection error');
/* Catch empty $_hostList arrays. */
if (!is_array($this->_hostList) || !count($this->_hostList)) {
throw new Horde_Ldap_Exception('No servers configured');
}
/* Cycle through the host list. */
foreach ($this->_hostList as $host) {
/* Ensure we have a valid string for host name. */
if (is_array($host)) {
$current_error = new Horde_Ldap_Exception('No Servers configured');
continue;
}
/* Skip this host if it is known to be down. */
if (in_array($host, $this->_downHostList)) {
continue;
}
/* Record the host that we are actually connecting to in case we
* need it later. */
$this->_config['hostspec'] = $host;
/* The ldap extension doesn't allow to provide connection timeouts
* and seems to default to 2 minutes. Open a socket manually
* instead to ping the server. */
$failed = true;
$url = @parse_url($host);
$sockhost = !empty($url['host']) ? $url['host'] : $host;
if ($fp = @fsockopen($sockhost, $this->_config['port'], $errno, $errstr, $this->_config['timeout'])) {
$failed = false;
fclose($fp);
}
/* Attempt a connection. */
if (!$failed) {
$this->_link = @ldap_connect($host, $this->_config['port']);
}
if (!$this->_link) {
$current_error = new Horde_Ldap_Exception('Could not connect to ' . $host . ':' . $this->_config['port']);
$this->_downHostList[] = $host;
continue;
}
/* If we're supposed to use TLS, do so before we try to bind, as
* some strict servers only allow binding via secure
* connections. */
if ($this->_config['tls']) {
try {
$this->startTLS();
} catch (Horde_Ldap_Exception $e) {
$current_error = $e;
$this->_link = false;
$this->_downHostList[] = $host;
continue;
}
}
/* Try to set the configured LDAP version on the connection if LDAP
* server needs that before binding (eg OpenLDAP).
* This could be necessary since RFC 1777 states that the protocol
* version has to be set at the bind request.
* We use force here which means that the test in the rootDSE is
* skipped; this is neccessary, because some strict LDAP servers
* only allow to read the LDAP rootDSE (which tells us the
* supported protocol versions) with authenticated clients.
* This may fail in which case we try again after binding.
* In this case, most probably the bind() or setVersion() call
* below will also fail, providing error messages. */
$version_set = false;
$this->setVersion(0, true);
/* Attempt to bind to the server. If we have credentials
* configured, we try to use them, otherwise it's an anonymous
* bind.
* As stated by RFC 1777, the bind request should be the first
* operation to be performed after the connection is established.
* This may give an protocol error if the server does not support
* v2 binds and the above call to setVersion() failed.
* If the above call failed, we try an v2 bind here and set the
* version afterwards (with checking to the rootDSE). */
try {
$this->bind();
} catch (Exception $e) {
/* The bind failed, discard link and save error msg.
* Then record the host as down and try next one. */
if ($this->errorName($e->getCode()) == 'LDAP_PROTOCOL_ERROR' &&
!$version_set) {
/* Provide a finer grained error message if protocol error
* arises because of invalid version. */
$e = new Horde_Ldap_Exception($e->getMessage() . ' (could not set LDAP protocol version to ' . $this->_config['version'].')', $e->getCode());
}
$this->_link = false;
$current_error = $e;
$this->_downHostList[] = $host;
continue;
}
/* Set desired LDAP version if not successfully set before.
* Here, a check against the rootDSE is performed, so we get a
* error message if the server does not support the version.
* The rootDSE entry should tell us which LDAP versions are
* supported. However, some strict LDAP servers only allow
* bound users to read the rootDSE. */
if (!$version_set) {
try {
$this->setVersion();
} catch (Exception $e) {
$current_error = $e;
$this->_link = false;
$this->_downHostList[] = $host;
continue;
}
}
/* Set LDAP parameters, now that we know we have a valid
* connection. */
if (isset($this->_config['options']) &&
is_array($this->_config['options']) &&
count($this->_config['options'])) {
foreach ($this->_config['options'] as $opt => $val) {
try {
$this->setOption($opt, $val);
} catch (Exception $e) {
$current_error = $e;
$this->_link = false;
$this->_downHostList[] = $host;
continue 2;
}
}
}
/* At this stage we have connected, bound, and set up options, so
* we have a known good LDAP server. Time to go home. */
return;
}
/* All connection attempts have failed, return the last error. */
throw $current_error;
}
/**
* Reconnects to the LDAP server.
*
* In case the connection to the LDAP service has dropped out for some
* reason, this function will reconnect, and re-bind if a bind has been
* attempted in the past. It is probably most useful when the server list
* provided to the new() or _connect() function is an array rather than a
* single host name, because in that case it will be able to connect to a
* failover or secondary server in case the primary server goes down.
*
* This method just tries to re-establish the current connection. It will
* sleep for the current backoff period (seconds) before attempting the
* connect, and if the connection fails it will double the backoff period,
* but not try again. If you want to ensure a reconnection during a
* transient period of server downtime then you need to call this function
* in a loop.
*
* @throws Horde_Ldap_Exception
*/
protected function _reconnect()
{
/* Return if we are already connected. */
if ($this->_link) {
return;
}
/* Sleep for a backoff period in seconds. */
sleep($this->_config['current_backoff']);
/* Retry all available connections. */
$this->_downHostList = array();
try {
$this->_connect();
} catch (Horde_Ldap_Exception $e) {
$this->_config['current_backoff'] *= 2;
if ($this->_config['current_backoff'] > $this->_config['max_backoff']) {
$this->_config['current_backoff'] = $this->_config['max_backoff'];
}
throw $e;
}
/* Now we should be able to safely (re-)bind. */
try {
$this->bind();
} catch (Exception $e) {
$this->_config['current_backoff'] *= 2;
if ($this->_config['current_backoff'] > $this->_config['max_backoff']) {
$this->_config['current_backoff'] = $this->_config['max_backoff'];
}
/* $this->_config['hostspec'] should have had the last connected
* host stored in it by _connect(). Since we are unable to
* bind to that host we can safely assume that it is down or has
* some other problem. */
$this->_downHostList[] = $this->_config['hostspec'];
throw $e;
}
/* At this stage we have connected, bound, and set up options, so we
* have a known good LDAP server. Time to go home. */
$this->_config['current_backoff'] = $this->_config['min_backoff'];
}
/**
* Closes the LDAP connection.
*/
public function disconnect()
{
@ldap_close($this->_link);
}
/**
* Starts an encrypted session.
*
* @throws Horde_Ldap_Exception
*/
public function startTLS()
{
/* First try STARTTLS blindly, some servers don't even allow to receive
* the rootDSE without TLS. */
if (@ldap_start_tls($this->_link)) {
return;
}
/* Keep original error. */
$error = 'TLS not started: ' . @ldap_error($this->_link);
$errno = @ldap_errno($this->_link);
/* Test to see if the server supports TLS at all.
* This is done via testing the extensions offered by the server.
* The OID 1.3.6.1.4.1.1466.20037 tells whether TLS is supported. */
try {
$rootDSE = $this->rootDSE();
} catch (Exception $e) {
throw new Horde_Ldap_Exception('Unable to start TLS and unable to fetch rootDSE entry to see if TLS is supported: ' . $e->getMessage(), $e->getCode());
}
try {
$supported_extensions = $rootDSE->getValue('supportedExtension');
} catch (Exception $e) {
throw new Horde_Ldap_Exception('Unable to start TLS and unable to fetch rootDSE attribute "supportedExtension" to see if TLS is supoported: ' . $e->getMessage(), $e->getCode());
}
if (!in_array('1.3.6.1.4.1.1466.20037', $supported_extensions)) {
throw new Horde_Ldap_Exception('Server reports that it does not support TLS');
}
throw new Horde_Ldap_Exception($error, $errno);
}
/**
* Adds a new entry to the directory.
*
* This also links the entry to the connection used for the add, if it was
* a fresh entry.
*
* @see HordeLdap_Entry::createFresh()
*
* @param Horde_Ldap_Entry $entry An LDAP entry.
*
* @throws Horde_Ldap_Exception
*/
public function add(Horde_Ldap_Entry $entry)
{
/* Continue attempting the add operation in a loop until we get a
* success, a definitive failure, or the world ends. */
while (true) {
$link = $this->getLink();
if ($link === false) {
/* We do not have a successful connection yet. The call to
* getLink() would have kept trying if we wanted one. */
throw new Horde_Ldap_Exception('Could not add entry ' . $entry->dn() . ' no valid LDAP connection could be found.');
}
if (@ldap_add($link, $entry->dn(), $entry->getValues())) {
/* Entry successfully added, we should update its Horde_Ldap
* reference in case it is not set so far (fresh entry). */
try {
$entry->getLDAP();
} catch (Horde_Ldap_Exception $e) {
$entry->setLDAP($this);
}
/* Store that the entry is present inside the directory. */
$entry->markAsNew(false);
return;
}
/* We have a failure. What kind? We may be able to reconnect and
* try again. */
$error_code = @ldap_errno($link);
if ($this->errorName($error_code) != 'LDAP_OPERATIONS_ERROR' |
!$this->_config['auto_reconnect']) {
/* Errors other than the above are just passed back to the user
* so he may react upon them. */
throw new Horde_Ldap_Exception('Could not add entry ' . $entry->dn() . ': ' . ldap_err2str($error_code), $error_code);
}
/* The server has disconnected before trying the operation. We
* should try again, possibly with a different server. */
$this->_link = false;
$this->_reconnect();
}
}
/**
* Deletes an entry from the directory.
*
* @param string|Horde_Ldap_Entry $dn DN string or Horde_Ldap_Entry.
* @param boolean $recursive Should we delete all children
* recursivelx as well?
* @throws Horde_Ldap_Exception
*/
public function delete($dn, $recursive = false)
{
if ($dn instanceof Horde_Ldap_Entry) {
$dn = $dn->dn();
}
if (!is_string($dn)) {
throw new Horde_Ldap_Exception('Parameter is not a string nor an entry object!');
}
/* Recursive delete searches for children and calls delete for them. */
if ($recursive) {
$result = @ldap_list($this->_link, $dn, '(objectClass=*)', array(null), 0, 0);
if ($result && @ldap_count_entries($this->_link, $result)) {
for ($subentry = @ldap_first_entry($this->_link, $result);
$subentry;
$subentry = @ldap_next_entry($this->_link, $subentry)) {
$this->delete(@ldap_get_dn($this->_link, $subentry), true);
}
}
}
/* Continue the delete operation in a loop until we get a success, or a
* definitive failure. */
while (true) {
$link = $this->getLink();
if (!$link) {
/* We do not have a successful connection yet. The call to
* getLink() would have kept trying if we wanted one. */
throw new Horde_Ldap_Exception('Could not add entry ' . $dn . ' no valid LDAP connection could be found.');
}
$s = @ldap_delete($link, $dn);
if ($s) {
/* Entry successfully deleted. */
return;
}
/* We have a failure. What kind? We may be able to reconnect and
* try again. */
$error_code = @ldap_errno($link);
if ($this->errorName($error_code) == 'LDAP_OPERATIONS_ERROR' &&
$this->_config['auto_reconnect']) {
/* The server has disconnected before trying the operation. We
* should try again, possibly with a different server. */
$this->_link = false;
$this->_reconnect();
} elseif ($this->errorName($error_code) == 'LDAP_NOT_ALLOWED_ON_NONLEAF') {
/* Subentries present, server refused to delete.
* Deleting subentries is the clients responsibility, but since
* the user may not know of the subentries, we do not force
* that here but instead notify the developer so he may take
* actions himself. */
throw new Horde_Ldap_Exception('Could not delete entry ' . $dn . ' because of subentries. Use the recursive parameter to delete them.', $error_code);
} else {
/* Errors other than the above catched are just passed back to
* the user so he may react upon them. */
throw new Horde_Ldap_Exception('Could not delete entry ' . $dn . ': ' . ldap_err2str($error_code), $error_code);
}
}
}
/**
* Modifies an LDAP entry on the server.
*
* The $params argument is an array of actions and should be something like
* this:
* <code>
* array('add' => array('attribute1' => array('val1', 'val2'),
* 'attribute2' => array('val1')),
* 'delete' => array('attribute1'),
* 'replace' => array('attribute1' => array('val1')),
* 'changes' => array('add' => ...,
* 'replace' => ...,
* 'delete' => array('attribute1', 'attribute2' => array('val1')))
* </code>
*
* The order of execution is as following:
* 1. adds from 'add' array
* 2. deletes from 'delete' array
* 3. replaces from 'replace' array
* 4. changes (add, replace, delete) in order of appearance
*
* The function calls the corresponding functions of an Horde_Ldap_Entry
* object. A detailed description of array structures can be found there.
*
* Unlike the modification methods provided by the Horde_Ldap_Entry object,
* this method will instantly carry out an update() after each operation,
* thus modifying "directly" on the server.
*
* @see Horde_Ldap_Entry::add()
* @see Horde_Ldap_Entry::delete()
* @see Horde_Ldap_Entry::replace()
*
* @param string|Horde_Ldap_Entry $entry DN string or Horde_Ldap_Entry.
* @param array $parms Array of changes
*
* @throws Horde_Ldap_Exception
*/
public function modify($entry, $parms = array())
{
if (is_string($entry)) {
$entry = $this->getEntry($entry);
}
if (!($entry instanceof Horde_Ldap_Entry)) {
throw new Horde_Ldap_Exception('Parameter is not a string nor an entry object!');
}
if ($unknown = array_diff(array_keys($parms), array('add', 'delete', 'replace', 'changes'))) {
throw new Horde_Ldap_Exception('Unknown modify action(s): ' . implode(', ', $unknown));
}
/* Perform changes mentioned separately. */
foreach (array('add', 'delete', 'replace') as $action) {
if (!isset($parms[$action])) {
continue;
}
$entry->$action($parms[$action]);
$entry->setLDAP($this);
/* Because the ldap_*() functions are called inside
* Horde_Ldap_Entry::update(), we have to trap the error codes
* issued from that if we want to support reconnection. */
while (true) {
try {
$entry->update();
break;
} catch (Exception $e) {
/* We have a failure. What kind? We may be able to
* reconnect and try again. */
if ($this->errorName($e->getCode()) != 'LDAP_OPERATIONS_ERROR' ||
!$this->_config['auto_reconnect']) {
/* Errors other than the above catched are just passed
* back to the user so he may react upon them. */
throw new Horde_Ldap_Exception('Could not modify entry: ' . $e->getMessage());
}
/* The server has disconnected before trying the operation.
* We should try again, possibly with a different
* server. */
$this->_link = false;
$this->_reconnect();
}
}
}
if (!isset($parms['changes']) || !is_array($parms['changes'])) {
return;
}
/* Perform combined changes in 'changes' array. */
foreach ($parms['changes'] as $action => $value) {
$this->modify($entry, array($action => $value));
}
}
/**
* Runs an LDAP search query.
*
* $base and $filter may be ommitted. The one from config will then be
* used. $base is either a DN-string or an Horde_Ldap_Entry object in which
* case its DN will be used.
*
* $params may contain:
* - scope: The scope which will be used for searching, defaults to 'sub':
* - base: Just one entry
* - sub: The whole tree
* - one: Immediately below $base
* - sizelimit: Limit the number of entries returned
* (default: 0 = unlimited)
* - timelimit: Limit the time spent for searching (default: 0 = unlimited)
* - attrsonly: If true, the search will only return the attribute names
* - attributes: Array of attribute names, which the entry should contain.
* It is good practice to limit this to just the ones you
* need.
*
* You cannot override server side limitations to sizelimit and timelimit:
* You can always only lower a given limit.
*
* @todo implement search controls (sorting etc)
*
* @param string|Horde_Ldap_Entry $base LDAP searchbase.
* @param string|Horde_Ldap_Filter $filter LDAP search filter.
* @param array $params Array of options.
*
* @return Horde_Ldap_Search The search result.
* @throws Horde_Ldap_Exception
*/
public function search($base = null, $filter = null, $params = array())
{
if (is_null($base)) {
$base = $this->_config['basedn'];
}
if ($base instanceof Horde_Ldap_Entry) {
/* Fetch DN of entry, making searchbase relative to the entry. */
$base = $base->dn();
}
if (is_null($filter)) {
$filter = $this->_config['filter'];
}
if ($filter instanceof Horde_Ldap_Filter) {
/* Convert Horde_Ldap_Filter to string representation. */
$filter = (string)$filter;
}
/* Setting search parameters. */
$sizelimit = isset($params['sizelimit']) ? $params['sizelimit'] : 0;
$timelimit = isset($params['timelimit']) ? $params['timelimit'] : 0;
$attrsonly = isset($params['attrsonly']) ? $params['attrsonly'] : 0;
$attributes = isset($params['attributes']) ? $params['attributes'] : array();
/* Ensure $attributes to be an array in case only one attribute name
* was given as string. */
if (!is_array($attributes)) {
$attributes = array($attributes);
}
/* Reorganize the $attributes array index keys sometimes there are
* problems with not consecutive indexes. */
$attributes = array_values($attributes);
/* Scoping makes searches faster! */
$scope = isset($params['scope'])
? $params['scope']
: $this->_config['scope'];
switch ($scope) {
case 'one':
$search_function = 'ldap_list';
break;
case 'base':
$search_function = 'ldap_read';
break;
default:
$search_function = 'ldap_search';
}
/* Continue attempting the search operation until we get a success or a
* definitive failure. */
while (true) {
$link = $this->getLink();
$search = @call_user_func($search_function,
$link,
$base,
$filter,
$attributes,
$attrsonly,
$sizelimit,
$timelimit);
if ($errno = @ldap_errno($link)) {
$err = $this->errorName($errno);
if ($err == 'LDAP_NO_SUCH_OBJECT' ||
$err == 'LDAP_SIZELIMIT_EXCEEDED') {
return new Horde_Ldap_Search($search, $this, $attributes);
}
if ($err == 'LDAP_FILTER_ERROR') {
/* Bad search filter. */
throw new Horde_Ldap_Exception(ldap_err2str($errno) . ' ($filter)', $errno);
}
if ($err == 'LDAP_OPERATIONS_ERROR' &&
$this->_config['auto_reconnect']) {
$this->_link = false;
$this->_reconnect();
} else {
$msg = "\nParameters:\nBase: $base\nFilter: $filter\nScope: $scope";
throw new Horde_Ldap_Exception(ldap_err2str($errno) . $msg, $errno);
}
} else {
return new Horde_Ldap_Search($search, $this, $attributes);
}
}
}
/**
* Returns the DN of a user.
*
* The purpose is to quickly find the full DN of a user so it can be used
* to re-bind as this user. This method requires the 'user' configuration
* parameter to be set.
*
* @param string $user The user to find.
*
* @return string The user's full DN.
* @throws Horde_Ldap_Exception
* @throws Horde_Exception_NotFound
*/
public function findUserDN($user)
{
$filter = Horde_Ldap_Filter::combine(
'and',
array(Horde_Ldap_Filter::build($this->_config['user']),
Horde_Ldap_Filter::create($this->_config['user']['uid'], 'equals', $user)));
$search = $this->search(
isset($this->_config['user']['basedn'])
? $this->_config['user']['basedn']
: null,
$filter,
array('attributes' => array($this->_config['user']['uid'])));
if (!$search->count()) {
throw new Horde_Exception_NotFound('DN for user ' . $user . ' not found');
}
$entry = $search->shiftEntry();
return $entry->currentDN();
}
/**
* Sets an LDAP option.
*
* @param string $option Option to set.
* @param mixed $value Value to set option to.
*
* @throws Horde_Ldap_Exception
*/
public function setOption($option, $value)
{
if (!$this->_link) {
throw new Horde_Ldap_Exception('Could not set LDAP option: No LDAP connection');
}
if (!defined($option)) {
throw new Horde_Ldap_Exception('Unkown option requested');
}
if (@ldap_set_option($this->_link, constant($option), $value)) {
return;
}
$err = @ldap_errno($this->_link);
if ($err) {
throw new Horde_Ldap_Exception(ldap_err2str($err), $err);
}
throw new Horde_Ldap_Exception('Unknown error');
}
/**
* Returns an LDAP option value.
*
* @param string $option Option to get.
*
* @return Horde_Ldap_Error|string Horde_Ldap_Error or option value
* @throws Horde_Ldap_Exception
*/
public function getOption($option)
{
if (!$this->_link) {
throw new Horde_Ldap_Exception('No LDAP connection');
}
if (!defined($option)) {
throw new Horde_Ldap_Exception('Unkown option requested');
}
if (@ldap_get_option($this->_link, constant($option), $value)) {
return $value;
}
$err = @ldap_errno($this->_link);
if ($err) {
throw new Horde_Ldap_Exception(ldap_err2str($err), $err);
}
throw new Horde_Ldap_Exception('Unknown error');
}
/**
* Returns the LDAP protocol version that is used on the connection.
*
* A lot of LDAP functionality is defined by what protocol version
* the LDAP server speaks. This might be 2 or 3.
*
* @return integer The protocol version.
*/
public function getVersion()
{
if ($this->_link) {
$version = $this->getOption('LDAP_OPT_PROTOCOL_VERSION');
} else {
$version = $this->_config['version'];
}
return $version;
}
/**
* Sets the LDAP protocol version that is used on the connection.
*
* @todo Checking via the rootDSE takes much time - why? fetching
* and instanciation is quick!
*
* @param integer $version LDAP version that should be used.
* @param boolean $force If set to true, the check against the rootDSE
* will be skipped.
*
* @throws Horde_Ldap_Exception
*/
public function setVersion($version = 0, $force = false)
{
if (!$version) {
$version = $this->_config['version'];
}
/* Check to see if the server supports this version first.
*
* TODO: Why is this so horribly slow? $this->rootDSE() is very fast,
* as well as Horde_Ldap_RootDse(). Seems like a problem at copying the
* object inside PHP?? Additionally, this is not always
* reproducable... */
if (!$force) {
try {
$rootDSE = $this->rootDSE();
$supported_versions = $rootDSE->getValue('supportedLDAPVersion');
if (is_string($supported_versions)) {
$supported_versions = array($supported_versions);
}
$check_ok = in_array($version, $supported_versions);
} catch (Horde_Ldap_Exception $e) {
/* If we don't get a root DSE, this is probably a v2 server. */
$check_ok = $version < 3;
}
}
$check_ok = true;
if ($force || $check_ok) {
return $this->setOption('LDAP_OPT_PROTOCOL_VERSION', $version);
}
throw new Horde_Ldap_Exception('LDAP Server does not support protocol version ' . $version);
}
/**
* Returns whether a DN exists in the directory.
*
* @param string|Horde_Ldap_Entry $dn The DN of the object to test.
*
* @return boolean True if the DN exists.
* @throws Horde_Ldap_Exception
*/
public function exists($dn)
{
if ($dn instanceof Horde_Ldap_Entry) {
$dn = $dn->dn();
}
if (!is_string($dn)) {
throw new Horde_Ldap_Exception('Parameter $dn is not a string nor an entry object!');
}
/* Make dn relative to parent. */
$options = array('casefold' => 'none');
$base = Horde_Ldap_Util::explodeDN($dn, $options);
$entry_rdn = '(&('
. Horde_Ldap_Util::canonicalDN(
array_shift($base),
array_merge($options, array('separator' => ')('))
)
. '))';
$base = Horde_Ldap_Util::canonicalDN($base, $options);
$result = @ldap_list($this->_link, $base, $entry_rdn, array('dn'), 1, 1);
if ($result && @ldap_count_entries($this->_link, $result)) {
return true;
}
if ($this->errorName(@ldap_errno($this->_link)) == 'LDAP_NO_SUCH_OBJECT') {
return false;
}
if (@ldap_errno($this->_link)) {
throw new Horde_Ldap_Exception(@ldap_error($this->_link), @ldap_errno($this->_link));
}
return false;
}
/**
* Returns a specific entry based on the DN.
*
* @todo Maybe a check against the schema should be done to be
* sure the attribute type exists.
*
* @param string $dn DN of the entry that should be fetched.
* @param array $attributes Array of Attributes to select. If ommitted, all
* attributes are fetched.
*
* @return Horde_Ldap_Entry A Horde_Ldap_Entry object.
* @throws Horde_Ldap_Exception
* @throws Horde_Exception_NotFound
*/
public function getEntry($dn, $attributes = array())
{
if (!is_array($attributes)) {
$attributes = array($attributes);
}
$result = $this->search($dn, '(objectClass=*)',
array('scope' => 'base', 'attributes' => $attributes));
if (!$result->count()) {
throw new Horde_Exception_NotFound(sprintf('Could not fetch entry %s: no entry found', $dn));
}
$entry = $result->shiftEntry();
if (!$entry) {
throw new Horde_Ldap_Exception('Could not fetch entry (error retrieving entry from search result)');
}
return $entry;
}
/**
* Renames or moves an entry.
*
* This method will instantly carry out an update() after the
* move, so the entry is moved instantly.
*
* You can pass an optional Horde_Ldap object. In this case, a
* cross directory move will be performed which deletes the entry
* in the source (THIS) directory and adds it in the directory
* $target_ldap.
*
* A cross directory move will switch the entry's internal LDAP
* reference so updates to the entry will go to the new directory.
*
* If you want to do a cross directory move, you need to pass an
* Horde_Ldap_Entry object, otherwise the attributes will be
* empty.
*
* @param string|Horde_Ldap_Entry $entry An LDAP entry.
* @param string $newdn The new location.
* @param Horde_Ldap $target_ldap Target directory for cross
* server move.
*
* @throws Horde_Ldap_Exception
*/
public function move($entry, $newdn, $target_ldap = null)
{
if (is_string($entry)) {
if ($target_ldap && $target_ldap !== $this) {
throw new Horde_Ldap_Exception('Unable to perform cross directory move: operation requires a Horde_Ldap_Entry object');
}
$entry = $this->getEntry($entry);
}
if (!$entry instanceof Horde_Ldap_Entry) {
throw new Horde_Ldap_Exception('Parameter $entry is expected to be a Horde_Ldap_Entry object! (If DN was passed, conversion failed)');
}
if ($target_ldap && !($target_ldap instanceof Horde_Ldap)) {
throw new Horde_Ldap_Exception('Parameter $target_ldap is expected to be a Horde_Ldap object!');
}
if (!$target_ldap || $target_ldap === $this) {
/* Local move. */
$entry->dn($newdn);
$entry->setLDAP($this);
$entry->update();
return;
}
/* Cross directory move. */
if ($target_ldap->exists($newdn)) {
throw new Horde_Ldap_Exception('Unable to perform cross directory move: entry does exist in target directory');
}
$entry->dn($newdn);
try {
$target_ldap->add($entry);
} catch (Exception $e) {
throw new Horde_Ldap_Exception('Unable to perform cross directory move: ' . $e->getMessage() . ' in target directory');
}
try {
$this->delete($entry->currentDN());
} catch (Exception $e) {
try {
$add_error_string = '';
/* Undo add. */
$target_ldap->delete($entry);
} catch (Exception $e) {
$add_error_string = ' Additionally, the deletion (undo add) of $entry in target directory failed.';
}
throw new Horde_Ldap_Exception('Unable to perform cross directory move: ' . $e->getMessage() . ' in source directory.' . $add_error_string);
}
$entry->setLDAP($target_ldap);
}
/**
* Copies an entry to a new location.
*
* The entry will be immediately copied. Only attributes you have
* selected will be copied.
*
* @param Horde_Ldap_Entry $entry An LDAP entry.
* @param string $newdn New FQF-DN of the entry.
*
* @return Horde_Ldap_Entry The copied entry.
* @throws Horde_Ldap_Exception
*/
public function copy($entry, $newdn)
{
if (!$entry instanceof Horde_Ldap_Entry) {
throw new Horde_Ldap_Exception('Parameter $entry is expected to be a Horde_Ldap_Entry object');
}
$newentry = Horde_Ldap_Entry::createFresh($newdn, $entry->getValues());
$this->add($newentry);
return $newentry;
}
/**
* Returns the string for an LDAP errorcode.
*
* Made to be able to make better errorhandling. Function based
* on DB::errorMessage().
*
* Hint: The best description of the errorcodes is found here:
* http://www.directory-info.com/Ldap/LDAPErrorCodes.html
*
* @param integer $errorcode An error code.
*
* @return string The description for the error.
*/
public static function errorName($errorcode)
{
$errorMessages = array(
0x00 => 'LDAP_SUCCESS',
0x01 => 'LDAP_OPERATIONS_ERROR',
0x02 => 'LDAP_PROTOCOL_ERROR',
0x03 => 'LDAP_TIMELIMIT_EXCEEDED',
0x04 => 'LDAP_SIZELIMIT_EXCEEDED',
0x05 => 'LDAP_COMPARE_FALSE',
0x06 => 'LDAP_COMPARE_TRUE',
0x07 => 'LDAP_AUTH_METHOD_NOT_SUPPORTED',
0x08 => 'LDAP_STRONG_AUTH_REQUIRED',
0x09 => 'LDAP_PARTIAL_RESULTS',
0x0a => 'LDAP_REFERRAL',
0x0b => 'LDAP_ADMINLIMIT_EXCEEDED',
0x0c => 'LDAP_UNAVAILABLE_CRITICAL_EXTENSION',
0x0d => 'LDAP_CONFIDENTIALITY_REQUIRED',
0x0e => 'LDAP_SASL_BIND_INPROGRESS',
0x10 => 'LDAP_NO_SUCH_ATTRIBUTE',
0x11 => 'LDAP_UNDEFINED_TYPE',
0x12 => 'LDAP_INAPPROPRIATE_MATCHING',
0x13 => 'LDAP_CONSTRAINT_VIOLATION',
0x14 => 'LDAP_TYPE_OR_VALUE_EXISTS',
0x15 => 'LDAP_INVALID_SYNTAX',
0x20 => 'LDAP_NO_SUCH_OBJECT',
0x21 => 'LDAP_ALIAS_PROBLEM',
0x22 => 'LDAP_INVALID_DN_SYNTAX',
0x23 => 'LDAP_IS_LEAF',
0x24 => 'LDAP_ALIAS_DEREF_PROBLEM',
0x30 => 'LDAP_INAPPROPRIATE_AUTH',
0x31 => 'LDAP_INVALID_CREDENTIALS',
0x32 => 'LDAP_INSUFFICIENT_ACCESS',
0x33 => 'LDAP_BUSY',
0x34 => 'LDAP_UNAVAILABLE',
0x35 => 'LDAP_UNWILLING_TO_PERFORM',
0x36 => 'LDAP_LOOP_DETECT',
0x3C => 'LDAP_SORT_CONTROL_MISSING',
0x3D => 'LDAP_INDEX_RANGE_ERROR',
0x40 => 'LDAP_NAMING_VIOLATION',
0x41 => 'LDAP_OBJECT_CLASS_VIOLATION',
0x42 => 'LDAP_NOT_ALLOWED_ON_NONLEAF',
0x43 => 'LDAP_NOT_ALLOWED_ON_RDN',
0x44 => 'LDAP_ALREADY_EXISTS',
0x45 => 'LDAP_NO_OBJECT_CLASS_MODS',
0x46 => 'LDAP_RESULTS_TOO_LARGE',
0x47 => 'LDAP_AFFECTS_MULTIPLE_DSAS',
0x50 => 'LDAP_OTHER',
0x51 => 'LDAP_SERVER_DOWN',
0x52 => 'LDAP_LOCAL_ERROR',
0x53 => 'LDAP_ENCODING_ERROR',
0x54 => 'LDAP_DECODING_ERROR',
0x55 => 'LDAP_TIMEOUT',
0x56 => 'LDAP_AUTH_UNKNOWN',
0x57 => 'LDAP_FILTER_ERROR',
0x58 => 'LDAP_USER_CANCELLED',
0x59 => 'LDAP_PARAM_ERROR',
0x5a => 'LDAP_NO_MEMORY',
0x5b => 'LDAP_CONNECT_ERROR',
0x5c => 'LDAP_NOT_SUPPORTED',
0x5d => 'LDAP_CONTROL_NOT_FOUND',
0x5e => 'LDAP_NO_RESULTS_RETURNED',
0x5f => 'LDAP_MORE_RESULTS_TO_RETURN',
0x60 => 'LDAP_CLIENT_LOOP',
0x61 => 'LDAP_REFERRAL_LIMIT_EXCEEDED',
1000 => 'Unknown Error');
return isset($errorMessages[$errorcode]) ?
$errorMessages[$errorcode] :
'Unknown Error (' . $errorcode . ')';
}
/**
* Returns a rootDSE object
*
* This either fetches a fresh rootDSE object or returns it from
* the internal cache for performance reasons, if possible.
*
* @param array $attrs Array of attributes to search for.
*
* @return Horde_Ldap_RootDse Horde_Ldap_RootDse object
* @throws Horde_Ldap_Exception
*/
public function rootDSE(array $attrs = array())
{
/* If a cache object is registered, we use that to fetch a rootDSE
* object. */
$key = 'Horde_Ldap_RootDse_'
. md5(serialize(array(
$this->_config['hostspec'], $this->_config['port'], $attrs
)));
if (empty($this->_rootDSE[$key]) &&
$this->_config['cache'] &&
$this->_config['cache_root_dse']) {
$entry = $this->_config['cache']->get(
$key, $this->_config['cachettl']
);
if ($entry) {
$this->_rootDSE[$key] = @unserialize($entry);
}
}
/* See if we need to fetch a fresh object, or if we already
* requested this object with the same attributes. */
if (empty($this->_rootDSE[$key])) {
$this->_rootDSE[$key] = new Horde_Ldap_RootDse($this, $attrs);
/* If caching is active, advise the cache to store the object. */
if ($this->_config['cache'] && $this->_config['cache_root_dse']) {
$this->_config['cache']->set(
$key,
serialize($this->_rootDSE[$key]),
$this->_config['cachettl']
);
}
}
return $this->_rootDSE[$key];
}
/**
* Returns a schema object
*
* @param string $dn Subschema entry dn.
*
* @return Horde_Ldap_Schema Horde_Ldap_Schema object
* @throws Horde_Ldap_Exception
*/
public function schema($dn = null)
{
/* If a schema caching object is registered, we use that to fetch a
* schema object. */
$key = 'Horde_Ldap_Schema_' . md5(serialize(array($this->_config['hostspec'], $this->_config['port'], $dn)));
if (!$this->_schema && $this->_config['cache']) {
$schema = $this->_config['cache']->get($key, $this->_config['cachettl']);
if ($schema) {
$this->_schema = @unserialize($schema);
}
}
/* Fetch schema, if not tried before and no cached version available.
* If we are already fetching the schema, we will skip fetching. */
if (!$this->_schema) {
/* Store a temporary error message so subsequent calls to schema()
* can detect that we are fetching the schema already. Otherwise we
* will get an infinite loop at Horde_Ldap_Schema. */
$this->_schema = new Horde_Ldap_Exception('Schema not initialized');
$this->_schema = new Horde_Ldap_Schema($this, $dn);
/* If schema caching is active, advise the cache to store the
* schema. */
if ($this->_config['cache']) {
$this->_config['cache']->set($key, serialize($this->_schema), $this->_config['cachettl']);
}
}
if ($this->_schema instanceof Horde_Ldap_Exception) {
throw $this->_schema;
}
return $this->_schema;
}
/**
* Checks if PHP's LDAP extension is loaded.
*
* If it is not loaded, it tries to load it manually using PHP's dl().
* It knows both windows-dll and *nix-so.
*
* @throws Horde_Ldap_Exception
*/
public static function checkLDAPExtension()
{
if (!extension_loaded('ldap') && !@dl('ldap.' . PHP_SHLIB_SUFFIX)) {
throw new Horde_Ldap_Exception('Unable to locate PHP LDAP extension. Please install it before using the Horde_Ldap package.');
}
}
/**
* @todo Remove this and expect all data to be UTF-8.
*
* Encodes given attributes to UTF8 if needed by schema.
*
* This function takes attributes in an array and then checks
* against the schema if they need UTF8 encoding. If that is the
* case, they will be encoded. An encoded array will be returned
* and can be used for adding or modifying.
*
* $attributes is expected to be an array with keys describing
* the attribute names and the values as the value of this attribute:
* <code>$attributes = array('cn' => 'foo', 'attr2' => array('mv1', 'mv2'));</code>
*
* @param array $attributes An array of attributes.
*
* @return array|Horde_Ldap_Error An array of UTF8 encoded attributes or an error.
*/
public function utf8Encode($attributes)
{
return $this->utf8($attributes, 'utf8_encode');
}
/**
* @todo Remove this and expect all data to be UTF-8.
*
* Decodes the given attribute values if needed by schema
*
* $attributes is expected to be an array with keys describing
* the attribute names and the values as the value of this attribute:
* <code>$attributes = array('cn' => 'foo', 'attr2' => array('mv1', 'mv2'));</code>
*
* @param array $attributes Array of attributes
*
* @access public
* @see utf8Encode()
* @return array|Horde_Ldap_Error Array with decoded attribute values or Error
*/
public function utf8Decode($attributes)
{
return $this->utf8($attributes, 'utf8_decode');
}
/**
* @todo Remove this and expect all data to be UTF-8.
*
* Encodes or decodes attribute values if needed
*
* @param array $attributes Array of attributes
* @param array $function Function to apply to attribute values
*
* @access protected
* @return array Array of attributes with function applied to values.
*/
protected function utf8($attributes, $function)
{
if (!is_array($attributes) || array_key_exists(0, $attributes)) {
throw new Horde_Ldap_Exception('Parameter $attributes is expected to be an associative array');
}
if (!$this->_schema) {
$this->_schema = $this->schema();
}
if (!$this->_link || !function_exists($function)) {
return $attributes;
}
if (is_array($attributes) && count($attributes) > 0) {
foreach ($attributes as $k => $v) {
if (!isset($this->_schemaAttrs[$k])) {
try {
$attr = $this->_schema->get('attribute', $k);
} catch (Exception $e) {
continue;
}
if (false !== strpos($attr['syntax'], '1.3.6.1.4.1.1466.115.121.1.15')) {
$encode = true;
} else {
$encode = false;
}
$this->_schemaAttrs[$k] = $encode;
} else {
$encode = $this->_schemaAttrs[$k];
}
if ($encode) {
if (is_array($v)) {
foreach ($v as $ak => $av) {
$v[$ak] = call_user_func($function, $av);
}
} else {
$v = call_user_func($function, $v);
}
}
$attributes[$k] = $v;
}
}
return $attributes;
}
/**
* Returns the LDAP link resource.
*
* It will loop attempting to re-establish the connection if the
* connection attempt fails and auto_reconnect has been turned on
* (see the _config array documentation).
*
* @return resource LDAP link.
*/
public function getLink()
{
if ($this->_config['auto_reconnect']) {
while (true) {
/* Return the link handle if we are already connected.
* Otherwise try to reconnect. */
if ($this->_link) {
return $this->_link;
}
$this->_reconnect();
}
}
return $this->_link;
}
/**
* Builds an LDAP search filter fragment.
*
* @param string $lhs The attribute to test.
* @param string $op The operator.
* @param string $rhs The comparison value.
* @param array $params Any additional parameters for the operator.
*
* @return string The LDAP search fragment.
*/
public static function buildClause($lhs, $op, $rhs, $params = array())
{
switch ($op) {
case 'LIKE':
if (empty($rhs)) {
return '(' . $lhs . '=*)';
}
if (!empty($params['begin'])) {
return sprintf('(|(%s=%s*)(%s=* %s*))', $lhs, self::quote($rhs), $lhs, self::quote($rhs));
}
if (!empty($params['approximate'])) {
return sprintf('(%s~=%s)', $lhs, self::quote($rhs));
}
return sprintf('(%s=*%s*)', $lhs, self::quote($rhs));
default:
return sprintf('(%s%s%s)', $lhs, $op, self::quote($rhs));
}
}
/**
* Escapes characters with special meaning in LDAP searches.
*
* @param string $clause The string to escape.
*
* @return string The escaped string.
*/
public static function quote($clause)
{
return str_replace(array('\\', '(', ')', '*', "\0"),
array('\\5c', '\(', '\)', '\*', "\\00"),
$clause);
}
/**
* Takes an array of DN elements and properly quotes it according to RFC
* 1485.
*
* @param array $parts An array of tuples containing the attribute
* name and that attribute's value which make
* up the DN. Example:
* <code>
* $parts = array(
* array('cn', 'John Smith'),
* array('dc', 'example'),
* array('dc', 'com')
* );
* </code>
* Nested arrays are supported since 2.1.0, to form
* multi-valued RDNs. Example:
* <code>
* $parts = array(
* array(
* array('cn', 'John'),
* array('sn', 'Smith'),
* array('o', 'Acme Inc.'),
* ),
* array('dc', 'example'),
* array('dc', 'com')
* );
* </code>
* which will result in
* cn=John+sn=Smith+o=Acme Inc.,dc=example,dc=com
*
* @return string The properly quoted string DN.
*/
public static function quoteDN($parts)
{
return implode(',', array_map('self::_quoteRDNs', $parts));
}
/**
* Takes a single or a list of RDN arrays with an attribute name and value
* and properly quotes it according to RFC 1485.
*
* @param array $attribute A tuple or array of tuples containing the
* attribute name and that attribute's value which
* make up the RDN.
*
* @return string The properly quoted string RDN.
*/
protected static function _quoteRDNs($attribute)
{
if (is_array($attribute[0])) {
return implode(
'+',
array_map('self::_quoteRDN', $attribute)
);
} else {
return self::_quoteRDN($attribute);
}
}
/**
* Takes an RDN array with an attribute name and value and properly quotes
* it according to RFC 1485.
*
* @param array $attribute A tuple containing the attribute name and that
* attribute's value which make up the RDN.
*
* @return string The properly quoted string RDN.
*/
protected static function _quoteRDN($attribute)
{
$rdn = $attribute[0] . '=';
// See if we need to quote the value.
if (preg_match('/^\s|\s$|\s\s|[,+="\r\n<>#;]/', $attribute[1])) {
$rdn .= '"' . str_replace('"', '\\"', $attribute[1]) . '"';
} else {
$rdn .= $attribute[1];
}
return $rdn;
}
}