You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

560 lines
16 KiB

<?php
/*
** Zabbix
** Copyright (C) 2001-2023 Zabbix SIA
**
** This program is free software; you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation; either version 2 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program; if not, write to the Free Software
** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
**/
class CLdap {
const BIND_NONE = 0;
const BIND_ANONYMOUS = 1;
const BIND_CONFIG_CREDENTIALS = 2;
const BIND_DNSTRING = 3;
const ERR_NONE = 0;
const ERR_PHP_EXTENSION = 1;
const ERR_SERVER_UNAVAILABLE = 2;
const ERR_BIND_FAILED = 3;
const ERR_BIND_ANON_FAILED = 4;
const ERR_USER_NOT_FOUND = 5;
const ERR_OPT_PROTOCOL_FAILED = 10;
const ERR_OPT_TLS_FAILED = 11;
const ERR_OPT_REFERRALS_FAILED = 12;
const ERR_OPT_DEREF_FAILED = 13;
const ERR_BIND_DNSTRING_UNAVAILABLE = 14;
const ERR_QUERY_FAILED = 15;
const DEFAULT_FILTER_USER = '(%{attr}=%{user})';
const DEFAULT_FILTER_GROUP = '(%{groupattr}=%{user})';
const DEFAULT_MEMBERSHIP_ATTRIBUTE = 'memberOf';
/**
* Type of binding made to LDAP server. One of static::BIND_ constant value.
*
* @var int
*/
public $bound;
/**
* @var int
*/
public $error;
/**
* Bind type to use when searching in LDAP tree. One of static::BIND_ constant value.
*
* @var int
*/
public $bind_type;
/**
* Bind DN string, may contain placeholders when BIND_TYPE_DNSTRING is detected.
*
* @var string
*/
protected $bind_dn;
/**
* @var array $cnf LDAP connection settings.
*/
protected $cnf = [
'host' => '',
'port' => '',
'bind_dn' => '',
'bind_password' => '',
'base_dn' => '',
'search_attribute' => '',
'search_filter' => '',
'group_basedn' => '',
'group_name' => '',
'group_member' => '',
'group_filter' => '',
'group_membership' => '',
'referrals' => 0,
'version' => 3,
'start_tls' => ZBX_AUTH_START_TLS_OFF,
'deref' => null
];
/**
* Placeholders with value used for building bind or search filter query.
* Key is placeholder name, %{attr} and value is placeholder value to replace to.
*
* @var array
*/
protected $placeholders = [];
/**
* Estabilished LDAP connection resource, for PHP8.1.0+ LDAP\Connection class instance.
*
* @var bool|resource|LDAP\Connection
*/
protected $ds;
public function __construct(array $config = []) {
$this->ds = false;
$this->bound = static::BIND_NONE;
$this->error = static::ERR_NONE;
$this->cnf = zbx_array_merge($this->cnf, $config);
if ($this->cnf['search_filter'] === '') {
$this->cnf['search_filter'] = static::DEFAULT_FILTER_USER;
}
if ($this->cnf['group_filter'] === '') {
$this->cnf['group_filter'] = static::DEFAULT_FILTER_GROUP;
}
if ($this->cnf['group_membership'] === '') {
$this->cnf['group_membership'] = static::DEFAULT_MEMBERSHIP_ATTRIBUTE;
}
$this->initBindAttributes();
$this->error = $this->moduleEnabled() ? static::ERR_NONE : static::ERR_PHP_EXTENSION;
}
/**
* Check is the PHP extension enabled.
*
* @return bool
*/
public function moduleEnabled(): bool {
return function_exists('ldap_connect') && function_exists('ldap_set_option') && function_exists('ldap_bind')
&& function_exists('ldap_search') && function_exists('ldap_get_entries')
&& function_exists('ldap_free_result') && function_exists('ldap_start_tls');
}
/**
* Initialize connection to LDAP server. Set LDAP connection options defined in configuration when required.
* Return true on success.
*
* @return bool
*/
public function connect(): bool {
$this->error = static::ERR_NONE;
if ($this->ds !== false) {
return true;
}
$this->bound = static::BIND_NONE;
if (!$this->ds = @ldap_connect($this->cnf['host'], $this->cnf['port'])) {
$this->error = static::ERR_SERVER_UNAVAILABLE;
return false;
}
// Set protocol version and dependent options.
if ($this->cnf['version']) {
if (!@ldap_set_option($this->ds, LDAP_OPT_PROTOCOL_VERSION, $this->cnf['version'])) {
$this->error = static::ERR_OPT_PROTOCOL_FAILED;
}
elseif ($this->cnf['version'] == 3) {
if ($this->cnf['start_tls'] && !@ldap_start_tls($this->ds)) {
$this->error = static::ERR_OPT_TLS_FAILED;
}
if (!$this->cnf['referrals']
&& !@ldap_set_option($this->ds, LDAP_OPT_REFERRALS, $this->cnf['referrals'])) {
$this->error = static::ERR_OPT_REFERRALS_FAILED;
}
}
}
if (isset($this->cnf['deref']) && !@ldap_set_option($this->ds, LDAP_OPT_DEREF, $this->cnf['deref'])) {
$this->error = static::ERR_OPT_DEREF_FAILED;
}
return $this->error == static::ERR_NONE;
}
/**
* Bind to LDAP server. Set $this->bound to type of successful binding.
* Arguments $user and $password are required when bind type BIND_DNSTRING is set.
*
* Bind types:
* BIND_CONFIG_CREDENTIALS - Special configuration user is used to bind and search.
* BIND_ANONYMOUS - Anonymous user is used to bind and search.
* BIND_DNSTRING - Logging in user is used to bind and search.
*
* Both arguments $user and $password are required for bind type BIND_DNSTRING only.
*
* @param string $user User name value.
* @param string $password Password value.
*
* @return bool
*/
public function bind($user = null, $password = null): bool {
$this->bound = static::BIND_NONE;
if ($this->bind_type == static::BIND_ANONYMOUS) {
if (!@ldap_bind($this->ds)) {
$this->error = static::ERR_BIND_ANON_FAILED;
return false;
}
$this->bound = static::BIND_ANONYMOUS;
return true;
}
$dn = $this->bind_dn;
$dn_password = $this->cnf['bind_password'];
if ($this->bind_type == static::BIND_DNSTRING) {
if ($user === null || $password === null) {
$this->error = static::ERR_BIND_DNSTRING_UNAVAILABLE;
return false;
}
$dn = $this->makeFilter($this->bind_dn, ['%{user}' => $user], LDAP_ESCAPE_DN);
$dn_password = $password;
}
if (!@ldap_bind($this->ds, $dn, $dn_password)) {
$this->error = static::ERR_BIND_FAILED;
return false;
}
$this->bound = $this->bind_type;
return true;
}
/**
* Check validity of user credentials. Do not allow to check credentials when password is empty.
*
* @param string $user User name attribute value.
* @param string $pass User password attribute value.
*
* @return bool
*/
public function checkCredentials(string $user, string $pass): bool {
if (!$pass) {
$this->error = static::ERR_USER_NOT_FOUND;
return false;
}
if (!$this->connect() || !$this->bind($user, $pass)) {
return false;
}
if (!$this->bind($user, $pass)) {
if ($this->bind_type == static::BIND_DNSTRING) {
$this->error = static::ERR_USER_NOT_FOUND;
}
return false;
}
if ($this->bound == static::BIND_ANONYMOUS || $this->bound == static::BIND_CONFIG_CREDENTIALS) {
// No need for user default attributes, only 'dn'.
$users = $this->search($this->cnf['base_dn'], $this->cnf['search_filter'], ['%{user}' => $user], ['dn']);
if ($users['count'] != 1) {
// Multiple users matched criteria.
$this->error = static::ERR_USER_NOT_FOUND;
return false;
}
if (!array_key_exists('dn', $users[0]) || !@ldap_bind($this->ds, $users[0]['dn'], $pass)) {
$this->error = static::ERR_USER_NOT_FOUND;
return false;
}
}
return true;
}
/**
* Get array of user groups. Is not available for bind type BIND_DNSTRING if password is not supplied.
*
* @param array $attributes Array of group attributes to return for every group.
* @param string $user User username value.
* @param string $password User password value, is required only for BIND_DNSTRING.
*
* @return array Array of arrays of matched group.
*/
public function getGroupAttributes(array $attributes, string $user, $password = null): array {
if ($attributes == [] || !$this->connect() || !$this->bind($user, $password)) {
return [];
}
$placeholders = [
'%{user}' => $user,
'%{groupattr}' => $this->cnf['group_member']
];
$results = $this->search($this->cnf['group_basedn'], $this->cnf['group_filter'], $placeholders, $attributes);
$groups = [];
if ($results['count'] == 0) {
return $groups;
}
$attributes = array_flip(array_map('strtolower', $attributes));
for ($j = 0; $j < $results['count']; $j++) {
$result = $results[$j];
$result_attributes = array_intersect_key($result, $attributes);
if (!$result_attributes) {
continue;
}
foreach ($result_attributes as &$value) {
$value = $value[0];
}
unset($value);
$groups[] = $result_attributes;
}
return $groups;
}
/**
* Get user data with specified attributes. Not available for bind type BIND_DNSTRING if password is not supplied.
* Mapped attribute names will be set to lower case.
*
* @param array $attributes Array of LDAP tree attributes names to be returned.
* @param string $user User to search attributes for.
* @param string $password (optional) User password, required only for BIND_DNSTRING.
*
* @return array Associative array of user attributes.
*/
public function getUserAttributes(array $attributes, string $user, $password = null): array {
if ($attributes == [] || !$this->connect() || !$this->bind($user, $password)) {
return [];
}
$placeholders = ['%{user}' => $user];
$results = $this->search($this->cnf['base_dn'], $this->cnf['search_filter'], $placeholders, $attributes);
$user = [];
if ($results['count'] == 0) {
return $user;
}
$results = $results[0];
if ($results['count'] == 0) {
return $user;
}
$group_key = strtolower($this->cnf['group_membership']);
$group_name_key = strtolower($this->cnf['group_name']);
for ($i = 0; $i < $results['count']; $i++) {
$key = $results[$i];
$user[$key] = $results[$i] === $group_key
? $this->getGroupPatternsFromDns($group_name_key, $results[$key])
: $results[$key][0];
}
return $user;
}
/**
* Extract the group pattern from given DN strings.
* For DN string "cn=zabbix-admins,ou=Group,dc=example,dc=org" and the "Group name attribute" set to "cn",
* the string "zabbix-admins" will be stored to the $groups array.
*
* @param string $group_name_key Lower case group name attribute for which to extract value from RDN.
* @param array $group_dns Array of DN strings.
*
* @return array Strings with the extracted groups, if any.
*/
public function getGroupPatternsFromDns(string $group_name_key, array $group_dns): array {
$groups = [];
foreach ($group_dns as $group_dn) {
$rdns = ldap_explode_dn($group_dn, 0);
if (!is_array($rdns)) {
continue;
}
foreach ($rdns as $rdn) {
if (strpos($rdn, '=') === false) {
continue;
}
/*
* For multi-value RDNs $rdn_key will be set to key of first key-value pair, the rest of string as value.
* For example for RDN "cn=John Doe+mail=jdoe@example.com" $rdn_value is "John Doe+mail=jdoe@example.com".
*/
[$rdn_key, $rdn_value] = explode('=', $rdn, 2);
if (strtolower($rdn_key) !== $group_name_key) {
continue;
}
// Convert escaped charcodes, f.e. 'Universit\C4\81te' => 'Universitāte'.
$groups[] = preg_replace_callback('/\\\\([0-9A-F]{2})/i', function (array $match): string {
return chr(hexdec($match[1]));
}, $rdn_value);
}
}
return $groups;
}
/**
* Setter for additional placeholders supported in bind or search query.
*
* @param array $placeholders Associative array where key is placeholder in form %{name}.
*/
public function setQueryPlaceholders(array $placeholders) {
$this->placeholders = $placeholders;
}
/**
* Return user data with medias, groups, roleid and user attributes matched from LDAP user data according
* provisioning options. All attributes are matched in case insensitive way.
*
* @param CProvisioning $provisioning Provisioning class instance.
* @param string $username Username of user to get provisioned data for.
*
* @return array
*/
public function getProvisionedData(CProvisioning $provisioning, string $username): array {
$ldap_groups = [];
$user = [
'medias' => [],
'usrgrps' => [],
'roleid' => 0
];
$config = $provisioning->getIdpConfig();
$user_attributes = $provisioning->getUserIdpAttributes();
$idp_user = $this->getUserAttributes($user_attributes, $username);
$user = $provisioning->getUserAttributes($idp_user, false);
$user['medias'] = $provisioning->getUserMedias($idp_user, false);
if ($config['group_membership'] !== '') {
$group_key = strtolower($config['group_membership']);
if (array_key_exists($group_key, $idp_user) && is_array($idp_user[$group_key])) {
$ldap_groups = $idp_user[$group_key];
}
}
else if ($config['group_filter'] !== '') {
$user_ref_attr = strtolower($config['user_ref_attr']);
if ($user_ref_attr !== '' && array_key_exists($user_ref_attr, $idp_user)) {
$this->setQueryPlaceholders(['%{ref}' => $idp_user[$user_ref_attr]]);
}
$group_attributes = $provisioning->getGroupIdpAttributes();
$ldap_groups = $this->getGroupAttributes($group_attributes, $username);
$ldap_groups = array_column($ldap_groups, strtolower($config['group_name']));
}
$user = array_merge($user, $provisioning->getUserGroupsAndRole($ldap_groups));
return $user;
}
/**
* Setup bind attributes according LDAP configuration.
*/
protected function initBindAttributes() {
$this->bind_type = static::BIND_ANONYMOUS;
if ($this->cnf['bind_dn'] !== '' && $this->cnf['bind_password'] !== '') {
$this->bind_type = static::BIND_CONFIG_CREDENTIALS;
$this->bind_dn = $this->cnf['bind_dn'];
}
elseif ($this->cnf['bind_dn'] !== '' && $this->cnf['search_filter'] !== static::DEFAULT_FILTER_USER) {
$this->bind_type = static::BIND_DNSTRING;
$this->bind_dn = $this->cnf['bind_dn'];
}
elseif (strpos($this->cnf['base_dn'], '%{user}') !== false) {
$this->bind_type = static::BIND_DNSTRING;
$this->bind_dn = $this->cnf['base_dn'];
}
}
/**
* Replaces placeholders found in string with their data.
*
* @param string $filter Filter string where to replace placeholders.
* @param array $placeholders Associative array for replacement in $filter string.
* Placeholders %{attr}, %{host} will be added by default,
* array key should be in form %{placeholder_key_value}.
* @param int escape_context Resulting string usage context:
* LDAP_ESCAPE_FILTER - use result string as filter argument of ldap_search.
* LDAP_ESCAPE_DN - use result string as base dn.
*
* @return string Filter string with replaced placeholders in it.
*/
protected function makeFilter(string $filter, array $placeholders, $escape_context): string {
$replace_pairs = $placeholders + [
'%{attr}' => $this->cnf['search_attribute'],
'%{host}' => $this->cnf['host']
];
foreach ($replace_pairs as &$value) {
$value = ldap_escape($value, '', $escape_context);
}
unset($value);
return strtr($filter, $replace_pairs);
}
/**
* Search for entry in LDAP tree for specified $dn and $filter.
* Requested attributes in resulting array, will be set in lowercase.
*
* @param string $dn DN string value, supports placeholders.
* @param string $filter Filter string, supports placeholders.
* @param array $placeholders Associative array of placeholders for creating base and filter for ldap_search.
* @param array $attributes List of attributes to be returned.
*
* @return array Array of ldap_get_entries.
*/
protected function search(string $dn, string $filter, array $placeholders, array $attributes): array {
$this->error = static::ERR_NONE;
$base = $this->makeFilter($dn, $placeholders, LDAP_ESCAPE_DN);
$filter = $this->makeFilter($filter, $placeholders + $this->placeholders, LDAP_ESCAPE_FILTER);
$resource = @ldap_search($this->ds, $base, $filter, $attributes);
$results = false;
if ($resource !== false) {
$results = @ldap_get_entries($this->ds, $resource);
ldap_free_result($resource);
}
if ($resource === false || $results === false) {
$this->error = static::ERR_QUERY_FAILED;
return ['count' => 0];
}
return $results;
}
}