Add basic stuff to handle LDAP connection and running search with LDAP entry object abstraction

This commit is contained in:
Benjamin Renard 2023-03-15 02:33:27 +01:00
parent cd303a3a12
commit 9520502ece
5 changed files with 685 additions and 1 deletions

View file

@ -4,6 +4,8 @@ parameters:
- src
- tests
treatPhpDocTypesAsCertain: false
universalObjectCratesClasses:
- EesyLDAP\Entry
ignoreErrors:
-
message: "#Method .*::test.*\\(\\) has no return type specified\\.#"

144
src/Entry.php Normal file
View file

@ -0,0 +1,144 @@
<?php
namespace EesyLDAP;
/**
* @property string|false $dn
* @property-read array<string,array<string>> $changes
*/
class Entry {
/**
* Entry DN
* @var string|false
*/
protected $dn = false;
/**
* Attributes data
* @var array<string,array<string>>
*/
protected $data = array();
/**
* Changed data
* @var array<string,array<string>>
*/
protected $changes = array();
/**
* LDAP connection
* @var Ldap|null
*/
protected $ldap = null;
/**
* Constructor
* @param string|null $dn Object DN (optional, default=null)
* @param array<string,array<string>> $data Object attributes data (optional, default=null)
* @param Ldap|null $ldap The LDAP connection (optional, default=null)
* @return void
*/
public function __construct($dn=null, $data=null, $ldap=null) {
if (is_string($dn))
$this -> dn = $dn;
if (is_array($data))
foreach($data as $attr => $value)
$this -> data[mb_strtolower($attr)] = $data[$attr];
$this -> ldap = $ldap;
}
/**
* Get entry key
* @param string $key
* @return mixed
*/
public function __get($key) {
switch ($key) {
case 'dn':
return $this -> dn;
case 'changes':
return $this -> changes;
default:
return $this -> get_values($key);
}
}
/**
* Set entry key value
* @param string $key
* @param mixed $value
* @return void
*/
public function __set($key, $value) {
switch ($key) {
case 'dn':
if (is_string($value) || $value === false)
$this -> dn = $value;
else
$this -> error('Unexcepted DN value provided: must be a string or false');
default:
$key = mb_strtolower($key);
if (is_null($value))
$value = array();
if (!is_array($value))
$value = array($value);
$this -> changes[$key] = array();
foreach($value as $v)
$this -> changes[$key][] = strval($v);
}
}
/**
* Check if entry key is set
* @param string $key
* @return bool
*/
public function __isset($key) {
return (
in_array($key, array('dn', 'changes'))
|| array_key_exists($key, $this -> data)
|| array_key_exists($key, $this -> changes)
);
}
/**
* Get an attribute values
* @param string $attr The expected attribute name
* @param mixed $default The default value if attribute is not set
* (optional, default: null or array() if $all_values is True)
* @param bool $all_values If True, return all the attribute values, otherwise return only the
* first one (optional, default: true)
* @return mixed
*/
public function get_values($attr, $default=null, $all_values=true) {
$attr = mb_strtolower($attr);
$values = false;
if (array_key_exists($attr, $this -> changes))
$values = &$this -> changes[$attr];
elseif (array_key_exists($attr, $this -> data))
$values = &$this -> data[$attr];
if (!$values)
return is_null($default) && $all_values?array():$default;
return $all_values?$values:$values[0];
}
/**
* Log and eventually raise an error
* @param string $error The error message
* @param array<mixed> $extra_args If passed, will be used to compute the error message using sprintf
* @return false
* @throws LdapException
*/
protected function error($error, ...$extra_args) {
if ($extra_args)
$error = call_user_func_array('sprintf', array_merge(array($error), $extra_args));
// Note: sprintf always return string
if ($this->ldap)
// @phpstan-ignore-next-line
return $this->ldap->error($error);
// @phpstan-ignore-next-line
error_log($error);
throw new LdapException($error); // @phpstan-ignore-line
}
}

View file

@ -2,4 +2,4 @@
namespace EesyLDAP\Filter;
class FilterException extends \Exception {}
class FilterException extends \EesyLDAP\LdapException {}

533
src/Ldap.php Normal file
View file

@ -0,0 +1,533 @@
<?php
namespace EesyLDAP;
/**
* @property-read array<string> $hosts
* @property-read array<string> $host
* @property-read int $port
* @property-read int $version
* @property-read bool $starttls
* @property-read string|null $bind_dn
* @property-read string|null $binddn
* @property-read string|null $dn
* @property-read string|null $bind_password
* @property-read string|null $bindpw
* @property-read string|null $password
* @property-read string|null $pwd
* @property-read string|null $basedn
* @property-read array $options
* @property-read array $required_options
* @property-read string $filter
* @property-read string $scope
* @property-read bool $raise_on_error
*/
class Ldap {
/**
* Configuration
* @var array<string,mixed>
*/
protected $config = array();
/**
* Default configuration
* @var array<string,mixed>
*/
protected static $default_config = array(
// LDAP server name to connect to. You can provide several hosts in an array in which case the
// hosts are tried from left to right.
'hosts' => array('localhost'),
// Port on the server
'port' => 389,
// LDAP protocol version
'version' => 3,
// TLS is started after connecting
'starttls' => false,
// The distinguished name to bind as (username). If you don't supply this, an anonymous bind
// will be established.
'bind_dn' => null,
// Password for the binddn. If the credentials are wrong, the bind will fail server-side and an
// anonymous bind will be established instead. An empty bindpw string requests an
// unauthenticated bind. This can cause security problems in your application, if you rely on a
// bind to make security decisions (see RFC-4513, section 6.3.1).
'bind_password' => null,
// LDAP base name (root directory)
'basedn' => null,
// Array of additional ldap options as key-value pairs
'options' => array(),
// Required options: consider LDAP link as bad if the required option could not be set.
// Possibles values:
// - array of expected options
// - True to consider all options as required (default)
// - False to consider all options as optional
'required_options' => true,
// Default search filter (string or preferably \EesyLDAP\Filter object).
'filter' => '(objectClass=*)',
// Default search scope
'scope' => 'sub',
// Raise LdapException on error
'raise_on_error' => true,
);
/**
* Configuration parameter aliases
* @var array<string,string>
*/
protected static $config_aliases = array(
'host' => 'hosts',
'binddn' => 'bind_dn',
'dn' => 'bind_dn',
'bindpw' => 'bind_password',
'password' => 'bind_password',
'pwd' => 'bind_password',
);
/**
* The LDAP connection
* @var resource|false
*/
protected $_link = false;
/**
* Scopes associated with their search function
* @var array<string,callable>
*/
protected static $search_function_by_scope = array(
'one' => 'ldap_list',
'base' => 'ldap_read',
'sub' => 'ldap_search',
);
/**
* Constructor
* @param array<string,mixed>|null $config Configuration (see $default_config for expected content)
* @param bool $connect If true, start LDAP connection (optional, default=true)
* @return void
*/
public function __construct($config=null, $connect=true) {
if (is_array($config))
$this -> set_config($config);
if ($connect)
$this -> connect();
}
/**
* Set configuration
* @param array<string,mixed> $config
* @return bool
*/
public function set_config($config) {
if (!is_array($config))
return $this->error('Expect $config to be an array');
foreach($config as $key => $value) {
if (array_key_exists($key, self :: $config_aliases))
$key = self :: $config_aliases[$key];
if (!array_key_exists($key, self :: $default_config))
return $this -> error("Invalid configuration parameter '$key'");
switch ($key) {
case 'hosts':
if (is_string($value)) {
$hosts = array();
foreach(explode(',', $value) as $host) {
$host = trim($host);
if (!$host)
return $this -> error("Empty host found in configuration!");
$hosts[] = $host;
}
$value = $hosts;
}
else if (!is_array($value)) {
$this -> error(
"Invalid hosts found in configuration: expect to be an array of LDAP host URI (or ".
"hostname) or a comma separated list of LDAP host URI (or hostname)."
);
continue 2;
}
break;
case 'port':
case 'version':
$value = intval($value);
break;
case 'starttls':
$value = boolval($value);
break;
case 'options':
if (!is_array($value)) {
$this -> error(
"Invalid options found in configuration: expect to be an array."
);
continue 2;
}
break;
case 'required_options':
if (!is_array($value) && !is_bool($value)) {
$this -> error(
"Invalid required_options found in configuration: expect to be an array or a boolean."
);
continue 2;
}
break;
case 'filter':
if (is_string($value))
$value = new Filter($value);
elseif (!$value instanceof Filter) {
$this -> error(
"Invalid filter found in configuration: expect to be a string or a \EesyLDAP\Filter ".
"object."
);
continue 2;
}
break;
case 'scope':
if (!is_string($value) || !array_key_exists($value, self :: $search_function_by_scope)){
$this -> error(
"Invalid scope '%s' found in configuration: expect to be one of the following: %s",
null,
is_string($value)?$value:gettype($value),
implode(', ', array_keys(self :: $search_function_by_scope))
);
continue 2;
}
}
$this -> config[$key] = $value;
}
return true;
}
/**
* Checks if phps ldap-extension is loaded
*
* Note: If it is not loaded, it tries to load it manually using PHPs dl().
* It knows both windows-dll and *nix-so.
*
* @param bool|null $raise See error() (optional, default: null)
* @return bool
*/
public function check_ldap_extension($raise=null) {
if (!extension_loaded('ldap') && !@dl('ldap.' . PHP_SHLIB_SUFFIX)) {
return $this -> error(
"It seems that you do not have the ldap-extension installed. Please install it before ".
"using the EesyLDAP.",
$raise
);
}
return true;
}
/**
* Start connection on configured LDAP hosts
* @param bool|null $raise See error() (optional, default: null)
* @param string|null $bind_dn Bind DN (optional, default: null = use configuration)
* @param string|null $bind_password Bind password (optional, default: null = use configuration)
* @return bool
* @throws LdapException
*/
public function connect($raise=null, $bind_dn=null, $bind_password=null) {
if ($this -> _link)
return true;
if (!self :: check_ldap_extension($raise))
return false;
foreach($this -> hosts as $host) {
$this->_link = @ldap_connect($host, $this->port);
if ($this->_link === false) {
$this -> error("Fail to connect on $host", false);
continue;
}
if ($this->starttls) {
if (@ldap_start_tls($this->_link) === false) {
$this -> close();
$this -> error("Fail to start TLS on $host", false);
continue;
}
}
if ($this->version && !$this->set_option("LDAP_OPT_PROTOCOL_VERSION", $this->version, false)) {
$this -> close();
$this -> error(
"Fail to switch to LDAP protocol version %d on %s", false, $this->version, $host);
continue;
}
if (!$this -> bind($bind_dn, $bind_password)) {
$this -> close();
$this -> error(
"Fail to bind on %s", false);
continue;
}
// Set LDAP parameters, now we know we have a valid connection.
foreach ($this->options as $option => $value) {
if (!$this -> set_option($option, $value, false)) {
if (in_array($option, $this -> required_options)) {
$this -> close();
$this -> error('Fail to set option %s on %s', false, $option, $host);
continue;
}
}
}
return true;
}
return $this -> error('Fail to connect on configured host(s)', $raise);
}
/**
* Bind on LDAP link
* Note: if not already connected, start connection with the specified credentials.
* @param string|null $dn Bind DN (optional, default: null = use configuration)
* @param string|null $password Bind password (optional, default: null = use configuration)
* @param bool|null $raise See error() (optional, default: null)
* @return bool
*/
public function bind($dn=null, $password=null, $raise=null) {
if (is_null($dn)) $dn = $this -> bind_dn;
if (is_null($password)) $password = $this -> bind_password;
if (!$this -> _link) return $this -> connect($raise, $dn, $password);
if (@ldap_bind($this->_link, $dn, $password) === false)
return $this -> log_error($dn?"Fail to bind as $dn":"Fail to anonymously bind", $raise);
return true;
}
/**
* Set an LDAP option
*
* @param string $option Option to set
* @param mixed $value Value to set Option to
* @param bool|null $raise See error() (optional, default: null)
*
* @access public
* @return bool
* @throws LdapException
*/
public function set_option($option, $value, $raise=null) {
if (!$this->_link)
return $this->error("Could not set LDAP option: No LDAP connection", $raise);
if (!is_string($option) || !defined($option) || strpos($option, 'LDAP_') !== 0)
return $this->error("Unkown Option requested", $raise);
// @phpstan-ignore-next-line
if (@ldap_set_option($this->_link, constant($option), $value))
return true;
return $this -> log_error("Fail to set option $option", $raise);
}
/**
* Close LDAP link (if established)
* @return void
*/
public function close() {
if ($this->_link)
@ldap_close($this->_link);
}
/**
* Log the last error occured on LDAP link
* @param string $prefix
* @param bool|null $raise See error() (optional, default: null)
* @param array<mixed> $extra_args If passed, will be used to compute the prefix message using sprintf
* @return false
*/
protected function log_error($prefix, $raise=null, ...$extra_args) {
if ($extra_args)
$prefix = call_user_func_array('sprintf', array_merge(array($prefix), $extra_args));
$errno = $this->_link?@ldap_errno($this->_link):false;
if (!$errno)
return $this -> error("%s: unkown error", $raise, $prefix);
$err = @ldap_err2str($errno);
if (!$err)
return $this -> error("%s: error #%s", $raise, $errno);
return $this -> error("%s: %s (#%s)", $raise, $err, $errno);
}
/**
* Log and eventually raise an error
* @param string $error The error message
* @param bool|null $raise If true, raise an LdapException, otherwise only log it and return false
* @param array<mixed> $extra_args If passed, will be used to compute the error message using sprintf
* @return false
* @throws LdapException
*/
public function error($error, $raise=null, ...$extra_args) {
if ($extra_args)
$error = call_user_func_array('sprintf', array_merge(array($error), $extra_args));
// Note: sprintf always return string
// @phpstan-ignore-next-line
error_log($error);
if (is_null($raise))
$raise = $this -> raise_on_error;
if ($raise)
throw new LdapException($error); // @phpstan-ignore-line
return false;
}
/**
* Get property
* @param string $key
* @return mixed
* @throws LdapException
*/
public function __get($key) {
if (
array_key_exists($key, self :: $default_config)
|| array_key_exists($key, self :: $config_aliases)
)
return $this -> get_config($key);
switch ($key) {
case 'config_aliases':
return self :: $config_aliases;
case 'default_config':
return self :: $default_config;
}
return $this -> error("Invalid property '$key' requested");
}
/**
* Get configuration parameter
* @param string $key
* @return mixed
* @throws LdapException
*/
public function get_config($key) {
if (array_key_exists($key, self :: $config_aliases))
$key = self :: $config_aliases[$key];
$value = null;
if (array_key_exists($key, $this -> config))
$value = $this -> config[$key];
elseif (array_key_exists($key, self :: $default_config))
$value = self :: $default_config[$key];
else
return $this -> error("Invalid configuration parameter '$key'");
switch ($key) {
case 'required_options':
if (is_array($value))
return $value;
if ($value === false)
return array();
return array_keys($this->options);
case 'filter':
if (is_string($value))
return Filter :: parse($value);
return $value;
}
return $value;
}
/**
* Search on LDAP directory
*
* Optional expected parameters are:
* - scope: The scope which will be used for searching.
* Possible values:
* - base: Just one entry (the specified base DN, see $base)
* - sub: The whole subtree
* - one: Immediately below the specified base DN (see $base)
* - sizelimit: Limit the number of entries returned (default: 0 = unlimited),
* - timelimit: Limit the time spent for searching in seconds (default: 0 = unlimited),
* - attrsonly: If true, the search will only return the attribute names,
* - attributes: Array of attribute names, which the entries in the expected result should contain.
* It is good practice to limit this to just the ones you need.
*
* @param string|null $base Base DN of the search (optional, default: null == use config)
* @param string|null $filter Filter of the search (optional, default: null == use config)
* @param array<string,mixed>|null $params Other optional parameters of the search (optional, default: null == use config)
* @param bool|null $raise See error() (optional, default: null)
* @return array<string|Entry>|false Array of Entry object with DN as key, or False in case of error
* @throws LdapException
*/
public function search($base=null, $filter=null, $params=null, $raise=null) {
if (!$this -> _link)
return $this -> error("Can't search: no LDAP link");
if (is_null($base))
$base = $this->basedn;
elseif ($base instanceof Entry)
$base = $base->dn?$base->dn:null;
if (is_null($filter)) $filter = $this->filter;
if ($filter instanceof Filter)
$filter = $filter->as_string(); // convert Filter object as string
// Adjust search function to expected scope
$scope = self :: get_param($params, 'scope', $this->scope, 'string');
if (!is_string($scope) || !array_key_exists($scope, self :: $search_function_by_scope))
return $this -> error("Invalid scope '%s' specified", $raise, $scope);
// Run the search
$search = @call_user_func(
self :: $search_function_by_scope[$scope],
$this->_link,
$base,
$filter,
self :: get_param($params, 'attributes', array(), 'array'),
self :: get_param($params, 'attrsonly', false, 'bool')?1:0,
self :: get_param($params, 'sizelimit', 0, 'int'),
self :: get_param($params, 'timelimit', 0, 'int')
);
if (!$search)
return $this -> log_error(
"Error occured searching on base '%s' with filter '%s'", $raise, $base, $filter);
// @phpstan-ignore-next-line
$result = ldap_get_entries($this->_link, $search);
if (!is_array($result))
return $this -> log_error(
"Error occured retreiving the result of the search on base '%s' with filter '%s'",
$raise, $base, $filter
);
$entries = array();
for ($i=0; $i<$result['count']; $i++) {
$dn = $result[$i]['dn'];
$attrs = array();
for($j=0; $j<$result[$i]['count']; $j++) {
$attr = $result[$i][$j];
$attrs[$attr] = array();
for($k=0; $k<$result[$i][$attr]['count']; $k++)
$attrs[$attr][$k] = $result[$i][$attr][$k];
}
// @phpstan-ignore-next-line
$entries[$dn] = new Entry($dn, $attrs);
}
return $entries;
}
/**
* Get parameter value
* @param array<string,mixed>|null $params Array of provided parameter values
* @param string $key Expected parameter key
* @param mixed $default Default value if parameter if not set (optional, default: null)
* @param string|null $cast Expected type of value: if provided, the parameter value will be casted
* as the expected type (optional, default: null == no cast)
* @return mixed
*/
protected static function get_param($params, $key, $default=null, $cast=null) {
if (!is_array($params) || !array_key_exists($key, $params))
return $default;
$value = $params[$key];
switch($cast) {
case 'bool':
case 'boolean':
return boolval($value);
case 'int':
case 'integer':
return intval($value);
case 'float':
return floatval($value);
case 'str':
case 'string':
return strval($value);
case 'array':
if (is_array($value))
return $value;
if (is_null($value))
return array();
return array($value);
}
return $value;
}
}

5
src/LdapException.php Normal file
View file

@ -0,0 +1,5 @@
<?php
namespace EesyLDAP;
class LdapException extends \Exception {}