From 9520502ecea717e48f0b57b5fbc2e42f8d858b7a Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Wed, 15 Mar 2023 02:33:27 +0100 Subject: [PATCH] Add basic stuff to handle LDAP connection and running search with LDAP entry object abstraction --- phpstan.neon | 2 + src/Entry.php | 144 +++++++++ src/Filter/FilterException.php | 2 +- src/Ldap.php | 533 +++++++++++++++++++++++++++++++++ src/LdapException.php | 5 + 5 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 src/Entry.php create mode 100644 src/Ldap.php create mode 100644 src/LdapException.php diff --git a/phpstan.neon b/phpstan.neon index 3c80b36..6e30a4e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,6 +4,8 @@ parameters: - src - tests treatPhpDocTypesAsCertain: false + universalObjectCratesClasses: + - EesyLDAP\Entry ignoreErrors: - message: "#Method .*::test.*\\(\\) has no return type specified\\.#" diff --git a/src/Entry.php b/src/Entry.php new file mode 100644 index 0000000..8c2cf69 --- /dev/null +++ b/src/Entry.php @@ -0,0 +1,144 @@ +> $changes + */ +class Entry { + + /** + * Entry DN + * @var string|false + */ + protected $dn = false; + + /** + * Attributes data + * @var array> + */ + protected $data = array(); + + /** + * Changed data + * @var array> + */ + protected $changes = array(); + + /** + * LDAP connection + * @var Ldap|null + */ + protected $ldap = null; + + /** + * Constructor + * @param string|null $dn Object DN (optional, default=null) + * @param array> $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 $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 + } +} diff --git a/src/Filter/FilterException.php b/src/Filter/FilterException.php index bcc8b15..a1fc575 100644 --- a/src/Filter/FilterException.php +++ b/src/Filter/FilterException.php @@ -2,4 +2,4 @@ namespace EesyLDAP\Filter; -class FilterException extends \Exception {} +class FilterException extends \EesyLDAP\LdapException {} diff --git a/src/Ldap.php b/src/Ldap.php new file mode 100644 index 0000000..256f17c --- /dev/null +++ b/src/Ldap.php @@ -0,0 +1,533 @@ + $hosts + * @property-read array $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 + */ + protected $config = array(); + + /** + * Default configuration + * @var array + */ + 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 + */ + 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 + */ + protected static $search_function_by_scope = array( + 'one' => 'ldap_list', + 'base' => 'ldap_read', + 'sub' => 'ldap_search', + ); + + /** + * Constructor + * @param array|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 $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 $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 $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|null $params Other optional parameters of the search (optional, default: null == use config) + * @param bool|null $raise See error() (optional, default: null) + * @return array|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|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; + } +} diff --git a/src/LdapException.php b/src/LdapException.php new file mode 100644 index 0000000..e1164c2 --- /dev/null +++ b/src/LdapException.php @@ -0,0 +1,5 @@ +