Files
server/usr/share/psa-horde/turba/lib/Driver/Ldap.php
2026-01-07 20:52:11 +01:00

793 lines
29 KiB
PHP

<?php
/**
* Turba directory driver implementation for PHP's LDAP extension.
*
* Copyright 2010-2017 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (ASL). If you did
* did not receive this file, see http://www.horde.org/licenses/apache.
*
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Jon Parise <jon@csh.rit.edu>
* @category Horde
* @license http://www.horde.org/licenses/apache ASL
* @package Turba
*/
class Turba_Driver_Ldap extends Turba_Driver
{
/**
* Handle for the current LDAP connection.
*
* @var resource
*/
protected $_ds = 0;
/**
* Cache _getSyntax() calls.
*
* @var array
*/
protected $_syntaxCache = array();
/**
* Constructs a new Turba LDAP driver object.
*
* @param string $name The source name
* @param array $params Hash containing additional configuration parameters.
*
* @return Turba_Driver_Ldap
*/
public function __construct($name = '', array $params = array())
{
if (!Horde_Util::extensionExists('ldap')) {
throw new Turba_Exception(_("LDAP support is required but the LDAP module is not available or not loaded."));
}
$params = array_merge(array(
'charset' => '',
'deref' => LDAP_DEREF_NEVER,
'multiple_entry_separator' => ', ',
'port' => 389,
'root' => '',
'scope' => 'sub',
'server' => 'localhost'
), $params);
parent::__construct($name, $params);
}
/**
* Initiate LDAP connection.
*
* Not done in __construct(), only when a read or write action is
* necessary.
*/
protected function _connect()
{
if ($this->_ds) {
return;
}
if (!($this->_ds = @ldap_connect($this->_params['server'], $this->_params['port']))) {
throw new Turba_Exception(_("Connection failure"));
}
/* Set the LDAP protocol version. */
if (!empty($this->_params['version'])) {
@ldap_set_option($this->_ds, LDAP_OPT_PROTOCOL_VERSION, $this->_params['version']);
}
/* Set the LDAP deref option for dereferencing aliases. */
if (!empty($this->_params['deref'])) {
@ldap_set_option($this->_ds, LDAP_OPT_DEREF, $this->_params['deref']);
}
/* Set the LDAP referrals. */
if (isset($this->_params['referrals'])) {
@ldap_set_option($this->_ds, LDAP_OPT_REFERRALS, $this->_params['referrals']);
}
/* Start TLS if we're using it. */
if (!empty($this->_params['tls']) &&
!@ldap_start_tls($this->_ds)) {
throw new Turba_Exception(sprintf(_("STARTTLS failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
/* Bind to the server. */
if (isset($this->_params['bind_dn']) &&
isset($this->_params['bind_password'])) {
$error = !@ldap_bind($this->_ds, $this->_params['bind_dn'], $this->_params['bind_password']);
} else {
$error = !(@ldap_bind($this->_ds));
}
if ($error) {
throw new Turba_Exception(sprintf(_("Bind failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
}
/**
* Extends parent function to build composed fields needed for the dn
* based on the contents of $this->map.
*
* @param array $hash Hash using Turba keys.
*
* @return array Translated version of $hash.
*/
public function toDriverKeys(array $hash)
{
// First check for combined fields in the dn-fields and add them.
if (is_array($this->_params['dn'])) {
foreach ($this->_params['dn'] as $param) {
foreach ($this->map as $turbaname => $ldapname) {
if ((is_array($ldapname)) &&
(isset($ldapname['attribute'])) &&
($ldapname['attribute'] == $param)) {
$fieldarray = array();
foreach ($ldapname['fields'] as $mapfield) {
$fieldarray[] = isset($hash[$mapfield])
? $hash[$mapfield]
: '';
}
$hash[$turbaname] = Turba::formatCompositeField($ldapname['format'], $fieldarray);
}
}
}
}
// Now convert the turba-fieldnames to ldap-fieldnames
return parent::toDriverKeys($hash);
}
/**
* Searches the LDAP directory with the given criteria and returns
* a filtered list of results. If no criteria are specified, all
* records are returned.
*
* @param array $criteria Array containing the search criteria.
* @param array $fields List of fields to return.
* @param array $blobFields Fields that contain binary data.
*
* @return array Hash containing the search results.
* @throws Turba_Exception
*/
protected function _search(array $criteria, array $fields, array $blobFields = array(), $count_only = false)
{
$this->_connect();
/* Build the LDAP filter. */
$filter = '';
if (count($criteria)) {
foreach ($criteria as $key => $vals) {
if ($key == 'OR') {
$filter .= '(|' . $this->_buildSearchQuery($vals) . ')';
} elseif ($key == 'AND') {
$filter .= '(&' . $this->_buildSearchQuery($vals) . ')';
}
}
} elseif (!empty($this->_params['objectclass'])) {
/* Filter on objectclass. */
$filter = Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
}
/* Add source-wide filters, which are _always_ AND-ed. */
if (!empty($this->_params['filter'])) {
$filter = '(&' . '(' . $this->_params['filter'] . ')' . $filter . ')';
}
/* Four11 (at least) doesn't seem to return 'cn' if you don't
* ask for 'sn' as well. Add 'sn' implicitly. */
$attr = $fields;
if (!in_array('sn', $attr)) {
$attr[] = 'sn';
}
/* Add a sizelimit, if specified. Default is 0, which means no
* limit. Note: You cannot override a server-side limit with
* this. */
$sizelimit = 0;
if (!empty($this->_params['sizelimit'])) {
$sizelimit = $this->_params['sizelimit'];
}
/* Log the query at a DEBUG log level. */
Horde::log(sprintf(
'LDAP query by Turba_Driver_ldap::_search(): user = %s, root = %s (%s); filter = "%s"; attributes = "%s"; deref = "%s" ; sizelimit = %d',
$GLOBALS['registry']->getAuth(),
$this->_params['root'],
$this->_params['server'],
$filter,
implode(', ', $attr),
$this->_params['deref'],
$sizelimit
), 'DEBUG');
/* Send the query to the LDAP server and fetch the matching
* entries. */
$func = ($this->_params['scope'] == 'one')
? 'ldap_list'
: 'ldap_search';
if (!($res = @$func($this->_ds, $this->_params['root'], $filter, $attr, 0, $sizelimit))) {
throw new Turba_Exception(sprintf(_("Query failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
return $count_only ? count($this->_getResults($fields, $res)) : $this->_getResults($fields, $res);
}
/**
* Reads the LDAP directory for a given element and returns the results.
*
* @param string $key The primary key field to use.
* @param mixed $ids The ids of the contacts to load.
* @param string $owner Only return contacts owned by this user.
* @param array $fields List of fields to return.
* @param array $blobFields Array of fields containing binary data.
* @param array $dateFields Array of fields containing date data.
* @since 4.2.0
*
* @return array Hash containing the search results.
* @throws Horde_Exception_NotFound
*/
protected function _read(
$key, $ids, $owner, array $fields,
array $blobFields = array(),
array $dateFields = array()
)
{
/* Only DN. */
if ($key != 'dn') {
return array();
}
$this->_connect();
if (empty($this->_params['objectclass'])) {
$filter = 'objectclass=*';
} else {
$filter = (string)Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
}
/* Four11 (at least) doesn't seem to return 'cn' if you don't
* ask for 'sn' as well. Add 'sn' implicitly. */
$attr = $fields;
if (!in_array('sn', $attr)) {
$attr[] = 'sn';
}
/* Handle a request for multiple records. */
if (is_array($ids) && !empty($ids)) {
$results = array();
foreach ($ids as $d) {
$res = @ldap_read($this->_ds, Horde_String::convertCharset($d, 'UTF-8', $this->_params['charset']), $filter, $attr);
if ($res) {
$results = array_merge($results, $this->_getResults($fields, $res));
} else {
throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
}
return $results;
}
$res = @ldap_read($this->_ds, Horde_String::convertCharset($this->_params['root'], 'UTF-8', $this->_params['charset']), $filter, $attr);
if (!$res) {
throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
return $this->_getResults($fields, $res);
}
/**
* Adds the specified contact to the addressbook.
*
* @param array $attributes The attribute values of the contact.
* @param array $blob_fields Fields that represent binary data.
* @param array $date_fields Fields that represent dates. @since 4.2.0
*
* @throws Turba_Exception
*/
protected function _add(array $attributes, array $blob_fields = array(), array $date_fields = array())
{
if (empty($attributes['dn'])) {
throw new Turba_Exception('Tried to add an object with no dn: [' . serialize($attributes) . '].');
}
if (empty($this->_params['objectclass'])) {
throw new Turba_Exception('Tried to add an object with no objectclass: [' . serialize($attributes) . '].');
}
$this->_connect();
/* Take the DN out of the attributes array. */
$dn = $attributes['dn'];
unset($attributes['dn']);
/* Put the objectClass into the attributes array. */
if (!is_array($this->_params['objectclass'])) {
$attributes['objectclass'] = $this->_params['objectclass'];
} else {
$i = 0;
foreach ($this->_params['objectclass'] as $objectclass) {
$attributes['objectclass'][$i++] = $objectclass;
}
}
/* Don't add empty attributes. */
$attributes = array_filter($attributes, array($this, '_emptyAttributeFilter'));
/* If a required attribute doesn't exist, add a dummy
* value. */
if (!empty($this->_params['checkrequired'])) {
$required = $this->_checkRequiredAttributes($this->_params['objectclass']);
foreach ($required as $v) {
if (!isset($attributes[$v])) {
$attributes[$v] = $this->_params['checkrequired_string'];
}
}
}
$this->_encodeAttributes($attributes);
if (!@ldap_add($this->_ds, Horde_String::convertCharset($dn, 'UTF-8', $this->_params['charset']), $attributes)) {
throw new Turba_Exception('Failed to add an object: [' . ldap_errno($this->_ds) . '] "' . ldap_error($this->_ds) . '" DN: ' . $dn . ' (attributes: [' . serialize($attributes) . '])');
}
}
/**
* TODO
*
* @return boolean TODO
*/
protected function _canAdd()
{
return true;
}
/**
* Deletes the specified entry from the LDAP directory.
*
* @param string $object_key
* @param string $object_id
*
* @throws Turba_Exception
*/
protected function _delete($object_key, $object_id)
{
if ($object_key != 'dn') {
throw new Turba_Exception(_("Invalid key specified."));
}
$this->_connect();
if (!@ldap_delete($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']))) {
throw new Turba_Exception(sprintf(_("Delete failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
}
/**
* Modifies the specified entry in the LDAP directory.
*
* @param Turba_Object $object The object we wish to save.
*
* @return string The object id, possibly updated.
* @throw Turba_Exception
*/
protected function _save(Turba_Object $object)
{
$this->_connect();
$object_keys = $this->toDriverKeys(array('__key' => $object->getValue('__key')));
$object_id = reset($object_keys);
$object_key = key($object_keys);
$attributes = $this->toDriverKeys($object->getAttributes());
/* Get the old entry so that we can access the old
* values. These are needed so that we can delete any
* attributes that have been removed by using ldap_mod_del. */
if (empty($this->_params['objectclass'])) {
$filter = 'objectclass=*';
} else {
$filter = (string)Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
}
$oldres = @ldap_read($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $filter, array_merge(array_keys($attributes), array('objectclass')));
$info = ldap_get_attributes($this->_ds, ldap_first_entry($this->_ds, $oldres));
if ($this->_params['version'] == 3 &&
Horde_String::lower(str_replace(array(',', '"'), array('\\2C', ''), $this->_makeKey($attributes))) !=
Horde_String::lower(str_replace(',', '\\2C', $object_id))) {
/* Need to rename the object. */
$newrdn = $this->_makeRDN($attributes);
if ($newrdn == '') {
throw new Turba_Exception(_("Missing DN in LDAP source configuration."));
}
if (ldap_rename($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']),
Horde_String::convertCharset($newrdn, 'UTF-8', $this->_params['charset']), $this->_params['root'], true)) {
$object_id = $newrdn . ',' . $this->_params['root'];
} else {
throw new Turba_Exception(sprintf(_("Failed to change name: (%s) %s; Old DN = %s, New DN = %s, Root = %s"), ldap_errno($this->_ds), ldap_error($this->_ds), $object_id, $newrdn, $this->_params['root']));
}
}
/* Work only with lowercase keys. */
$info = array_change_key_case($info, CASE_LOWER);
$attributes = array_change_key_case($attributes, CASE_LOWER);
foreach ($info as $key => $var) {
$oldval = null;
/* Check to see if the old value and the new value are
* different and that the new value is empty. If so then
* we use ldap_mod_del to delete the attribute. */
if (isset($attributes[$key]) &&
($var[0] != $attributes[$key]) &&
$attributes[$key] == '') {
$oldval[$key] = $var[0];
if (!@ldap_mod_del($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $oldval)) {
throw new Turba_Exception(sprintf(_("Modify failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
unset($attributes[$key]);
} elseif (isset($attributes[$key]) &&
$var[0] == $attributes[$key]) {
/* Drop unchanged elements from list of attributes to write. */
unset($attributes[$key]);
}
}
unset($attributes[Horde_String::lower($object_key)]);
$this->_encodeAttributes($attributes);
$attributes = array_filter($attributes, array($this, '_emptyAttributeFilter'));
/* Modify objectclasses only if they really changed. */
$oldClasses = array_map(array('Horde_String', 'lower'), $info['objectclass']);
array_shift($oldClasses);
$attributes['objectclass'] = array_unique(array_map('strtolower', array_merge($info['objectclass'], $this->_params['objectclass'])));
unset($attributes['objectclass']['count']);
$attributes['objectclass'] = array_values($attributes['objectclass']);
/* Do not handle object classes unless they have changed. */
if ((!array_diff($oldClasses, $attributes['objectclass']))) {
unset($attributes['objectclass']);
}
if (!@ldap_modify($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $attributes)) {
throw new Turba_Exception(sprintf(_("Modify failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
return $object_id;
}
/**
* Build a RDN based on a set of attributes and what attributes
* make a RDN for the current source.
*
* @param array $attributes The attributes (in driver keys) of the
* object being added.
*
* @return string The RDN for the new object.
*/
protected function _makeRDN(array $attributes)
{
if (!is_array($this->_params['dn'])) {
return '';
}
$pairs = array();
foreach ($this->_params['dn'] as $param) {
if (isset($attributes[$param])) {
$pairs[] = array($param, $attributes[$param]);
}
}
return Horde_Ldap::quoteDN($pairs);
}
/**
* Build a DN based on a set of attributes and what attributes
* make a DN for the current source.
*
* @param array $attributes The attributes (in driver keys) of the
* object being added.
*
* @return string The DN for the new object.
*/
protected function _makeKey(array $attributes)
{
return $this->_makeRDN($attributes) . ',' . $this->_params['root'];
}
/**
* Build a piece of a search query.
*
* @param array $criteria The array of criteria.
*
* @return string An LDAP query fragment.
*/
protected function _buildSearchQuery(array $criteria)
{
$clause = '';
foreach ($criteria as $key => $vals) {
if (!empty($vals['OR']) || $key === 'OR') {
$clause .= '(|' . $this->_buildSearchQuery($vals) . ')';
} elseif (!empty($vals['AND'])) {
$clause .= '(&' . $this->_buildSearchQuery($vals) . ')';
} else {
if (isset($vals['field'])) {
$rhs = Horde_String::convertCharset($vals['test'], 'UTF-8', $this->_params['charset']);
$clause .= Horde_Ldap::buildClause($vals['field'], $vals['op'], $rhs, array('begin' => !empty($vals['begin'])));
} else {
foreach ($vals as $test) {
if (!empty($test['OR'])) {
$clause .= '(|' . $this->_buildSearchQuery($test) . ')';
} elseif (!empty($test['AND'])) {
$clause .= '(&' . $this->_buildSearchQuery($test) . ')';
} else {
$rhs = Horde_String::convertCharset($test['test'], 'UTF-8', $this->_params['charset']);
$clause .= Horde_Ldap::buildClause($test['field'], $test['op'], $rhs, array('begin' => !empty($vals['begin'])));
}
}
}
}
}
return $clause;
}
/**
* Get some results from a result identifier and clean them up.
*
* @param array $fields List of fields to return.
* @param resource $res Result identifier.
*
* @return array Hash containing the results.
* @throws Horde_Exception_NotFound
*/
protected function _getResults(array $fields, $res)
{
$entries = @ldap_get_entries($this->_ds, $res);
if ($entries === false) {
throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
}
/* Return only the requested fields (from $fields, above). */
$results = array();
for ($i = 0; $i < $entries['count']; ++$i) {
$entry = $entries[$i];
$result = array();
foreach ($fields as $field) {
$field_l = Horde_String::lower($field);
if ($field == 'dn') {
$result[$field] = Horde_String::convertCharset($entry[$field_l], $this->_params['charset'], 'UTF-8');
} else {
$result[$field] = '';
if (!empty($entry[$field_l])) {
for ($j = 0; $j < $entry[$field_l]['count']; $j++) {
if (!empty($result[$field])) {
$result[$field] .= $this->_params['multiple_entry_separator'];
}
$result[$field] .= Horde_String::convertCharset($entry[$field_l][$j], $this->_params['charset'], 'UTF-8');
}
/* If schema checking is enabled check the
* backend syntax. */
if (!empty($this->_params['checksyntax'])) {
$postal = $this->_isPostalAddress($field_l);
} else {
/* Otherwise rely on the attribute mapping
* in attributes.php. */
$attr = array_search($field_l, $this->map);
$postal = (!empty($attr) && !empty($GLOBALS['attributes'][$attr]) &&
$GLOBALS['attributes'][$attr]['type'] == 'address');
}
if ($postal) {
$result[$field] = str_replace('$', "\r\n", $result[$field]);
}
}
}
}
$results[] = $result;
}
return $results;
}
/**
* Remove empty attributes from attributes array.
*
* @param mixed $val Value from attributes array.
*
* @return boolean Boolean used by array_filter.
*/
protected function _emptyAttributeFilter($var)
{
if (!is_array($var)) {
return ($var != '');
}
if (!count($var)) {
return false;
}
foreach ($var as $v) {
if ($v == '') {
return false;
}
}
return true;
}
/**
* Format and encode attributes including postal addresses,
* character set encoding, etc.
*
* @param array $attributes The attributes array.
*/
protected function _encodeAttributes(&$attributes)
{
foreach ($attributes as $key => $val) {
/* If schema checking is enabled check the backend syntax. */
if (!empty($this->_params['checksyntax'])) {
$postal = $this->_isPostalAddress($key);
} else {
/* Otherwise rely on the attribute mapping in
* attributes.php. */
$attr = array_search($key, $this->map);
$postal = (!empty($attr) && !empty($val) && !empty($GLOBALS['attributes'][$attr]) &&
$GLOBALS['attributes'][$attr]['type'] == 'address');
}
if ($postal) {
/* Correctly store postal addresses. */
$val = str_replace(array("\r\n", "\r", "\n"), '$', $val);
}
if (!is_array($val)) {
$attributes[$key] = Horde_String::convertCharset($val, 'UTF-8', $this->_params['charset']);
}
}
}
/**
* Returns a list of required attributes.
*
* @param array $objectclasses List of objectclasses that should be
* checked for required attributes.
*
* @return array List of attribute names of the specified objectclasses
* that have been configured as being required.
* @throws Turba_Exception
*/
protected function _checkRequiredAttributes(array $objectclasses)
{
$ldap = new Horde_Ldap($this->_convertParameters($this->_params));
$schema = $ldap->schema();
$retval = array();
foreach ($objectclasses as $oc) {
if (Horde_String::lower($oc) == 'top') {
continue;
}
$required = $schema->must($oc, true);
if (is_array($required)) {
foreach ($required as $v) {
if ($this->_isString($v)) {
$retval[] = Horde_String::lower($v);
}
}
}
}
return $retval;
}
/**
* Checks if an attribute refers to a string.
*
* @param string $attribute An attribute name.
*
* @return boolean True if the specified attribute refers to a string.
*/
protected function _isString($attribute)
{
$syntax = $this->_getSyntax($attribute);
/* Syntaxes we want to allow, i.e. no integers.
* Syntaxes have the form:
* 1.3.6.1.4.1.1466.115.121.1.$n{$y}
* ... where $n is the integer used below and $y is a sizelimit. */
$okSyntax = array(
44 => 1, /* Printable string. */
41 => 1, /* Postal address. */
39 => 1, /* Other mailbox. */
34 => 1, /* Name and optional UID. */
26 => 1, /* IA5 string. */
15 => 1, /* Directory string. */
);
return (preg_match('/^(.*)\.(\d+)\{\d+\}$/', $syntax, $matches) &&
($matches[1] == "1.3.6.1.4.1.1466.115.121.1") &&
isset($okSyntax[$matches[2]]));
}
/**
* Checks if an attribute refers to a Postal Address.
*
* @param string $attribute An attribute name.
*
* @return boolean True if the specified attribute refers to a Postal
* Address.
*/
protected function _isPostalAddress($attribute)
{
/* LDAP postal address syntax is
* 1.3.6.1.4.1.1466.115.121.1.41 */
return ($this->_getSyntax($attribute) == '1.3.6.1.4.1.1466.115.121.1.41');
}
/**
* Returns the syntax of an attribute, if necessary recursively.
*
* @param string $att Attribute name.
*
* @return string Attribute syntax.
* @throws Turba_Exception
*/
protected function _getSyntax($att)
{
$ldap = new Horde_Ldap($this->_convertParameters($this->_params));
$schema = $ldap->schema();
if (!isset($this->_syntaxCache[$att])) {
$attv = $schema->get('attribute', $att);
$this->_syntaxCache[$att] = isset($attv['syntax'])
? $attv['syntax']
: $this->_getSyntax($attv['sup'][0]);
}
return $this->_syntaxCache[$att];
}
/**
* Converts Turba connection parameter so Horde_Ldap parameters.
*
* @param array $in Turba parameters.
*
* @return array Horde_Ldap parameters.
*/
protected function _convertParameters(array $in)
{
$map = array(
'server' => 'hostspec',
'port' => 'port',
'tls' => 'tls',
'version' => 'version',
'root' => 'basedn',
'bind_dn' => 'binddn',
'bind_password' => 'bindpw',
// can both be specified in Turba but only one in Horde_Ldap.
//'objectclass',
//'filter' => 'filter',
'scope' => 'scope',
// charset is always utf-8
//'charset',
// Not yet implemented.
//'deref',
//'referrals',
//'sizelimit',
//'dn',
);
$out = array();
foreach ($in as $key => $value) {
if (isset($map[$key])) {
$out[$map[$key]] = $value;
}
}
return $out;
}
}