diff options
Diffstat (limited to 'library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php')
-rw-r--r-- | library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php | 944 |
1 files changed, 944 insertions, 0 deletions
diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php new file mode 100644 index 0000000..54ccaa9 --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php @@ -0,0 +1,944 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Exception; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\LdapUserBackend; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; +use Icinga\Protocol\Ldap\LdapException; +use Icinga\Protocol\Ldap\LdapUtils; +use Icinga\Repository\LdapRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\User; + +class LdapUserGroupBackend extends LdapRepository implements Inspectable, UserGroupBackendInterface +{ + /** + * The user backend being associated with this user group backend + * + * @var LdapUserBackend + */ + protected $userBackend; + + /** + * The base DN to use for a user query + * + * @var string + */ + protected $userBaseDn; + + /** + * The base DN to use for a group query + * + * @var string + */ + protected $groupBaseDn; + + /** + * The objectClass where look for users + * + * @var string + */ + protected $userClass; + + /** + * The objectClass where look for groups + * + * @var string + */ + protected $groupClass; + + /** + * The attribute name where to find a user's name + * + * @var string + */ + protected $userNameAttribute; + + /** + * The attribute name where to find a group's name + * + * @var string + */ + protected $groupNameAttribute; + + /** + * The attribute name where to find a group's member + * + * @var string + */ + protected $groupMemberAttribute; + + /** + * Whether the attribute name where to find a group's member holds ambiguous values + * + * @var bool + */ + protected $ambiguousMemberAttribute; + + /** + * The custom LDAP filter to apply on a user query + * + * @var string + */ + protected $userFilter; + + /** + * The custom LDAP filter to apply on a group query + * + * @var string + */ + protected $groupFilter; + + /** + * ActiveDirectory nested group on the user? + * + * @var bool + */ + protected $nestedGroupSearch; + + /** + * The domain the backend is responsible for + * + * @var string + */ + protected $domain; + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('group', 'user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('group', 'user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'group_name' => array( + 'order' => 'asc' + ) + ); + + /** + * Set the user backend to be associated with this user group backend + * + * @param LdapUserBackend $backend + * + * @return $this + */ + public function setUserBackend(LdapUserBackend $backend) + { + $this->userBackend = $backend; + return $this; + } + + /** + * Return the user backend being associated with this user group backend + * + * @return LdapUserBackend + */ + public function getUserBackend() + { + return $this->userBackend; + } + + /** + * Set the base DN to use for a user query + * + * @param string $baseDn + * + * @return $this + */ + public function setUserBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->userBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a user query + * + * @return string + */ + public function getUserBaseDn() + { + return $this->userBaseDn; + } + + /** + * Set the base DN to use for a group query + * + * @param string $baseDn + * + * @return $this + */ + public function setGroupBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->groupBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a group query + * + * @return string + */ + public function getGroupBaseDn() + { + return $this->groupBaseDn; + } + + /** + * Set the objectClass where to look for users + * + * @param string $userClass + * + * @return $this + */ + public function setUserClass($userClass) + { + $this->userClass = $this->getNormedAttribute($userClass); + return $this; + } + + /** + * Return the objectClass where to look for users + * + * @return string + */ + public function getUserClass() + { + return $this->userClass; + } + + /** + * Set the objectClass where to look for groups + * + * @param string $groupClass + * + * @return $this + */ + public function setGroupClass($groupClass) + { + $this->groupClass = $this->getNormedAttribute($groupClass); + return $this; + } + + /** + * Return the objectClass where to look for groups + * + * @return string + */ + public function getGroupClass() + { + return $this->groupClass; + } + + /** + * Set the attribute name where to find a user's name + * + * @param string $userNameAttribute + * + * @return $this + */ + public function setUserNameAttribute($userNameAttribute) + { + $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a user's name + * + * @return string + */ + public function getUserNameAttribute() + { + return $this->userNameAttribute; + } + + /** + * Set the attribute name where to find a group's name + * + * @param string $groupNameAttribute + * + * @return $this + */ + public function setGroupNameAttribute($groupNameAttribute) + { + $this->groupNameAttribute = $this->getNormedAttribute($groupNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's name + * + * @return string + */ + public function getGroupNameAttribute() + { + return $this->groupNameAttribute; + } + + /** + * Set the attribute name where to find a group's member + * + * @param string $groupMemberAttribute + * + * @return $this + */ + public function setGroupMemberAttribute($groupMemberAttribute) + { + $this->groupMemberAttribute = $this->getNormedAttribute($groupMemberAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's member + * + * @return string + */ + public function getGroupMemberAttribute() + { + return $this->groupMemberAttribute; + } + + /** + * Set the custom LDAP filter to apply on a user query + * + * @param string $filter + * + * @return $this + */ + public function setUserFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + if ($filter[0] === '(') { + $filter = substr($filter, 1, -1); + } + + $this->userFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a user query + * + * @return string + */ + public function getUserFilter() + { + return $this->userFilter; + } + + /** + * Set the custom LDAP filter to apply on a group query + * + * @param string $filter + * + * @return $this + */ + public function setGroupFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + $this->groupFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a group query + * + * @return string + */ + public function getGroupFilter() + { + return $this->groupFilter; + } + + /** + * Set nestedGroupSearch for the group query + * + * @param bool $enable + * + * @return $this + */ + public function setNestedGroupSearch($enable = true) + { + $this->nestedGroupSearch = $enable; + return $this; + } + + /** + * Get nestedGroupSearch for the group query + * + * @return bool + */ + public function getNestedGroupSearch() + { + return $this->nestedGroupSearch; + } + + /** + * Get the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be returned. + * + * @return string + */ + public function getDomain() + { + return $this->userBackend !== null ? $this->userBackend->getDomain() : $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be used nonetheless. + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + if ($domain && ($domain = trim($domain))) { + $this->domain = $domain; + } + + return $this; + } + + /** + * Return whether the attribute name where to find a group's member holds ambiguous values + * + * This tries to detect if the member attribute of groups contain: + * + * full DN -> distinguished name of another object + * other -> ambiguous field referencing the member by userNameAttribute + * + * @return bool + * + * @throws ProgrammingError In case either $this->groupClass or $this->groupMemberAttribute + * has not been set yet + */ + protected function isMemberAttributeAmbiguous() + { + if ($this->ambiguousMemberAttribute === null) { + if ($this->groupClass === null) { + throw new ProgrammingError( + 'It is required to set the objectClass where to look for groups first' + ); + } elseif ($this->groupMemberAttribute === null) { + throw new ProgrammingError( + 'It is required to set a attribute name where to find a group\'s members first' + ); + } + + $sampleValues = $this->ds + ->select() + ->from($this->groupClass, array($this->groupMemberAttribute)) + ->where($this->groupMemberAttribute, '*') + ->limit(Logger::getInstance()->getLevel() === Logger::DEBUG ? 3 : 1) + ->setUnfoldAttribute($this->groupMemberAttribute) + ->setBase($this->groupBaseDn) + ->fetchAll(); + + Logger::debug('Ambiguity query returned %d results', count($sampleValues)); + + $i = 0; + $sampleValue = null; + foreach ($sampleValues as $key => $value) { + if ($sampleValue === null) { + $sampleValue = $value; + } + + Logger::debug('Result %d: %s (%s)', ++$i, $value, $key); + } + + if (is_object($sampleValue) && isset($sampleValue->{$this->groupMemberAttribute})) { + $this->ambiguousMemberAttribute = ! LdapUtils::isDn($sampleValue->{$this->groupMemberAttribute}); + + Logger::debug( + 'Ambiguity check came to the conclusion that the member attribute %s ambiguous. Tested sample: %s', + $this->ambiguousMemberAttribute ? 'is' : 'is not', + $sampleValue->{$this->groupMemberAttribute} + ); + } else { + Logger::warning( + 'Ambiguity query returned zero or invalid results. Sample value is `%s`', + print_r($sampleValue, true) + ); + } + } + + return $this->ambiguousMemberAttribute; + } + + /** + * Initialize this repository's virtual tables + * + * @return array + * + * @throws ProgrammingError In case $this->groupClass has not been set yet + */ + protected function initializeVirtualTables() + { + if ($this->groupClass === null) { + throw new ProgrammingError('It is required to set the object class where to find groups first'); + } + + return array( + 'group' => $this->groupClass, + 'group_membership' => $this->groupClass + ); + } + + /** + * Initialize this repository's query columns + * + * @return array + * + * @throws ProgrammingError In case either $this->groupNameAttribute or + * $this->groupMemberAttribute has not been set yet + */ + protected function initializeQueryColumns() + { + if ($this->groupNameAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s name first'); + } + if ($this->groupMemberAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s members first'); + } + + if ($this->ds->getCapabilities()->isActiveDirectory()) { + $createdAtAttribute = 'whenCreated'; + $lastModifiedAttribute = 'whenChanged'; + } else { + $createdAtAttribute = 'createTimestamp'; + $lastModifiedAttribute = 'modifyTimestamp'; + } + + $columns = array( + 'group' => $this->groupNameAttribute, + 'group_name' => $this->groupNameAttribute, + 'user' => $this->groupMemberAttribute, + 'user_name' => $this->groupMemberAttribute, + 'created_at' => $createdAtAttribute, + 'last_modified' => $lastModifiedAttribute + ); + return array('group' => $columns, 'group_membership' => $columns); + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + return array( + t('Username') => 'user_name', + t('User Group') => 'group_name', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Initialize this repository's conversion rules + * + * @return array + */ + protected function initializeConversionRules() + { + $rules = array( + 'group' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ), + 'group_membership' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ) + ); + if (! $this->isMemberAttributeAmbiguous()) { + $rules['group_membership']['user_name'] = 'user_name'; + $rules['group_membership']['user'] = 'user_name'; + $rules['group']['user_name'] = 'user_name'; + $rules['group']['user'] = 'user_name'; + } + + return $rules; + } + + /** + * Return the distinguished name for the given uid or gid + * + * @param string $name + * + * @return string + */ + protected function persistUserName($name) + { + try { + $userDn = $this->ds + ->select() + ->from($this->userClass, array()) + ->where($this->userNameAttribute, $name) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($userDn) { + return $userDn; + } + + $groupDn = $this->ds + ->select() + ->from($this->groupClass, array()) + ->where($this->groupNameAttribute, $name) + ->setBase($this->groupBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($groupDn) { + return $groupDn; + } + } catch (LdapException $_) { + // pass + } + + Logger::debug('Unable to persist uid or gid "%s" in repository "%s". No DN found.', $name, $this->getName()); + return $name; + } + + /** + * Return the uid for the given distinguished name + * + * @param string $username + * + * @param string + */ + protected function retrieveUserName($dn) + { + return $this->ds + ->select() + ->from('*', array($this->userNameAttribute)) + ->setUnfoldAttribute($this->userNameAttribute) + ->setBase($dn) + ->fetchOne(); + } + + /** + * Validate that the requested table exists + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + if ($query !== null) { + $query->getQuery()->setBase($this->groupBaseDn); + if ($table === 'group' && $this->groupFilter) { + $query->getQuery()->setNativeFilter($this->groupFilter); + } + } + + return parent::requireTable($table, $query); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + $column = parent::requireQueryColumn($table, $name, $query); + if (($name === 'user_name' || $name === 'group_name') && $query !== null) { + $query->getQuery()->setUnfoldAttribute($name); + } + + return $column; + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $domain = $this->getDomain(); + + if ($domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($domain)) { + return array(); + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + + if ($this->isMemberAttributeAmbiguous()) { + $queryValue = $username; + } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) { + $userQuery = $this->ds + ->select() + ->from($this->userClass) + ->where($this->userNameAttribute, $username) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false); + if ($this->userFilter) { + $userQuery->setNativeFilter($this->userFilter); + } + + if (($queryValue = $userQuery->fetchDn()) === null) { + return array(); + } + } + + if ($this->nestedGroupSearch) { + $groupMemberAttribute = $this->groupMemberAttribute . ':1.2.840.113556.1.4.1941:'; + } else { + $groupMemberAttribute = $this->groupMemberAttribute; + } + + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setUnfoldAttribute($this->groupNameAttribute) + ->where($groupMemberAttribute, $queryValue) + ->setBase($this->groupBaseDn); + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $groups = array(); + foreach ($groupQuery as $row) { + $groups[] = $row->{$this->groupNameAttribute}; + if ($domain !== null) { + $groups[] = $row->{$this->groupNameAttribute} . "@$domain"; + } + } + + return $groups; + } + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username Unused + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username) + { + $userBackend = $this->getUserBackend(); + if ($userBackend !== null) { + return $userBackend->getName(); + } + } + + /** + * Apply the given configuration on this backend + * + * @param ConfigObject $config + * + * @return $this + * + * @throws ConfigurationError In case a linked user backend does not exist or is invalid + */ + public function setConfig(ConfigObject $config) + { + if ($config->backend === 'ldap') { + $defaults = $this->getOpenLdapDefaults(); + } elseif ($config->backend === 'msldap') { + $defaults = $this->getActiveDirectoryDefaults(); + } else { + $defaults = new ConfigObject(); + } + + if ($config->user_backend && $config->user_backend !== 'none') { + $userBackend = UserBackend::create($config->user_backend); + if (! $userBackend instanceof LdapUserBackend) { + throw new ConfigurationError('User backend "%s" is not of type LDAP', $config->user_backend); + } + + if ($this->ds->getHostname() !== $userBackend->getDataSource()->getHostname() + || $this->ds->getPort() !== $userBackend->getDataSource()->getPort() + ) { + // TODO(jom): Elaborate whether it makes sense to link directories on different hosts + throw new ConfigurationError( + 'It is required that a linked user backend refers to the ' + . 'same directory as it\'s user group backend counterpart' + ); + } + + $this->setUserBackend($userBackend); + $defaults->merge(array( + 'user_base_dn' => $userBackend->getBaseDn(), + 'user_class' => $userBackend->getUserClass(), + 'user_name_attribute' => $userBackend->getUserNameAttribute(), + 'user_filter' => $userBackend->getFilter(), + 'domain' => $userBackend->getDomain() + )); + } + + return $this + ->setGroupBaseDn($config->base_dn) + ->setUserBaseDn($config->get('user_base_dn', $defaults->get('user_base_dn', $this->getGroupBaseDn()))) + ->setGroupClass($config->get('group_class', $defaults->group_class)) + ->setUserClass($config->get('user_class', $defaults->user_class)) + ->setGroupNameAttribute($config->get('group_name_attribute', $defaults->group_name_attribute)) + ->setUserNameAttribute($config->get('user_name_attribute', $defaults->user_name_attribute)) + ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute)) + ->setGroupFilter($config->group_filter) + ->setUserFilter($config->user_filter) + ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search)) + ->setDomain($defaults->get('domain', $config->domain)); + } + + /** + * Return the configuration defaults for an OpenLDAP environment + * + * @return ConfigObject + */ + public function getOpenLdapDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'inetOrgPerson', + 'group_name_attribute' => 'gid', + 'user_name_attribute' => 'uid', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Return the configuration defaults for an ActiveDirectory environment + * + * @return ConfigObject + */ + public function getActiveDirectoryDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'user', + 'group_name_attribute' => 'sAMAccountName', + 'user_name_attribute' => 'sAMAccountName', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Inspect if this LDAP User Group Backend is working as expected by probing the backend + * + * Try to bind to the backend and fetch a single group to check if: + * <ul> + * <li>Connection credentials are correct and the bind is possible</li> + * <li>At least one group exists</li> + * <li>The specified groupClass has the property specified by groupNameAttribute</li> + * </ul> + * + * @return Inspection Inspection result + */ + public function inspect() + { + $result = new Inspection('Ldap User Group Backend'); + + // inspect the used connection to get more diagnostic info in case the connection is not working + $result->write($this->ds->inspect()); + + try { + try { + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setBase($this->groupBaseDn); + + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $res = $groupQuery->fetchRow(); + } catch (LdapException $e) { + throw new AuthenticationException('Connection not possible', $e); + } + + $result->write('Searching for: ' . sprintf( + 'objectClass "%s" in DN "%s" (Filter: %s)', + $this->groupClass, + $this->groupBaseDn ?: $this->ds->getDn(), + $this->groupFilter ?: 'None' + )); + + if ($res === false) { + throw new AuthenticationException('Error, no groups found in backend'); + } + + $result->write(sprintf('%d groups found in backend', $groupQuery->count())); + + if (! isset($res->{$this->groupNameAttribute})) { + throw new AuthenticationException( + 'GroupNameAttribute "%s" not existing in objectClass "%s"', + $this->groupNameAttribute, + $this->groupClass + ); + } + } catch (AuthenticationException $e) { + if (($previous = $e->getPrevious()) !== null) { + $result->error($previous->getMessage()); + } else { + $result->error($e->getMessage()); + } + } catch (Exception $e) { + $result->error(sprintf('Unable to validate backend: %s', $e->getMessage())); + } + + return $result; + } +} |