diff options
Diffstat (limited to 'apps/user_ldap/lib')
110 files changed, 15973 insertions, 6296 deletions
diff --git a/apps/user_ldap/lib/Access.php b/apps/user_ldap/lib/Access.php new file mode 100644 index 00000000000..9fe0aa64268 --- /dev/null +++ b/apps/user_ldap/lib/Access.php @@ -0,0 +1,2053 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use DomainException; +use OC\Hooks\PublicEmitter; +use OC\ServerNotAvailableException; +use OCA\User_LDAP\Exceptions\ConstraintViolationException; +use OCA\User_LDAP\Exceptions\NoMoreResults; +use OCA\User_LDAP\Mapping\AbstractMapping; +use OCA\User_LDAP\User\Manager; +use OCA\User_LDAP\User\OfflineUser; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\HintException; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\Server; +use OCP\User\Events\UserIdAssignedEvent; +use OCP\Util; +use Psr\Log\LoggerInterface; +use function strlen; +use function substr; + +/** + * Class Access + * + * @package OCA\User_LDAP + */ +class Access extends LDAPUtility { + public const UUID_ATTRIBUTES = ['entryuuid', 'nsuniqueid', 'objectguid', 'guid', 'ipauniqueid']; + + /** + * never ever check this var directly, always use getPagedSearchResultState + * @var ?bool + */ + protected $pagedSearchedSuccessful; + + /** @var ?AbstractMapping */ + protected $userMapper; + + /** @var ?AbstractMapping */ + protected $groupMapper; + + private string $lastCookie = ''; + + public function __construct( + ILDAPWrapper $ldap, + public Connection $connection, + public Manager $userManager, + private Helper $helper, + private IConfig $config, + private IUserManager $ncUserManager, + private LoggerInterface $logger, + private IAppConfig $appConfig, + private IEventDispatcher $dispatcher, + ) { + parent::__construct($ldap); + $this->userManager->setLdapAccess($this); + } + + /** + * sets the User Mapper + */ + public function setUserMapper(AbstractMapping $mapper): void { + $this->userMapper = $mapper; + } + + /** + * @throws \Exception + */ + public function getUserMapper(): AbstractMapping { + if (is_null($this->userMapper)) { + throw new \Exception('UserMapper was not assigned to this Access instance.'); + } + return $this->userMapper; + } + + /** + * sets the Group Mapper + */ + public function setGroupMapper(AbstractMapping $mapper): void { + $this->groupMapper = $mapper; + } + + /** + * returns the Group Mapper + * + * @throws \Exception + */ + public function getGroupMapper(): AbstractMapping { + if (is_null($this->groupMapper)) { + throw new \Exception('GroupMapper was not assigned to this Access instance.'); + } + return $this->groupMapper; + } + + /** + * @return bool + */ + private function checkConnection() { + return ($this->connection instanceof Connection); + } + + /** + * returns the Connection instance + * + * @return \OCA\User_LDAP\Connection + */ + public function getConnection() { + return $this->connection; + } + + /** + * Reads several attributes for an LDAP record identified by a DN and a filter + * No support for ranged attributes. + * + * @param string $dn the record in question + * @param array $attrs the attributes that shall be retrieved + * if empty, just check the record's existence + * @param string $filter + * @return array|false an array of values on success or an empty + * array if $attr is empty, false otherwise + * @throws ServerNotAvailableException + */ + public function readAttributes(string $dn, array $attrs, string $filter = 'objectClass=*'): array|false { + if (!$this->checkConnection()) { + $this->logger->warning( + 'No LDAP Connector assigned, access impossible for readAttribute.', + ['app' => 'user_ldap'] + ); + return false; + } + $cr = $this->connection->getConnectionResource(); + $attrs = array_map( + fn (string $attr): string => mb_strtolower($attr, 'UTF-8'), + $attrs, + ); + + $values = []; + $record = $this->executeRead($dn, $attrs, $filter); + if (is_bool($record)) { + // when an exists request was run and it was successful, an empty + // array must be returned + return $record ? [] : false; + } + + $result = []; + foreach ($attrs as $attr) { + $values = $this->extractAttributeValuesFromResult($record, $attr); + if (!empty($values)) { + $result[$attr] = $values; + } + } + + if (!empty($result)) { + return $result; + } + + $this->logger->debug('Requested attributes {attrs} not found for ' . $dn, ['app' => 'user_ldap', 'attrs' => $attrs]); + return false; + } + + /** + * reads a given attribute for an LDAP record identified by a DN + * + * @param string $dn the record in question + * @param string $attr the attribute that shall be retrieved + * if empty, just check the record's existence + * @param string $filter + * @return array|false an array of values on success or an empty + * array if $attr is empty, false otherwise + * @throws ServerNotAvailableException + */ + public function readAttribute(string $dn, string $attr, string $filter = 'objectClass=*') { + if (!$this->checkConnection()) { + $this->logger->warning( + 'No LDAP Connector assigned, access impossible for readAttribute.', + ['app' => 'user_ldap'] + ); + return false; + } + $cr = $this->connection->getConnectionResource(); + $attr = mb_strtolower($attr, 'UTF-8'); + // the actual read attribute later may contain parameters on a ranged + // request, e.g. member;range=99-199. Depends on server reply. + $attrToRead = $attr; + + $values = []; + $isRangeRequest = false; + do { + $result = $this->executeRead($dn, $attrToRead, $filter); + if (is_bool($result)) { + // when an exists request was run and it was successful, an empty + // array must be returned + return $result ? [] : false; + } + + if (!$isRangeRequest) { + $values = $this->extractAttributeValuesFromResult($result, $attr); + if (!empty($values)) { + return $values; + } + } + + $isRangeRequest = false; + $result = $this->extractRangeData($result, $attr); + if (!empty($result)) { + $normalizedResult = $this->extractAttributeValuesFromResult( + [$attr => $result['values']], + $attr + ); + $values = array_merge($values, $normalizedResult); + + if ($result['rangeHigh'] === '*') { + // when server replies with * as high range value, there are + // no more results left + return $values; + } else { + $low = $result['rangeHigh'] + 1; + $attrToRead = $result['attributeName'] . ';range=' . $low . '-*'; + $isRangeRequest = true; + } + } + } while ($isRangeRequest); + + $this->logger->debug('Requested attribute ' . $attr . ' not found for ' . $dn, ['app' => 'user_ldap']); + return false; + } + + /** + * Runs an read operation against LDAP + * + * @return array|bool false if there was any error, true if an exists check + * was performed and the requested DN found, array with the + * returned data on a successful usual operation + * @throws ServerNotAvailableException + */ + public function executeRead(string $dn, string|array $attribute, string $filter) { + $dn = $this->helper->DNasBaseParameter($dn); + $rr = @$this->invokeLDAPMethod('read', $dn, $filter, (is_string($attribute) ? [$attribute] : $attribute)); + if (!$this->ldap->isResource($rr)) { + if ($attribute !== '') { + //do not throw this message on userExists check, irritates + $this->logger->debug('readAttribute failed for DN ' . $dn, ['app' => 'user_ldap']); + } + //in case an error occurs , e.g. object does not exist + return false; + } + if ($attribute === '' && ($filter === 'objectclass=*' || $this->invokeLDAPMethod('countEntries', $rr) === 1)) { + $this->logger->debug('readAttribute: ' . $dn . ' found', ['app' => 'user_ldap']); + return true; + } + $er = $this->invokeLDAPMethod('firstEntry', $rr); + if (!$this->ldap->isResource($er)) { + //did not match the filter, return false + return false; + } + //LDAP attributes are not case sensitive + $result = Util::mb_array_change_key_case( + $this->invokeLDAPMethod('getAttributes', $er), MB_CASE_LOWER, 'UTF-8'); + + return $result; + } + + /** + * Normalizes a result grom getAttributes(), i.e. handles DNs and binary + * data if present. + * + * DN values are escaped as per RFC 2253 + * + * @param array $result from ILDAPWrapper::getAttributes() + * @param string $attribute the attribute name that was read + * @return string[] + */ + public function extractAttributeValuesFromResult($result, $attribute) { + $values = []; + if (isset($result[$attribute]) && $result[$attribute]['count'] > 0) { + $lowercaseAttribute = strtolower($attribute); + for ($i = 0; $i < $result[$attribute]['count']; $i++) { + if ($this->resemblesDN($attribute)) { + $values[] = $this->helper->sanitizeDN($result[$attribute][$i]); + } elseif ($lowercaseAttribute === 'objectguid' || $lowercaseAttribute === 'guid') { + $values[] = $this->convertObjectGUID2Str($result[$attribute][$i]); + } else { + $values[] = $result[$attribute][$i]; + } + } + } + return $values; + } + + /** + * Attempts to find ranged data in a getAttribute results and extracts the + * returned values as well as information on the range and full attribute + * name for further processing. + * + * @param array $result from ILDAPWrapper::getAttributes() + * @param string $attribute the attribute name that was read. Without ";range=…" + * @return array If a range was detected with keys 'values', 'attributeName', + * 'attributeFull' and 'rangeHigh', otherwise empty. + */ + public function extractRangeData(array $result, string $attribute): array { + $keys = array_keys($result); + foreach ($keys as $key) { + if ($key !== $attribute && str_starts_with((string)$key, $attribute)) { + $queryData = explode(';', (string)$key); + if (isset($queryData[1]) && str_starts_with($queryData[1], 'range=')) { + $high = substr($queryData[1], 1 + strpos($queryData[1], '-')); + return [ + 'values' => $result[$key], + 'attributeName' => $queryData[0], + 'attributeFull' => $key, + 'rangeHigh' => $high, + ]; + } + } + } + return []; + } + + /** + * Set password for an LDAP user identified by a DN + * + * @param string $userDN the user in question + * @param string $password the new password + * @return bool + * @throws HintException + * @throws \Exception + */ + public function setPassword($userDN, $password) { + if ((int)$this->connection->turnOnPasswordChange !== 1) { + throw new \Exception('LDAP password changes are disabled.'); + } + $cr = $this->connection->getConnectionResource(); + try { + // try PASSWD extended operation first + return @$this->invokeLDAPMethod('exopPasswd', $userDN, '', $password) + || @$this->invokeLDAPMethod('modReplace', $userDN, $password); + } catch (ConstraintViolationException $e) { + throw new HintException('Password change rejected.', Util::getL10N('user_ldap')->t('Password change rejected. Hint: %s', $e->getMessage()), (int)$e->getCode()); + } + } + + /** + * checks whether the given attributes value is probably a DN + * + * @param string $attr the attribute in question + * @return boolean if so true, otherwise false + */ + private function resemblesDN($attr) { + $resemblingAttributes = [ + 'dn', + 'uniquemember', + 'member', + // memberOf is an "operational" attribute, without a definition in any RFC + 'memberof' + ]; + return in_array($attr, $resemblingAttributes); + } + + /** + * checks whether the given string is probably a DN + * + * @param string $string + * @return boolean + */ + public function stringResemblesDN($string) { + $r = $this->ldap->explodeDN($string, 0); + // if exploding a DN succeeds and does not end up in + // an empty array except for $r[count] being 0. + return (is_array($r) && count($r) > 1); + } + + /** + * returns a DN-string that is cleaned from not domain parts, e.g. + * cn=foo,cn=bar,dc=foobar,dc=server,dc=org + * becomes dc=foobar,dc=server,dc=org + * + * @param string $dn + * @return string + */ + public function getDomainDNFromDN($dn) { + $allParts = $this->ldap->explodeDN($dn, 0); + if ($allParts === false) { + //not a valid DN + return ''; + } + $domainParts = []; + $dcFound = false; + foreach ($allParts as $part) { + if (!$dcFound && str_starts_with($part, 'dc=')) { + $dcFound = true; + } + if ($dcFound) { + $domainParts[] = $part; + } + } + return implode(',', $domainParts); + } + + /** + * returns the LDAP DN for the given internal Nextcloud name of the group + * + * @param string $name the Nextcloud name in question + * @return string|false LDAP DN on success, otherwise false + */ + public function groupname2dn($name) { + return $this->getGroupMapper()->getDNByName($name); + } + + /** + * returns the LDAP DN for the given internal Nextcloud name of the user + * + * @param string $name the Nextcloud name in question + * @return string|false with the LDAP DN on success, otherwise false + */ + public function username2dn($name) { + $fdn = $this->getUserMapper()->getDNByName($name); + + //Check whether the DN belongs to the Base, to avoid issues on multi- + //server setups + if (is_string($fdn) && $this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { + return $fdn; + } + + return false; + } + + /** + * returns the internal Nextcloud name for the given LDAP DN of the group, false on DN outside of search DN or failure + * + * @param string $fdn the dn of the group object + * @param string $ldapName optional, the display name of the object + * @param bool $autoMapping Should the group be mapped if not yet mapped + * @return string|false with the name to use in Nextcloud, false on DN outside of search DN + * @throws \Exception + */ + public function dn2groupname($fdn, $ldapName = null, bool $autoMapping = true) { + //To avoid bypassing the base DN settings under certain circumstances + //with the group support, check whether the provided DN matches one of + //the given Bases + if (!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseGroups)) { + return false; + } + + return $this->dn2ocname($fdn, $ldapName, false, autoMapping:$autoMapping); + } + + /** + * returns the internal Nextcloud name for the given LDAP DN of the user, false on DN outside of search DN or failure + * + * @param string $fdn the dn of the user object + * @param string $ldapName optional, the display name of the object + * @return string|false with with the name to use in Nextcloud + * @throws \Exception + */ + public function dn2username($fdn) { + //To avoid bypassing the base DN settings under certain circumstances + //with the group support, check whether the provided DN matches one of + //the given Bases + if (!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { + return false; + } + + return $this->dn2ocname($fdn, null, true); + } + + /** + * returns an internal Nextcloud name for the given LDAP DN, false on DN outside of search DN + * + * @param string $fdn the dn of the user object + * @param string|null $ldapName optional, the display name of the object + * @param bool $isUser optional, whether it is a user object (otherwise group assumed) + * @param bool|null $newlyMapped + * @param array|null $record + * @param bool $autoMapping Should the group be mapped if not yet mapped + * @return false|string with with the name to use in Nextcloud + * @throws \Exception + */ + public function dn2ocname($fdn, $ldapName = null, $isUser = true, &$newlyMapped = null, ?array $record = null, bool $autoMapping = true) { + static $intermediates = []; + if (isset($intermediates[($isUser ? 'user-' : 'group-') . $fdn])) { + return false; // is a known intermediate + } + + $newlyMapped = false; + if ($isUser) { + $mapper = $this->getUserMapper(); + } else { + $mapper = $this->getGroupMapper(); + } + + //let's try to retrieve the Nextcloud name from the mappings table + $ncName = $mapper->getNameByDN($fdn); + if (is_string($ncName)) { + return $ncName; + } + + if (!$autoMapping) { + /* If no auto mapping, stop there */ + return false; + } + + if ($isUser) { + $nameAttribute = strtolower($this->connection->ldapUserDisplayName); + $filter = $this->connection->ldapUserFilter; + $uuidAttr = 'ldapUuidUserAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; + $usernameAttribute = strtolower($this->connection->ldapExpertUsernameAttr); + $attributesToRead = [$nameAttribute,$usernameAttribute]; + // TODO fetch also display name attributes and cache them if the user is mapped + } else { + $nameAttribute = strtolower($this->connection->ldapGroupDisplayName); + $filter = $this->connection->ldapGroupFilter; + $uuidAttr = 'ldapUuidGroupAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; + $attributesToRead = [$nameAttribute]; + } + + if ($this->detectUuidAttribute($fdn, $isUser, false, $record)) { + $attributesToRead[] = $this->connection->$uuidAttr; + } + + if ($record === null) { + /* No record was passed, fetch it */ + $record = $this->readAttributes($fdn, $attributesToRead, $filter); + if ($record === false) { + $this->logger->debug('Cannot read attributes for ' . $fdn . '. Skipping.', ['filter' => $filter]); + $intermediates[($isUser ? 'user-' : 'group-') . $fdn] = true; + return false; + } + } + + //second try: get the UUID and check if it is known. Then, update the DN and return the name. + $uuid = $this->getUUID($fdn, $isUser, $record); + if (is_string($uuid)) { + $ncName = $mapper->getNameByUUID($uuid); + if (is_string($ncName)) { + $mapper->setDNbyUUID($fdn, $uuid); + return $ncName; + } + } else { + //If the UUID can't be detected something is foul. + $this->logger->debug('Cannot determine UUID for ' . $fdn . '. Skipping.', ['app' => 'user_ldap']); + return false; + } + + if ($isUser) { + if ($usernameAttribute !== '') { + $username = $record[$usernameAttribute]; + if (!isset($username[0]) || empty($username[0])) { + $this->logger->debug('No or empty username (' . $usernameAttribute . ') for ' . $fdn . '.', ['app' => 'user_ldap']); + return false; + } + $username = $username[0]; + } else { + $username = $uuid; + } + try { + $intName = $this->sanitizeUsername($username); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Error sanitizing username: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + // we don't attempt to set a username here. We can go for + // for an alternative 4 digit random number as we would append + // otherwise, however it's likely not enough space in bigger + // setups, and most importantly: this is not intended. + return false; + } + } else { + if (is_null($ldapName)) { + $ldapName = $record[$nameAttribute]; + if (!isset($ldapName[0]) || empty($ldapName[0])) { + $this->logger->debug('No or empty name for ' . $fdn . ' with filter ' . $filter . '.', ['app' => 'user_ldap']); + $intermediates['group-' . $fdn] = true; + return false; + } + $ldapName = $ldapName[0]; + } + $intName = $this->sanitizeGroupIDCandidate($ldapName); + } + + //a new user/group! Add it only if it doesn't conflict with other backend's users or existing groups + //disabling Cache is required to avoid that the new user is cached as not-existing in fooExists check + //NOTE: mind, disabling cache affects only this instance! Using it + // outside of core user management will still cache the user as non-existing. + $originalTTL = $this->connection->ldapCacheTTL; + $this->connection->setConfiguration(['ldapCacheTTL' => 0]); + if ($intName !== '' + && (($isUser && !$this->ncUserManager->userExists($intName)) + || (!$isUser && !Server::get(IGroupManager::class)->groupExists($intName)) + ) + ) { + $this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]); + $newlyMapped = $this->mapAndAnnounceIfApplicable($mapper, $fdn, $intName, $uuid, $isUser); + if ($newlyMapped) { + $this->logger->debug('Mapped {fdn} as {name}', ['fdn' => $fdn,'name' => $intName]); + return $intName; + } + } + + $this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]); + $altName = $this->createAltInternalOwnCloudName($intName, $isUser); + if (is_string($altName)) { + if ($this->mapAndAnnounceIfApplicable($mapper, $fdn, $altName, $uuid, $isUser)) { + $this->logger->warning( + 'Mapped {fdn} as {altName} because of a name collision on {intName}.', + [ + 'fdn' => $fdn, + 'altName' => $altName, + 'intName' => $intName, + ] + ); + $newlyMapped = true; + return $altName; + } + } + + //if everything else did not help.. + $this->logger->info('Could not create unique name for ' . $fdn . '.', ['app' => 'user_ldap']); + return false; + } + + public function mapAndAnnounceIfApplicable( + AbstractMapping $mapper, + string $fdn, + string $name, + string $uuid, + bool $isUser, + ): bool { + if ($mapper->map($fdn, $name, $uuid)) { + if ($isUser) { + $this->cacheUserExists($name); + $this->dispatcher->dispatchTyped(new UserIdAssignedEvent($name)); + if ($this->ncUserManager instanceof PublicEmitter) { + $this->ncUserManager->emit('\OC\User', 'assignedUserId', [$name]); + } + } else { + $this->cacheGroupExists($name); + } + return true; + } + return false; + } + + /** + * gives back the user names as they are used ownClod internally + * + * @param array $ldapUsers as returned by fetchList() + * @return array<int,string> an array with the user names to use in Nextcloud + * + * gives back the user names as they are used ownClod internally + * @throws \Exception + */ + public function nextcloudUserNames($ldapUsers) { + return $this->ldap2NextcloudNames($ldapUsers, true); + } + + /** + * gives back the group names as they are used ownClod internally + * + * @param array $ldapGroups as returned by fetchList() + * @return array<int,string> an array with the group names to use in Nextcloud + * + * gives back the group names as they are used ownClod internally + * @throws \Exception + */ + public function nextcloudGroupNames($ldapGroups) { + return $this->ldap2NextcloudNames($ldapGroups, false); + } + + /** + * @param array[] $ldapObjects as returned by fetchList() + * @return array<int,string> + * @throws \Exception + */ + private function ldap2NextcloudNames(array $ldapObjects, bool $isUsers): array { + if ($isUsers) { + $nameAttribute = $this->connection->ldapUserDisplayName; + $sndAttribute = $this->connection->ldapUserDisplayName2; + } else { + $nameAttribute = $this->connection->ldapGroupDisplayName; + $sndAttribute = null; + } + $nextcloudNames = []; + + foreach ($ldapObjects as $ldapObject) { + $nameByLDAP = $ldapObject[$nameAttribute][0] ?? null; + + $ncName = $this->dn2ocname($ldapObject['dn'][0], $nameByLDAP, $isUsers); + if ($ncName) { + $nextcloudNames[] = $ncName; + if ($isUsers) { + $this->updateUserState($ncName); + //cache the user names so it does not need to be retrieved + //again later (e.g. sharing dialogue). + if (is_null($nameByLDAP)) { + continue; + } + $sndName = $ldapObject[$sndAttribute][0] ?? ''; + $this->cacheUserDisplayName($ncName, $nameByLDAP, $sndName); + } elseif ($nameByLDAP !== null) { + $this->cacheGroupDisplayName($ncName, $nameByLDAP); + } + } + } + return $nextcloudNames; + } + + /** + * removes the deleted-flag of a user if it was set + * + * @param string $ncname + * @throws \Exception + */ + public function updateUserState($ncname): void { + $user = $this->userManager->get($ncname); + if ($user instanceof OfflineUser) { + $user->unmark(); + } + } + + /** + * caches the user display name + * + * @param string $ocName the internal Nextcloud username + * @param string|false $home the home directory path + */ + public function cacheUserHome(string $ocName, $home): void { + $cacheKey = 'getHome' . $ocName; + $this->connection->writeToCache($cacheKey, $home); + } + + /** + * caches a user as existing + */ + public function cacheUserExists(string $ocName): void { + $this->connection->writeToCache('userExists' . $ocName, true); + $this->connection->writeToCache('userExistsOnLDAP' . $ocName, true); + } + + /** + * caches a group as existing + */ + public function cacheGroupExists(string $gid): void { + $this->connection->writeToCache('groupExists' . $gid, true); + } + + /** + * caches the user display name + * + * @param string $ocName the internal Nextcloud username + * @param string $displayName the display name + * @param string $displayName2 the second display name + * @throws \Exception + */ + public function cacheUserDisplayName(string $ocName, string $displayName, string $displayName2 = ''): void { + $user = $this->userManager->get($ocName); + if ($user === null) { + return; + } + $displayName = $user->composeAndStoreDisplayName($displayName, $displayName2); + $cacheKeyTrunk = 'getDisplayName'; + $this->connection->writeToCache($cacheKeyTrunk . $ocName, $displayName); + } + + public function cacheGroupDisplayName(string $ncName, string $displayName): void { + $cacheKey = 'group_getDisplayName' . $ncName; + $this->connection->writeToCache($cacheKey, $displayName); + } + + /** + * creates a unique name for internal Nextcloud use for users. Don't call it directly. + * + * @param string $name the display name of the object + * @return string|false with with the name to use in Nextcloud or false if unsuccessful + * + * Instead of using this method directly, call + * createAltInternalOwnCloudName($name, true) + */ + private function _createAltInternalOwnCloudNameForUsers(string $name) { + $attempts = 0; + //while loop is just a precaution. If a name is not generated within + //20 attempts, something else is very wrong. Avoids infinite loop. + while ($attempts < 20) { + $altName = $name . '_' . rand(1000, 9999); + if (!$this->ncUserManager->userExists($altName)) { + return $altName; + } + $attempts++; + } + return false; + } + + /** + * creates a unique name for internal Nextcloud use for groups. Don't call it directly. + * + * @param string $name the display name of the object + * @return string|false with with the name to use in Nextcloud or false if unsuccessful. + * + * Instead of using this method directly, call + * createAltInternalOwnCloudName($name, false) + * + * Group names are also used as display names, so we do a sequential + * numbering, e.g. Developers_42 when there are 41 other groups called + * "Developers" + */ + private function _createAltInternalOwnCloudNameForGroups(string $name) { + $usedNames = $this->getGroupMapper()->getNamesBySearch($name, '', '_%'); + if (count($usedNames) === 0) { + $lastNo = 1; //will become name_2 + } else { + natsort($usedNames); + $lastName = array_pop($usedNames); + $lastNo = (int)substr($lastName, strrpos($lastName, '_') + 1); + } + $altName = $name . '_' . (string)($lastNo + 1); + unset($usedNames); + + $attempts = 1; + while ($attempts < 21) { + // Check to be really sure it is unique + // while loop is just a precaution. If a name is not generated within + // 20 attempts, something else is very wrong. Avoids infinite loop. + if (!Server::get(IGroupManager::class)->groupExists($altName)) { + return $altName; + } + $altName = $name . '_' . ($lastNo + $attempts); + $attempts++; + } + return false; + } + + /** + * creates a unique name for internal Nextcloud use. + * + * @param string $name the display name of the object + * @param bool $isUser whether name should be created for a user (true) or a group (false) + * @return string|false with with the name to use in Nextcloud or false if unsuccessful + */ + private function createAltInternalOwnCloudName(string $name, bool $isUser) { + // ensure there is space for the "_1234" suffix + if (strlen($name) > 59) { + $name = substr($name, 0, 59); + } + + $originalTTL = $this->connection->ldapCacheTTL; + $this->connection->setConfiguration(['ldapCacheTTL' => 0]); + if ($isUser) { + $altName = $this->_createAltInternalOwnCloudNameForUsers($name); + } else { + $altName = $this->_createAltInternalOwnCloudNameForGroups($name); + } + $this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]); + + return $altName; + } + + /** + * fetches a list of users according to a provided loginName and utilizing + * the login filter. + */ + public function fetchUsersByLoginName(string $loginName, array $attributes = ['dn']): array { + $loginName = $this->escapeFilterPart($loginName); + $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); + return $this->fetchListOfUsers($filter, $attributes); + } + + /** + * counts the number of users according to a provided loginName and + * utilizing the login filter. + * + * @param string $loginName + * @return false|int + */ + public function countUsersByLoginName($loginName) { + $loginName = $this->escapeFilterPart($loginName); + $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); + return $this->countUsers($filter); + } + + /** + * @throws \Exception + */ + public function fetchListOfUsers(string $filter, array $attr, ?int $limit = null, ?int $offset = null, bool $forceApplyAttributes = false): array { + $ldapRecords = $this->searchUsers($filter, $attr, $limit, $offset); + $recordsToUpdate = $ldapRecords; + if (!$forceApplyAttributes) { + $isBackgroundJobModeAjax = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax') === 'ajax'; + $listOfDNs = array_reduce($ldapRecords, function ($listOfDNs, $entry) { + $listOfDNs[] = $entry['dn'][0]; + return $listOfDNs; + }, []); + $idsByDn = $this->getUserMapper()->getListOfIdsByDn($listOfDNs); + $recordsToUpdate = array_filter($ldapRecords, function ($record) use ($isBackgroundJobModeAjax, $idsByDn) { + $newlyMapped = false; + $uid = $idsByDn[$record['dn'][0]] ?? null; + if ($uid === null) { + $uid = $this->dn2ocname($record['dn'][0], null, true, $newlyMapped, $record); + } + if (is_string($uid)) { + $this->cacheUserExists($uid); + } + return ($uid !== false) && ($newlyMapped || $isBackgroundJobModeAjax); + }); + } + $this->batchApplyUserAttributes($recordsToUpdate); + return $this->fetchList($ldapRecords, $this->manyAttributes($attr)); + } + + /** + * provided with an array of LDAP user records the method will fetch the + * user object and requests it to process the freshly fetched attributes and + * and their values + * + * @throws \Exception + */ + public function batchApplyUserAttributes(array $ldapRecords): void { + $displayNameAttribute = strtolower((string)$this->connection->ldapUserDisplayName); + foreach ($ldapRecords as $userRecord) { + if (!isset($userRecord[$displayNameAttribute])) { + // displayName is obligatory + continue; + } + $ocName = $this->dn2ocname($userRecord['dn'][0], null, true); + if ($ocName === false) { + continue; + } + $this->updateUserState($ocName); + $user = $this->userManager->get($ocName); + if ($user !== null) { + $user->processAttributes($userRecord); + } else { + $this->logger->debug( + "The ldap user manager returned null for $ocName", + ['app' => 'user_ldap'] + ); + } + } + } + + /** + * @return array[] + */ + public function fetchListOfGroups(string $filter, array $attr, ?int $limit = null, ?int $offset = null): array { + $cacheKey = 'fetchListOfGroups_' . $filter . '_' . implode('-', $attr) . '_' . (string)$limit . '_' . (string)$offset; + $listOfGroups = $this->connection->getFromCache($cacheKey); + if (!is_null($listOfGroups)) { + return $listOfGroups; + } + $groupRecords = $this->searchGroups($filter, $attr, $limit, $offset); + + $listOfGroups = $this->fetchList($groupRecords, $this->manyAttributes($attr)); + $this->connection->writeToCache($cacheKey, $listOfGroups); + return $listOfGroups; + } + + private function fetchList(array $list, bool $manyAttributes): array { + if ($manyAttributes) { + return $list; + } else { + $list = array_reduce($list, function ($carry, $item) { + $attribute = array_keys($item)[0]; + $carry[] = $item[$attribute][0]; + return $carry; + }, []); + return array_unique($list, SORT_LOCALE_STRING); + } + } + + /** + * @throws ServerNotAvailableException + */ + public function searchUsers(string $filter, ?array $attr = null, ?int $limit = null, ?int $offset = null): array { + $result = []; + foreach ($this->connection->ldapBaseUsers as $base) { + $result = array_merge($result, $this->search($filter, $base, $attr, $limit, $offset)); + } + return $result; + } + + /** + * @param string[] $attr + * @return false|int + * @throws ServerNotAvailableException + */ + public function countUsers(string $filter, array $attr = ['dn'], ?int $limit = null, ?int $offset = null) { + $result = false; + foreach ($this->connection->ldapBaseUsers as $base) { + $count = $this->count($filter, [$base], $attr, $limit ?? 0, $offset ?? 0); + $result = is_int($count) ? (int)$result + $count : $result; + } + return $result; + } + + /** + * executes an LDAP search, optimized for Groups + * + * @param ?string[] $attr optional, when certain attributes shall be filtered out + * + * Executes an LDAP search + * @throws ServerNotAvailableException + */ + public function searchGroups(string $filter, ?array $attr = null, ?int $limit = null, ?int $offset = null): array { + $result = []; + foreach ($this->connection->ldapBaseGroups as $base) { + $result = array_merge($result, $this->search($filter, $base, $attr, $limit, $offset)); + } + return $result; + } + + /** + * returns the number of available groups + * + * @return int|bool + * @throws ServerNotAvailableException + */ + public function countGroups(string $filter, array $attr = ['dn'], ?int $limit = null, ?int $offset = null) { + $result = false; + foreach ($this->connection->ldapBaseGroups as $base) { + $count = $this->count($filter, [$base], $attr, $limit ?? 0, $offset ?? 0); + $result = is_int($count) ? (int)$result + $count : $result; + } + return $result; + } + + /** + * returns the number of available objects on the base DN + * + * @return int|bool + * @throws ServerNotAvailableException + */ + public function countObjects(?int $limit = null, ?int $offset = null) { + $result = false; + foreach ($this->connection->ldapBase as $base) { + $count = $this->count('objectclass=*', [$base], ['dn'], $limit ?? 0, $offset ?? 0); + $result = is_int($count) ? (int)$result + $count : $result; + } + return $result; + } + + /** + * Returns the LDAP handler + * + * @throws \OC\ServerNotAvailableException + */ + + /** + * @param mixed[] $arguments + * @return mixed + * @throws \OC\ServerNotAvailableException + */ + private function invokeLDAPMethod(string $command, ...$arguments) { + if ($command == 'controlPagedResultResponse') { + // php no longer supports call-time pass-by-reference + // thus cannot support controlPagedResultResponse as the third argument + // is a reference + throw new \InvalidArgumentException('Invoker does not support controlPagedResultResponse, call LDAP Wrapper directly instead.'); + } + if (!method_exists($this->ldap, $command)) { + return null; + } + array_unshift($arguments, $this->connection->getConnectionResource()); + $doMethod = function () use ($command, &$arguments) { + return call_user_func_array([$this->ldap, $command], $arguments); + }; + try { + $ret = $doMethod(); + } catch (ServerNotAvailableException $e) { + /* Server connection lost, attempt to reestablish it + * Maybe implement exponential backoff? + * This was enough to get solr indexer working which has large delays between LDAP fetches. + */ + $this->logger->debug("Connection lost on $command, attempting to reestablish.", ['app' => 'user_ldap']); + $this->connection->resetConnectionResource(); + $cr = $this->connection->getConnectionResource(); + + if (!$this->ldap->isResource($cr)) { + // Seems like we didn't find any resource. + $this->logger->debug("Could not $command, because resource is missing.", ['app' => 'user_ldap']); + throw $e; + } + + $arguments[0] = $cr; + $ret = $doMethod(); + } + return $ret; + } + + /** + * retrieved. Results will according to the order in the array. + * + * @param string $filter + * @param string $base + * @param string[] $attr + * @param int|null $limit optional, maximum results to be counted + * @param int|null $offset optional, a starting point + * @return array|false array with the search result as first value and pagedSearchOK as + * second | false if not successful + * @throws ServerNotAvailableException + */ + private function executeSearch( + string $filter, + string $base, + ?array &$attr, + ?int $pageSize, + ?int $offset, + ) { + // See if we have a resource, in case not cancel with message + $cr = $this->connection->getConnectionResource(); + + //check whether paged search should be attempted + try { + [$pagedSearchOK, $pageSize, $cookie] = $this->initPagedSearch($filter, $base, $attr, (int)$pageSize, (int)$offset); + } catch (NoMoreResults $e) { + // beyond last results page + return false; + } + + $sr = $this->invokeLDAPMethod('search', $base, $filter, $attr, 0, 0, $pageSize, $cookie); + $error = $this->ldap->errno($this->connection->getConnectionResource()); + if (!$this->ldap->isResource($sr) || $error !== 0) { + $this->logger->error('Attempt for Paging? ' . print_r($pagedSearchOK, true), ['app' => 'user_ldap']); + return false; + } + + return [$sr, $pagedSearchOK]; + } + + /** + * processes an LDAP paged search operation + * + * @param \LDAP\Result|\LDAP\Result[] $sr the array containing the LDAP search resources + * @param int $foundItems number of results in the single search operation + * @param int $limit maximum results to be counted + * @param bool $pagedSearchOK whether a paged search has been executed + * @param bool $skipHandling required for paged search when cookies to + * prior results need to be gained + * @return bool cookie validity, true if we have more pages, false otherwise. + * @throws ServerNotAvailableException + */ + private function processPagedSearchStatus( + $sr, + int $foundItems, + int $limit, + bool $pagedSearchOK, + bool $skipHandling, + ): bool { + $cookie = ''; + if ($pagedSearchOK) { + $cr = $this->connection->getConnectionResource(); + if ($this->ldap->controlPagedResultResponse($cr, $sr, $cookie)) { + $this->lastCookie = $cookie; + } + + //browsing through prior pages to get the cookie for the new one + if ($skipHandling) { + return false; + } + // if count is bigger, then the server does not support + // paged search. Instead, they did a normal search. We set a + // flag here, so the callee knows how to deal with it. + if ($foundItems <= $limit) { + $this->pagedSearchedSuccessful = true; + } + } else { + if ((int)$this->connection->ldapPagingSize !== 0) { + $this->logger->debug( + 'Paged search was not available', + ['app' => 'user_ldap'] + ); + } + } + /* ++ Fixing RHDS searches with pages with zero results ++ + * Return cookie status. If we don't have more pages, with RHDS + * cookie is null, with openldap cookie is an empty string and + * to 386ds '0' is a valid cookie. Even if $iFoundItems == 0 + */ + return !empty($cookie) || $cookie === '0'; + } + + /** + * executes an LDAP search, but counts the results only + * + * @param string $filter the LDAP filter for the search + * @param array $bases an array containing the LDAP subtree(s) that shall be searched + * @param ?string[] $attr optional, array, one or more attributes that shall be + * retrieved. Results will according to the order in the array. + * @param int $limit maximum results to be counted, 0 means no limit + * @param int $offset a starting point, defaults to 0 + * @param bool $skipHandling indicates whether the pages search operation is + * completed + * @return int|false Integer or false if the search could not be initialized + * @throws ServerNotAvailableException + */ + private function count( + string $filter, + array $bases, + ?array $attr = null, + int $limit = 0, + int $offset = 0, + bool $skipHandling = false, + ) { + $this->logger->debug('Count filter: {filter}', [ + 'app' => 'user_ldap', + 'filter' => $filter + ]); + + $limitPerPage = (int)$this->connection->ldapPagingSize; + if ($limit < $limitPerPage && $limit > 0) { + $limitPerPage = $limit; + } + + $counter = 0; + $count = null; + $this->connection->getConnectionResource(); + + foreach ($bases as $base) { + do { + $search = $this->executeSearch($filter, $base, $attr, $limitPerPage, $offset); + if ($search === false) { + return $counter > 0 ? $counter : false; + } + [$sr, $pagedSearchOK] = $search; + + /* ++ Fixing RHDS searches with pages with zero results ++ + * countEntriesInSearchResults() method signature changed + * by removing $limit and &$hasHitLimit parameters + */ + $count = $this->countEntriesInSearchResults($sr); + $counter += $count; + + $hasMorePages = $this->processPagedSearchStatus($sr, $count, $limitPerPage, $pagedSearchOK, $skipHandling); + $offset += $limitPerPage; + /* ++ Fixing RHDS searches with pages with zero results ++ + * Continue now depends on $hasMorePages value + */ + $continue = $pagedSearchOK && $hasMorePages; + } while ($continue && ($limit <= 0 || $limit > $counter)); + } + + return $counter; + } + + /** + * @param \LDAP\Result|\LDAP\Result[] $sr + * @return int + * @throws ServerNotAvailableException + */ + private function countEntriesInSearchResults($sr): int { + return (int)$this->invokeLDAPMethod('countEntries', $sr); + } + + /** + * Executes an LDAP search + * + * DN values in the result set are escaped as per RFC 2253 + * + * @throws ServerNotAvailableException + */ + public function search( + string $filter, + string $base, + ?array $attr = null, + ?int $limit = null, + ?int $offset = null, + bool $skipHandling = false, + ): array { + $limitPerPage = (int)$this->connection->ldapPagingSize; + if (!is_null($limit) && $limit < $limitPerPage && $limit > 0) { + $limitPerPage = $limit; + } + + /* ++ Fixing RHDS searches with pages with zero results ++ + * As we can have pages with zero results and/or pages with less + * than $limit results but with a still valid server 'cookie', + * loops through until we get $continue equals true and + * $findings['count'] < $limit + */ + $findings = []; + $offset = $offset ?? 0; + $savedoffset = $offset; + $iFoundItems = 0; + + do { + $search = $this->executeSearch($filter, $base, $attr, $limitPerPage, $offset); + if ($search === false) { + return []; + } + [$sr, $pagedSearchOK] = $search; + + if ($skipHandling) { + //i.e. result do not need to be fetched, we just need the cookie + //thus pass 1 or any other value as $iFoundItems because it is not + //used + $this->processPagedSearchStatus($sr, 1, $limitPerPage, $pagedSearchOK, $skipHandling); + return []; + } + + $findings = array_merge($findings, $this->invokeLDAPMethod('getEntries', $sr)); + $iFoundItems = max($iFoundItems, $findings['count']); + unset($findings['count']); + + $continue = $this->processPagedSearchStatus($sr, $iFoundItems, $limitPerPage, $pagedSearchOK, $skipHandling); + $offset += $limitPerPage; + } while ($continue && $pagedSearchOK && ($limit === null || count($findings) < $limit)); + + // resetting offset + $offset = $savedoffset; + + if (!is_null($attr)) { + $selection = []; + $i = 0; + foreach ($findings as $item) { + if (!is_array($item)) { + continue; + } + $item = Util::mb_array_change_key_case($item, MB_CASE_LOWER, 'UTF-8'); + foreach ($attr as $key) { + if (isset($item[$key])) { + if (is_array($item[$key]) && isset($item[$key]['count'])) { + unset($item[$key]['count']); + } + if ($key !== 'dn') { + if ($this->resemblesDN($key)) { + $selection[$i][$key] = $this->helper->sanitizeDN($item[$key]); + } elseif ($key === 'objectguid' || $key === 'guid') { + $selection[$i][$key] = [$this->convertObjectGUID2Str($item[$key][0])]; + } else { + $selection[$i][$key] = $item[$key]; + } + } else { + $selection[$i][$key] = [$this->helper->sanitizeDN($item[$key])]; + } + } + } + $i++; + } + $findings = $selection; + } + //we slice the findings, when + //a) paged search unsuccessful, though attempted + //b) no paged search, but limit set + if ((!$this->getPagedSearchResultState() + && $pagedSearchOK) + || ( + !$pagedSearchOK + && !is_null($limit) + ) + ) { + $findings = array_slice($findings, $offset, $limit); + } + return $findings; + } + + /** + * @param string $name + * @return string + * @throws \InvalidArgumentException + */ + public function sanitizeUsername($name) { + $name = trim($name); + + if ($this->connection->ldapIgnoreNamingRules) { + return $name; + } + + // Use htmlentities to get rid of accents + $name = htmlentities($name, ENT_NOQUOTES, 'UTF-8'); + + // Remove accents + $name = preg_replace('#&([A-Za-z])(?:acute|cedil|caron|circ|grave|orn|ring|slash|th|tilde|uml);#', '\1', $name); + // Remove ligatures + $name = preg_replace('#&([A-Za-z]{2})(?:lig);#', '\1', $name); + // Remove unknown leftover entities + $name = preg_replace('#&[^;]+;#', '', $name); + + // Replacements + $name = str_replace(' ', '_', $name); + + // Every remaining disallowed characters will be removed + $name = preg_replace('/[^a-zA-Z0-9_.@-]/u', '', $name); + + if (strlen($name) > 64) { + $name = hash('sha256', $name, false); + } + + if ($name === '') { + throw new \InvalidArgumentException('provided name template for username does not contain any allowed characters'); + } + + return $name; + } + + public function sanitizeGroupIDCandidate(string $candidate): string { + $candidate = trim($candidate); + if (strlen($candidate) > 64) { + $candidate = hash('sha256', $candidate, false); + } + if ($candidate === '') { + throw new \InvalidArgumentException('provided name template for username does not contain any allowed characters'); + } + + return $candidate; + } + + /** + * escapes (user provided) parts for LDAP filter + * + * @param string $input , the provided value + * @param bool $allowAsterisk whether in * at the beginning should be preserved + * @return string the escaped string + */ + public function escapeFilterPart($input, $allowAsterisk = false): string { + $asterisk = ''; + if ($allowAsterisk && strlen($input) > 0 && $input[0] === '*') { + $asterisk = '*'; + $input = mb_substr($input, 1, null, 'UTF-8'); + } + return $asterisk . ldap_escape($input, '', LDAP_ESCAPE_FILTER); + } + + /** + * combines the input filters with AND + * + * @param string[] $filters the filters to connect + * @return string the combined filter + */ + public function combineFilterWithAnd($filters): string { + return $this->combineFilter($filters, '&'); + } + + /** + * combines the input filters with OR + * + * @param string[] $filters the filters to connect + * @return string the combined filter + * Combines Filter arguments with OR + */ + public function combineFilterWithOr($filters) { + return $this->combineFilter($filters, '|'); + } + + /** + * combines the input filters with given operator + * + * @param string[] $filters the filters to connect + * @param string $operator either & or | + * @return string the combined filter + */ + private function combineFilter(array $filters, string $operator): string { + $combinedFilter = '(' . $operator; + foreach ($filters as $filter) { + if ($filter !== '' && $filter[0] !== '(') { + $filter = '(' . $filter . ')'; + } + $combinedFilter .= $filter; + } + $combinedFilter .= ')'; + return $combinedFilter; + } + + /** + * creates a filter part for to perform search for users + * + * @param string $search the search term + * @return string the final filter part to use in LDAP searches + */ + public function getFilterPartForUserSearch($search): string { + return $this->getFilterPartForSearch($search, + $this->connection->ldapAttributesForUserSearch, + $this->connection->ldapUserDisplayName); + } + + /** + * creates a filter part for to perform search for groups + * + * @param string $search the search term + * @return string the final filter part to use in LDAP searches + */ + public function getFilterPartForGroupSearch($search): string { + return $this->getFilterPartForSearch($search, + $this->connection->ldapAttributesForGroupSearch, + $this->connection->ldapGroupDisplayName); + } + + /** + * creates a filter part for searches by splitting up the given search + * string into single words + * + * @param string $search the search term + * @param string[]|null|'' $searchAttributes needs to have at least two attributes, + * otherwise it does not make sense :) + * @return string the final filter part to use in LDAP searches + * @throws DomainException + */ + private function getAdvancedFilterPartForSearch(string $search, $searchAttributes): string { + if (!is_array($searchAttributes) || count($searchAttributes) < 2) { + throw new DomainException('searchAttributes must be an array with at least two string'); + } + $searchWords = explode(' ', trim($search)); + $wordFilters = []; + foreach ($searchWords as $word) { + $word = $this->prepareSearchTerm($word); + //every word needs to appear at least once + $wordMatchOneAttrFilters = []; + foreach ($searchAttributes as $attr) { + $wordMatchOneAttrFilters[] = $attr . '=' . $word; + } + $wordFilters[] = $this->combineFilterWithOr($wordMatchOneAttrFilters); + } + return $this->combineFilterWithAnd($wordFilters); + } + + /** + * creates a filter part for searches + * + * @param string $search the search term + * @param string[]|null|'' $searchAttributes + * @param string $fallbackAttribute a fallback attribute in case the user + * did not define search attributes. Typically the display name attribute. + * @return string the final filter part to use in LDAP searches + */ + private function getFilterPartForSearch(string $search, $searchAttributes, string $fallbackAttribute): string { + $filter = []; + $haveMultiSearchAttributes = (is_array($searchAttributes) && count($searchAttributes) > 0); + if ($haveMultiSearchAttributes && str_contains(trim($search), ' ')) { + try { + return $this->getAdvancedFilterPartForSearch($search, $searchAttributes); + } catch (DomainException $e) { + // Creating advanced filter for search failed, falling back to simple method. Edge case, but valid. + } + } + + $originalSearch = $search; + $search = $this->prepareSearchTerm($search); + if (!is_array($searchAttributes) || count($searchAttributes) === 0) { + if ($fallbackAttribute === '') { + return ''; + } + // wildcards don't work with some attributes + if ($originalSearch !== '') { + $filter[] = $fallbackAttribute . '=' . $originalSearch; + } + $filter[] = $fallbackAttribute . '=' . $search; + } else { + foreach ($searchAttributes as $attribute) { + // wildcards don't work with some attributes + if ($originalSearch !== '') { + $filter[] = $attribute . '=' . $originalSearch; + } + $filter[] = $attribute . '=' . $search; + } + } + if (count($filter) === 1) { + return '(' . $filter[0] . ')'; + } + return $this->combineFilterWithOr($filter); + } + + /** + * returns the search term depending on whether we are allowed + * list users found by ldap with the current input appended by + * a * + */ + private function prepareSearchTerm(string $term): string { + $config = Server::get(IConfig::class); + + $allowEnum = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes'); + + $result = $term; + if ($term === '') { + $result = '*'; + } elseif ($allowEnum !== 'no') { + $result = $term . '*'; + } + return $result; + } + + /** + * returns the filter used for counting users + */ + public function getFilterForUserCount(): string { + $filter = $this->combineFilterWithAnd([ + $this->connection->ldapUserFilter, + $this->connection->ldapUserDisplayName . '=*' + ]); + + return $filter; + } + + public function areCredentialsValid(string $name, string $password): bool { + if ($name === '' || $password === '') { + return false; + } + $name = $this->helper->DNasBaseParameter($name); + $testConnection = clone $this->connection; + $credentials = [ + 'ldapAgentName' => $name, + 'ldapAgentPassword' => $password, + ]; + if (!$testConnection->setConfiguration($credentials)) { + return false; + } + return $testConnection->bind(); + } + + /** + * reverse lookup of a DN given a known UUID + * + * @param string $uuid + * @return string + * @throws \Exception + */ + public function getUserDnByUuid($uuid) { + $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; + $filter = $this->connection->ldapUserFilter; + $bases = $this->connection->ldapBaseUsers; + + if ($this->connection->ldapUuidUserAttribute === 'auto' && $uuidOverride === '') { + // Sacrebleu! The UUID attribute is unknown :( We need first an + // existing DN to be able to reliably detect it. + foreach ($bases as $base) { + $result = $this->search($filter, $base, ['dn'], 1); + if (!isset($result[0]) || !isset($result[0]['dn'])) { + continue; + } + $dn = $result[0]['dn'][0]; + if ($hasFound = $this->detectUuidAttribute($dn, true)) { + break; + } + } + if (!isset($hasFound) || !$hasFound) { + throw new \Exception('Cannot determine UUID attribute'); + } + } else { + // The UUID attribute is either known or an override is given. + // By calling this method we ensure that $this->connection->$uuidAttr + // is definitely set + if (!$this->detectUuidAttribute('', true)) { + throw new \Exception('Cannot determine UUID attribute'); + } + } + + $uuidAttr = $this->connection->ldapUuidUserAttribute; + if ($uuidAttr === 'guid' || $uuidAttr === 'objectguid') { + $uuid = $this->formatGuid2ForFilterUser($uuid); + } + + $filter = $uuidAttr . '=' . $uuid; + $result = $this->searchUsers($filter, ['dn'], 2); + if (isset($result[0]['dn']) && count($result) === 1) { + // we put the count into account to make sure that this is + // really unique + return $result[0]['dn'][0]; + } + + throw new \Exception('Cannot determine UUID attribute'); + } + + /** + * auto-detects the directory's UUID attribute + * + * @param string $dn a known DN used to check against + * @param bool $isUser + * @param bool $force the detection should be run, even if it is not set to auto + * @param array|null $ldapRecord + * @return bool true on success, false otherwise + * @throws ServerNotAvailableException + */ + private function detectUuidAttribute(string $dn, bool $isUser = true, bool $force = false, ?array $ldapRecord = null): bool { + if ($isUser) { + $uuidAttr = 'ldapUuidUserAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; + } else { + $uuidAttr = 'ldapUuidGroupAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; + } + + if (!$force) { + if ($this->connection->$uuidAttr !== 'auto') { + return true; + } elseif (is_string($uuidOverride) && trim($uuidOverride) !== '') { + $this->connection->$uuidAttr = $uuidOverride; + return true; + } + + $attribute = $this->connection->getFromCache($uuidAttr); + if ($attribute !== null) { + $this->connection->$uuidAttr = $attribute; + return true; + } + } + + foreach (self::UUID_ATTRIBUTES as $attribute) { + if ($ldapRecord !== null) { + // we have the info from LDAP already, we don't need to talk to the server again + if (isset($ldapRecord[$attribute])) { + $this->connection->$uuidAttr = $attribute; + return true; + } + } + + $value = $this->readAttribute($dn, $attribute); + if (is_array($value) && isset($value[0]) && !empty($value[0])) { + $this->logger->debug( + 'Setting {attribute} as {subject}', + [ + 'app' => 'user_ldap', + 'attribute' => $attribute, + 'subject' => $uuidAttr + ] + ); + $this->connection->$uuidAttr = $attribute; + $this->connection->writeToCache($uuidAttr, $attribute); + return true; + } + } + $this->logger->debug('Could not autodetect the UUID attribute', ['app' => 'user_ldap']); + + return false; + } + + /** + * @param array|null $ldapRecord + * @return false|string + * @throws ServerNotAvailableException + */ + public function getUUID(string $dn, bool $isUser = true, ?array $ldapRecord = null) { + if ($isUser) { + $uuidAttr = 'ldapUuidUserAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; + } else { + $uuidAttr = 'ldapUuidGroupAttribute'; + $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; + } + + $uuid = false; + if ($this->detectUuidAttribute($dn, $isUser, false, $ldapRecord)) { + $attr = $this->connection->$uuidAttr; + $uuid = $ldapRecord[$attr] ?? $this->readAttribute($dn, $attr); + if (!is_array($uuid) + && $uuidOverride !== '' + && $this->detectUuidAttribute($dn, $isUser, true, $ldapRecord)) { + $uuid = isset($ldapRecord[$this->connection->$uuidAttr]) + ? $ldapRecord[$this->connection->$uuidAttr] + : $this->readAttribute($dn, $this->connection->$uuidAttr); + } + if (is_array($uuid) && !empty($uuid[0])) { + $uuid = $uuid[0]; + } + } + + return $uuid; + } + + /** + * converts a binary ObjectGUID into a string representation + * + * @param string $oguid the ObjectGUID in its binary form as retrieved from AD + * @link https://www.php.net/manual/en/function.ldap-get-values-len.php#73198 + */ + private function convertObjectGUID2Str(string $oguid): string { + $hex_guid = bin2hex($oguid); + $hex_guid_to_guid_str = ''; + for ($k = 1; $k <= 4; ++$k) { + $hex_guid_to_guid_str .= substr($hex_guid, 8 - 2 * $k, 2); + } + $hex_guid_to_guid_str .= '-'; + for ($k = 1; $k <= 2; ++$k) { + $hex_guid_to_guid_str .= substr($hex_guid, 12 - 2 * $k, 2); + } + $hex_guid_to_guid_str .= '-'; + for ($k = 1; $k <= 2; ++$k) { + $hex_guid_to_guid_str .= substr($hex_guid, 16 - 2 * $k, 2); + } + $hex_guid_to_guid_str .= '-' . substr($hex_guid, 16, 4); + $hex_guid_to_guid_str .= '-' . substr($hex_guid, 20); + + return strtoupper($hex_guid_to_guid_str); + } + + /** + * the first three blocks of the string-converted GUID happen to be in + * reverse order. In order to use it in a filter, this needs to be + * corrected. Furthermore the dashes need to be replaced and \\ prepended + * to every two hex figures. + * + * If an invalid string is passed, it will be returned without change. + */ + public function formatGuid2ForFilterUser(string $guid): string { + $blocks = explode('-', $guid); + if (count($blocks) !== 5) { + /* + * Why not throw an Exception instead? This method is a utility + * called only when trying to figure out whether a "missing" known + * LDAP user was or was not renamed on the LDAP server. And this + * even on the use case that a reverse lookup is needed (UUID known, + * not DN), i.e. when finding users (search dialog, users page, + * login, …) this will not be fired. This occurs only if shares from + * a users are supposed to be mounted who cannot be found. Throwing + * an exception here would kill the experience for a valid, acting + * user. Instead we write a log message. + */ + $this->logger->info( + 'Passed string does not resemble a valid GUID. Known UUID ' + . '({uuid}) probably does not match UUID configuration.', + ['app' => 'user_ldap', 'uuid' => $guid] + ); + return $guid; + } + for ($i = 0; $i < 3; $i++) { + $pairs = str_split($blocks[$i], 2); + $pairs = array_reverse($pairs); + $blocks[$i] = implode('', $pairs); + } + for ($i = 0; $i < 5; $i++) { + $pairs = str_split($blocks[$i], 2); + $blocks[$i] = '\\' . implode('\\', $pairs); + } + return implode('', $blocks); + } + + /** + * gets a SID of the domain of the given dn + * + * @param string $dn + * @return string|bool + * @throws ServerNotAvailableException + */ + public function getSID($dn) { + $domainDN = $this->getDomainDNFromDN($dn); + $cacheKey = 'getSID-' . $domainDN; + $sid = $this->connection->getFromCache($cacheKey); + if (!is_null($sid)) { + return $sid; + } + + $objectSid = $this->readAttribute($domainDN, 'objectsid'); + if (!is_array($objectSid) || empty($objectSid)) { + $this->connection->writeToCache($cacheKey, false); + return false; + } + $domainObjectSid = $this->convertSID2Str($objectSid[0]); + $this->connection->writeToCache($cacheKey, $domainObjectSid); + + return $domainObjectSid; + } + + /** + * converts a binary SID into a string representation + * + * @param string $sid + * @return string + */ + public function convertSID2Str($sid) { + // The format of a SID binary string is as follows: + // 1 byte for the revision level + // 1 byte for the number n of variable sub-ids + // 6 bytes for identifier authority value + // n*4 bytes for n sub-ids + // + // Example: 010400000000000515000000a681e50e4d6c6c2bca32055f + // Legend: RRNNAAAAAAAAAAAA11111111222222223333333344444444 + $revision = ord($sid[0]); + $numberSubID = ord($sid[1]); + + $subIdStart = 8; // 1 + 1 + 6 + $subIdLength = 4; + if (strlen($sid) !== $subIdStart + $subIdLength * $numberSubID) { + // Incorrect number of bytes present. + return ''; + } + + // 6 bytes = 48 bits can be represented using floats without loss of + // precision (see https://gist.github.com/bantu/886ac680b0aef5812f71) + $iav = number_format(hexdec(bin2hex(substr($sid, 2, 6))), 0, '', ''); + + $subIDs = []; + for ($i = 0; $i < $numberSubID; $i++) { + $subID = unpack('V', substr($sid, $subIdStart + $subIdLength * $i, $subIdLength)); + $subIDs[] = sprintf('%u', $subID[1]); + } + + // Result for example above: S-1-5-21-249921958-728525901-1594176202 + return sprintf('S-%d-%s-%s', $revision, $iav, implode('-', $subIDs)); + } + + /** + * checks if the given DN is part of the given base DN(s) + * + * @param string[] $bases array containing the allowed base DN or DNs + */ + public function isDNPartOfBase(string $dn, array $bases): bool { + $belongsToBase = false; + $bases = $this->helper->sanitizeDN($bases); + + foreach ($bases as $base) { + $belongsToBase = true; + if (mb_strripos($dn, $base, 0, 'UTF-8') !== (mb_strlen($dn, 'UTF-8') - mb_strlen($base, 'UTF-8'))) { + $belongsToBase = false; + } + if ($belongsToBase) { + break; + } + } + return $belongsToBase; + } + + /** + * resets a running Paged Search operation + * + * @throws ServerNotAvailableException + */ + private function abandonPagedSearch(): void { + if ($this->lastCookie === '') { + return; + } + $this->getPagedSearchResultState(); + $this->lastCookie = ''; + } + + /** + * checks whether an LDAP paged search operation has more pages that can be + * retrieved, typically when offset and limit are provided. + * + * Be very careful to use it: the last cookie value, which is inspected, can + * be reset by other operations. Best, call it immediately after a search(), + * searchUsers() or searchGroups() call. count-methods are probably safe as + * well. Don't rely on it with any fetchList-method. + * + * @return bool + */ + public function hasMoreResults() { + if ($this->lastCookie === '') { + // as in RFC 2696, when all results are returned, the cookie will + // be empty. + return false; + } + + return true; + } + + /** + * Check whether the most recent paged search was successful. It flushed the state var. Use it always after a possible paged search. + * + * @return boolean|null true on success, null or false otherwise + */ + public function getPagedSearchResultState() { + $result = $this->pagedSearchedSuccessful; + $this->pagedSearchedSuccessful = null; + return $result; + } + + /** + * Prepares a paged search, if possible + * + * @param string $filter the LDAP filter for the search + * @param string $base the LDAP subtree that shall be searched + * @param string[] $attr optional, when a certain attribute shall be filtered outside + * @param int $limit + * @param int $offset + * @return array{bool, int, string} + * @throws ServerNotAvailableException + * @throws NoMoreResults + */ + private function initPagedSearch( + string $filter, + string $base, + ?array $attr, + int $pageSize, + int $offset, + ): array { + $pagedSearchOK = false; + if ($pageSize !== 0) { + $this->logger->debug( + 'initializing paged search for filter {filter}, base {base}, attr {attr}, pageSize {pageSize}, offset {offset}', + [ + 'app' => 'user_ldap', + 'filter' => $filter, + 'base' => $base, + 'attr' => $attr, + 'pageSize' => $pageSize, + 'offset' => $offset + ] + ); + // Get the cookie from the search for the previous search, required by LDAP + if (($this->lastCookie === '') && ($offset > 0)) { + // no cookie known from a potential previous search. We need + // to start from 0 to come to the desired page. cookie value + // of '0' is valid, because 389ds + $defaultPageSize = (int)$this->connection->ldapPagingSize; + if ($offset < $defaultPageSize) { + /* Make a search with offset as page size and dismiss the result, to init the cookie */ + $this->search($filter, $base, $attr, $offset, 0, true); + } else { + /* Make a search for previous page and dismiss the result, to init the cookie */ + $reOffset = $offset - $defaultPageSize; + $this->search($filter, $base, $attr, $defaultPageSize, $reOffset, true); + } + if (!$this->hasMoreResults()) { + // when the cookie is reset with != 0 offset, there are no further + // results, so stop. + throw new NoMoreResults(); + } + } + if ($this->lastCookie !== '' && $offset === 0) { + //since offset = 0, this is a new search. We abandon other searches that might be ongoing. + $this->abandonPagedSearch(); + } + $this->logger->debug('Ready for a paged search', ['app' => 'user_ldap']); + return [true, $pageSize, $this->lastCookie]; + /* ++ Fixing RHDS searches with pages with zero results ++ + * We couldn't get paged searches working with our RHDS for login ($limit = 0), + * due to pages with zero results. + * So we added "&& !empty($this->lastCookie)" to this test to ignore pagination + * if we don't have a previous paged search. + */ + } elseif ($this->lastCookie !== '') { + // a search without limit was requested. However, if we do use + // Paged Search once, we always must do it. This requires us to + // initialize it with the configured page size. + $this->abandonPagedSearch(); + // in case someone set it to 0 … use 500, otherwise no results will + // be returned. + $pageSize = (int)$this->connection->ldapPagingSize > 0 ? (int)$this->connection->ldapPagingSize : 500; + return [true, $pageSize, $this->lastCookie]; + } + + return [false, $pageSize, '']; + } + + /** + * Is more than one $attr used for search? + * + * @param string|string[]|null $attr + * @return bool + */ + private function manyAttributes($attr): bool { + if (\is_array($attr)) { + return \count($attr) > 1; + } + return false; + } +} diff --git a/apps/user_ldap/lib/AccessFactory.php b/apps/user_ldap/lib/AccessFactory.php new file mode 100644 index 00000000000..da114c467a7 --- /dev/null +++ b/apps/user_ldap/lib/AccessFactory.php @@ -0,0 +1,44 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OCA\User_LDAP\User\Manager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class AccessFactory { + + public function __construct( + private ILDAPWrapper $ldap, + private Helper $helper, + private IConfig $config, + private IAppConfig $appConfig, + private IUserManager $ncUserManager, + private LoggerInterface $logger, + private IEventDispatcher $dispatcher, + ) { + } + + public function get(Connection $connection): Access { + /* Each Access instance gets its own Manager instance, see OCA\User_LDAP\AppInfo\Application::register() */ + return new Access( + $this->ldap, + $connection, + Server::get(Manager::class), + $this->helper, + $this->config, + $this->ncUserManager, + $this->logger, + $this->appConfig, + $this->dispatcher, + ); + } +} diff --git a/apps/user_ldap/lib/AppInfo/Application.php b/apps/user_ldap/lib/AppInfo/Application.php new file mode 100644 index 00000000000..70b7920f7ab --- /dev/null +++ b/apps/user_ldap/lib/AppInfo/Application.php @@ -0,0 +1,152 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\AppInfo; + +use Closure; +use OCA\Files_External\Service\BackendService; +use OCA\User_LDAP\Controller\RenewPasswordController; +use OCA\User_LDAP\Events\GroupBackendRegistered; +use OCA\User_LDAP\Events\UserBackendRegistered; +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\GroupPluginManager; +use OCA\User_LDAP\Handler\ExtStorageConfigHandler; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\ILDAPWrapper; +use OCA\User_LDAP\LDAP; +use OCA\User_LDAP\LoginListener; +use OCA\User_LDAP\Notification\Notifier; +use OCA\User_LDAP\SetupChecks\LdapConnection; +use OCA\User_LDAP\SetupChecks\LdapInvalidUuids; +use OCA\User_LDAP\User\Manager; +use OCA\User_LDAP\User_Proxy; +use OCA\User_LDAP\UserPluginManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\IAppContainer; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\Image; +use OCP\IServerContainer; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\Share\IManager as IShareManager; +use OCP\User\Events\PostLoginEvent; +use OCP\Util; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class Application extends App implements IBootstrap { + public function __construct() { + parent::__construct('user_ldap'); + $container = $this->getContainer(); + + /** + * Controller + */ + $container->registerService('RenewPasswordController', function (IAppContainer $appContainer) { + /** @var IServerContainer $server */ + $server = $appContainer->get(IServerContainer::class); + + return new RenewPasswordController( + $appContainer->get('AppName'), + $server->getRequest(), + $appContainer->get('UserManager'), + $server->getConfig(), + $appContainer->get(IL10N::class), + $appContainer->get('Session'), + $server->getURLGenerator() + ); + }); + + $container->registerService(ILDAPWrapper::class, function (IAppContainer $appContainer) { + /** @var IServerContainer $server */ + $server = $appContainer->get(IServerContainer::class); + + return new LDAP( + $server->getConfig()->getSystemValueString('ldap_log_file') + ); + }); + } + + public function register(IRegistrationContext $context): void { + $context->registerNotifierService(Notifier::class); + + $context->registerService( + Manager::class, + function (ContainerInterface $c) { + return new Manager( + $c->get(IConfig::class), + $c->get(LoggerInterface::class), + $c->get(IAvatarManager::class), + $c->get(Image::class), + $c->get(IUserManager::class), + $c->get(INotificationManager::class), + $c->get(IShareManager::class), + ); + }, + // the instance is specific to a lazy bound Access instance, thus cannot be shared. + false + ); + $context->registerEventListener(PostLoginEvent::class, LoginListener::class); + $context->registerSetupCheck(LdapInvalidUuids::class); + $context->registerSetupCheck(LdapConnection::class); + } + + public function boot(IBootContext $context): void { + $context->injectFn(function ( + INotificationManager $notificationManager, + IAppContainer $appContainer, + IEventDispatcher $dispatcher, + IUserManager $userManager, + IGroupManager $groupManager, + User_Proxy $userBackend, + Group_Proxy $groupBackend, + Helper $helper, + ): void { + $configPrefixes = $helper->getServerConfigurationPrefixes(true); + if (count($configPrefixes) > 0) { + $userPluginManager = $appContainer->get(UserPluginManager::class); + $groupPluginManager = $appContainer->get(GroupPluginManager::class); + + $userManager->registerBackend($userBackend); + $groupManager->addBackend($groupBackend); + + $userBackendRegisteredEvent = new UserBackendRegistered($userBackend, $userPluginManager); + $dispatcher->dispatch('OCA\\User_LDAP\\User\\User::postLDAPBackendAdded', $userBackendRegisteredEvent); + $dispatcher->dispatchTyped($userBackendRegisteredEvent); + $groupBackendRegisteredEvent = new GroupBackendRegistered($groupBackend, $groupPluginManager); + $dispatcher->dispatchTyped($groupBackendRegisteredEvent); + } + }); + + $context->injectFn(Closure::fromCallable([$this, 'registerBackendDependents'])); + + Util::connectHook( + '\OCA\Files_Sharing\API\Server2Server', + 'preLoginNameUsedAsUserName', + '\OCA\User_LDAP\Helper', + 'loginName2UserName' + ); + } + + private function registerBackendDependents(IAppContainer $appContainer, IEventDispatcher $dispatcher): void { + $dispatcher->addListener( + 'OCA\\Files_External::loadAdditionalBackends', + function () use ($appContainer): void { + $storagesBackendService = $appContainer->get(BackendService::class); + $storagesBackendService->registerConfigHandler('home', function () use ($appContainer) { + return $appContainer->get(ExtStorageConfigHandler::class); + }); + } + ); + } +} diff --git a/apps/user_ldap/lib/BackendUtility.php b/apps/user_ldap/lib/BackendUtility.php new file mode 100644 index 00000000000..88d7311cde0 --- /dev/null +++ b/apps/user_ldap/lib/BackendUtility.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +abstract class BackendUtility { + /** + * constructor, make sure the subclasses call this one! + * @param Access $access an instance of Access for LDAP interaction + */ + public function __construct( + protected Access $access, + ) { + } +} diff --git a/apps/user_ldap/lib/Command/CheckGroup.php b/apps/user_ldap/lib/Command/CheckGroup.php new file mode 100644 index 00000000000..9c7ccb9d3b3 --- /dev/null +++ b/apps/user_ldap/lib/Command/CheckGroup.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Service\UpdateGroupsService; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Group\Events\GroupCreatedEvent; +use OCP\Group\Events\UserAddedEvent; +use OCP\Group\Events\UserRemovedEvent; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CheckGroup extends Command { + public function __construct( + private UpdateGroupsService $service, + protected Group_Proxy $backend, + protected Helper $helper, + protected GroupMapping $mapping, + protected IEventDispatcher $dispatcher, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:check-group') + ->setDescription('checks whether a group exists on LDAP.') + ->addArgument( + 'ocName', + InputArgument::REQUIRED, + 'the group name as used in Nextcloud, or the LDAP DN' + ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'ignores disabled LDAP configuration' + ) + ->addOption( + 'update', + null, + InputOption::VALUE_NONE, + 'syncs values from LDAP' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->dispatcher->addListener(GroupCreatedEvent::class, fn ($event) => $this->onGroupCreatedEvent($event, $output)); + $this->dispatcher->addListener(UserAddedEvent::class, fn ($event) => $this->onUserAddedEvent($event, $output)); + $this->dispatcher->addListener(UserRemovedEvent::class, fn ($event) => $this->onUserRemovedEvent($event, $output)); + try { + $this->assertAllowed($input->getOption('force')); + $gid = $input->getArgument('ocName'); + $wasMapped = $this->groupWasMapped($gid); + if ($this->backend->getLDAPAccess($gid)->stringResemblesDN($gid)) { + $groupname = $this->backend->dn2GroupName($gid); + if ($groupname !== false) { + $gid = $groupname; + } + } + /* Search to trigger mapping for new groups */ + $this->backend->getGroups($gid); + $exists = $this->backend->groupExistsOnLDAP($gid, true); + if ($exists === true) { + $output->writeln('The group is still available on LDAP.'); + if ($input->getOption('update')) { + $this->backend->getLDAPAccess($gid)->connection->clearCache(); + if ($wasMapped) { + $this->service->handleKnownGroups([$gid]); + } else { + $this->service->handleCreatedGroups([$gid]); + } + } + return self::SUCCESS; + } + + if ($wasMapped) { + $output->writeln('The group does not exist on LDAP anymore.'); + if ($input->getOption('update')) { + $this->backend->getLDAPAccess($gid)->connection->clearCache(); + $this->service->handleRemovedGroups([$gid]); + } + return self::SUCCESS; + } + + throw new \Exception('The given group is not a recognized LDAP group.'); + } catch (\Exception $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + } + + public function onGroupCreatedEvent(GroupCreatedEvent $event, OutputInterface $output): void { + $output->writeln('<info>The group ' . $event->getGroup()->getGID() . ' was added to Nextcloud with ' . $event->getGroup()->count() . ' users</info>'); + } + + public function onUserAddedEvent(UserAddedEvent $event, OutputInterface $output): void { + $user = $event->getUser(); + $group = $event->getGroup(); + $output->writeln('<info>The user ' . $user->getUID() . ' was added to group ' . $group->getGID() . '</info>'); + } + + public function onUserRemovedEvent(UserRemovedEvent $event, OutputInterface $output): void { + $user = $event->getUser(); + $group = $event->getGroup(); + $output->writeln('<info>The user ' . $user->getUID() . ' was removed from group ' . $group->getGID() . '</info>'); + } + + /** + * checks whether a group is actually mapped + * @param string $gid the groupname as passed to the command + */ + protected function groupWasMapped(string $gid): bool { + $dn = $this->mapping->getDNByName($gid); + if ($dn !== false) { + return true; + } + $name = $this->mapping->getNameByDN($gid); + return $name !== false; + } + + /** + * checks whether the setup allows reliable checking of LDAP group existence + * @throws \Exception + */ + protected function assertAllowed(bool $force): void { + if ($this->helper->haveDisabledConfigurations() && !$force) { + throw new \Exception('Cannot check group existence, because ' + . 'disabled LDAP configurations are present.'); + } + + // we don't check ldapUserCleanupInterval from config.php because this + // action is triggered manually, while the setting only controls the + // background job. + } + + private function updateGroup(string $gid, OutputInterface $output, bool $wasMapped): void { + try { + if ($wasMapped) { + $this->service->handleKnownGroups([$gid]); + } else { + $this->service->handleCreatedGroups([$gid]); + } + } catch (\Exception $e) { + $output->writeln('<error>Error while trying to lookup and update attributes from LDAP</error>'); + } + } +} diff --git a/apps/user_ldap/lib/Command/CheckUser.php b/apps/user_ldap/lib/Command/CheckUser.php new file mode 100644 index 00000000000..8bb26ce3d0e --- /dev/null +++ b/apps/user_ldap/lib/Command/CheckUser.php @@ -0,0 +1,135 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User_Proxy; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CheckUser extends Command { + public function __construct( + protected User_Proxy $backend, + protected Helper $helper, + protected DeletedUsersIndex $dui, + protected UserMapping $mapping, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:check-user') + ->setDescription('checks whether a user exists on LDAP.') + ->addArgument( + 'ocName', + InputArgument::REQUIRED, + 'the user name as used in Nextcloud, or the LDAP DN' + ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'ignores disabled LDAP configuration' + ) + ->addOption( + 'update', + null, + InputOption::VALUE_NONE, + 'syncs values from LDAP' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->assertAllowed($input->getOption('force')); + $uid = $input->getArgument('ocName'); + if ($this->backend->getLDAPAccess($uid)->stringResemblesDN($uid)) { + $username = $this->backend->dn2UserName($uid); + if ($username !== false) { + $uid = $username; + } + } + $wasMapped = $this->userWasMapped($uid); + $exists = $this->backend->userExistsOnLDAP($uid, true); + if ($exists === true) { + $output->writeln('The user is still available on LDAP.'); + if ($input->getOption('update')) { + $this->updateUser($uid, $output); + } + return self::SUCCESS; + } + + if ($wasMapped) { + $this->dui->markUser($uid); + $output->writeln('The user does not exists on LDAP anymore.'); + $output->writeln('Clean up the user\'s remnants by: ./occ user:delete "' + . $uid . '"'); + return self::SUCCESS; + } + + throw new \Exception('The given user is not a recognized LDAP user.'); + } catch (\Exception $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + } + + /** + * checks whether a user is actually mapped + * @param string $ocName the username as used in Nextcloud + */ + protected function userWasMapped(string $ocName): bool { + $dn = $this->mapping->getDNByName($ocName); + return $dn !== false; + } + + /** + * checks whether the setup allows reliable checking of LDAP user existence + * @throws \Exception + */ + protected function assertAllowed(bool $force): void { + if ($this->helper->haveDisabledConfigurations() && !$force) { + throw new \Exception('Cannot check user existence, because ' + . 'disabled LDAP configurations are present.'); + } + + // we don't check ldapUserCleanupInterval from config.php because this + // action is triggered manually, while the setting only controls the + // background job. + } + + private function updateUser(string $uid, OutputInterface $output): void { + try { + $access = $this->backend->getLDAPAccess($uid); + $attrs = $access->userManager->getAttributes(); + $user = $access->userManager->get($uid); + $avatarAttributes = $access->getConnection()->resolveRule('avatar'); + $baseDn = $this->helper->DNasBaseParameter($user->getDN()); + $result = $access->search('objectclass=*', $baseDn, $attrs, 1, 0); + foreach ($result[0] as $attribute => $valueSet) { + $output->writeln(' ' . $attribute . ': '); + foreach ($valueSet as $value) { + if (in_array($attribute, $avatarAttributes)) { + $value = '{ImageData}'; + } + $output->writeln(' ' . $value); + } + } + $access->batchApplyUserAttributes($result); + } catch (\Exception $e) { + $output->writeln('<error>Error while trying to lookup and update attributes from LDAP</error>'); + } + } +} diff --git a/apps/user_ldap/lib/Command/CreateEmptyConfig.php b/apps/user_ldap/lib/Command/CreateEmptyConfig.php new file mode 100644 index 00000000000..7c381cf431f --- /dev/null +++ b/apps/user_ldap/lib/Command/CreateEmptyConfig.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\Helper; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CreateEmptyConfig extends Command { + public function __construct( + protected Helper $helper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:create-empty-config') + ->setDescription('creates an empty LDAP configuration') + ->addOption( + 'only-print-prefix', + 'p', + InputOption::VALUE_NONE, + 'outputs only the prefix' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $configPrefix = $this->helper->getNextServerConfigurationPrefix(); + $configHolder = new Configuration($configPrefix); + $configHolder->ldapConfigurationActive = false; + $configHolder->saveConfiguration(); + + $prose = ''; + if (!$input->getOption('only-print-prefix')) { + $prose = 'Created new configuration with configID '; + } + $output->writeln($prose . "{$configPrefix}"); + return self::SUCCESS; + } +} diff --git a/apps/user_ldap/lib/Command/DeleteConfig.php b/apps/user_ldap/lib/Command/DeleteConfig.php new file mode 100644 index 00000000000..7604e229bed --- /dev/null +++ b/apps/user_ldap/lib/Command/DeleteConfig.php @@ -0,0 +1,48 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Helper; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class DeleteConfig extends Command { + public function __construct( + protected Helper $helper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:delete-config') + ->setDescription('deletes an existing LDAP configuration') + ->addArgument( + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $configPrefix = $input->getArgument('configID'); + + $success = $this->helper->deleteServerConfiguration($configPrefix); + + if (!$success) { + $output->writeln("Cannot delete configuration with configID '{$configPrefix}'"); + return self::FAILURE; + } + + $output->writeln("Deleted configuration with configID '{$configPrefix}'"); + return self::SUCCESS; + } +} diff --git a/apps/user_ldap/lib/Command/PromoteGroup.php b/apps/user_ldap/lib/Command/PromoteGroup.php new file mode 100644 index 00000000000..b203a910b14 --- /dev/null +++ b/apps/user_ldap/lib/Command/PromoteGroup.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Group_Proxy; +use OCP\IGroup; +use OCP\IGroupManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +class PromoteGroup extends Command { + + public function __construct( + private IGroupManager $groupManager, + private Group_Proxy $backend, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:promote-group') + ->setDescription('declares the specified group as admin group (only one is possible per LDAP configuration)') + ->addArgument( + 'group', + InputArgument::REQUIRED, + 'the group ID in Nextcloud or a group name' + ) + ->addOption( + 'yes', + 'y', + InputOption::VALUE_NONE, + 'do not ask for confirmation' + ); + } + + protected function formatGroupName(IGroup $group): string { + $idLabel = ''; + if ($group->getGID() !== $group->getDisplayName()) { + $idLabel = sprintf(' (Group ID: %s)', $group->getGID()); + } + return sprintf('%s%s', $group->getDisplayName(), $idLabel); + } + + protected function promoteGroup(IGroup $group, InputInterface $input, OutputInterface $output): void { + $access = $this->backend->getLDAPAccess($group->getGID()); + $currentlyPromotedGroupId = $access->connection->ldapAdminGroup; + if ($currentlyPromotedGroupId === $group->getGID()) { + $output->writeln('<info>The specified group is already promoted</info>'); + return; + } + + if ($input->getOption('yes') === false) { + $currentlyPromotedGroup = $this->groupManager->get($currentlyPromotedGroupId); + $demoteLabel = ''; + if ($currentlyPromotedGroup instanceof IGroup && $this->backend->groupExists($currentlyPromotedGroup->getGID())) { + $groupNameLabel = $this->formatGroupName($currentlyPromotedGroup); + $demoteLabel = sprintf('and demote %s ', $groupNameLabel); + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $q = new Question(sprintf('Promote %s to the admin group %s(y|N)? ', $this->formatGroupName($group), $demoteLabel)); + $input->setOption('yes', $helper->ask($input, $output, $q) === 'y'); + } + if ($input->getOption('yes') === true) { + $access->connection->setConfiguration(['ldapAdminGroup' => $group->getGID()]); + $access->connection->saveConfiguration(); + $output->writeln(sprintf('<info>Group %s was promoted</info>', $group->getDisplayName())); + } else { + $output->writeln('<comment>Group promotion cancelled</comment>'); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $groupInput = (string)$input->getArgument('group'); + $group = $this->groupManager->get($groupInput); + + if ($group instanceof IGroup && $this->backend->groupExists($group->getGID())) { + $this->promoteGroup($group, $input, $output); + return 0; + } + + $groupCandidates = $this->backend->getGroups($groupInput, 20); + foreach ($groupCandidates as $gidCandidate) { + $group = $this->groupManager->get($gidCandidate); + if ($group !== null + && $this->backend->groupExists($group->getGID()) // ensure it is an LDAP group + && ($group->getGID() === $groupInput + || $group->getDisplayName() === $groupInput) + ) { + $this->promoteGroup($group, $input, $output); + return 0; + } + } + + $output->writeln('<error>No matching group found</error>'); + return 1; + } + +} diff --git a/apps/user_ldap/lib/Command/ResetGroup.php b/apps/user_ldap/lib/Command/ResetGroup.php new file mode 100644 index 00000000000..5833ca980f2 --- /dev/null +++ b/apps/user_ldap/lib/Command/ResetGroup.php @@ -0,0 +1,87 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\GroupPluginManager; +use OCP\IGroup; +use OCP\IGroupManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +class ResetGroup extends Command { + public function __construct( + private IGroupManager $groupManager, + private GroupPluginManager $pluginManager, + private Group_Proxy $backend, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:reset-group') + ->setDescription('deletes an LDAP group independent of the group state in the LDAP') + ->addArgument( + 'gid', + InputArgument::REQUIRED, + 'the group name as used in Nextcloud' + ) + ->addOption( + 'yes', + 'y', + InputOption::VALUE_NONE, + 'do not ask for confirmation' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $gid = $input->getArgument('gid'); + $group = $this->groupManager->get($gid); + if (!$group instanceof IGroup) { + throw new \Exception('Group not found'); + } + $backends = $group->getBackendNames(); + if (!in_array('LDAP', $backends)) { + throw new \Exception('The given group is not a recognized LDAP group.'); + } + if ($input->getOption('yes') === false) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $q = new Question('Delete all local data of this group (y|N)? '); + $input->setOption('yes', $helper->ask($input, $output, $q) === 'y'); + } + if ($input->getOption('yes') !== true) { + throw new \Exception('Reset cancelled by operator'); + } + + // Disable real deletion if a plugin supports it + $pluginManagerSuppressed = $this->pluginManager->setSuppressDeletion(true); + // Bypass groupExists test to force mapping deletion + $this->backend->getLDAPAccess($gid)->connection->writeToCache('groupExists' . $gid, false); + echo "calling delete $gid\n"; + if ($group->delete()) { + $this->pluginManager->setSuppressDeletion($pluginManagerSuppressed); + return self::SUCCESS; + } + } catch (\Throwable $e) { + if (isset($pluginManagerSuppressed)) { + $this->pluginManager->setSuppressDeletion($pluginManagerSuppressed); + } + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + $output->writeln('<error>Error while resetting group</error>'); + return self::INVALID; + } +} diff --git a/apps/user_ldap/lib/Command/ResetUser.php b/apps/user_ldap/lib/Command/ResetUser.php new file mode 100644 index 00000000000..1409806e4ac --- /dev/null +++ b/apps/user_ldap/lib/Command/ResetUser.php @@ -0,0 +1,85 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User_Proxy; +use OCA\User_LDAP\UserPluginManager; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +class ResetUser extends Command { + public function __construct( + protected DeletedUsersIndex $dui, + private IUserManager $userManager, + private UserPluginManager $pluginManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:reset-user') + ->setDescription('deletes an LDAP user independent of the user state') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'the user id as used in Nextcloud' + ) + ->addOption( + 'yes', + 'y', + InputOption::VALUE_NONE, + 'do not ask for confirmation' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $uid = $input->getArgument('uid'); + $user = $this->userManager->get($uid); + if (!$user instanceof IUser) { + throw new \Exception('User not found'); + } + $backend = $user->getBackend(); + if (!$backend instanceof User_Proxy) { + throw new \Exception('The given user is not a recognized LDAP user.'); + } + if ($input->getOption('yes') === false) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $q = new Question('Delete all local data of this user (y|N)? '); + $input->setOption('yes', $helper->ask($input, $output, $q) === 'y'); + } + if ($input->getOption('yes') !== true) { + throw new \Exception('Reset cancelled by operator'); + } + + $this->dui->markUser($uid); + $pluginManagerSuppressed = $this->pluginManager->setSuppressDeletion(true); + if ($user->delete()) { + $this->pluginManager->setSuppressDeletion($pluginManagerSuppressed); + return self::SUCCESS; + } + } catch (\Throwable $e) { + if (isset($pluginManagerSuppressed)) { + $this->pluginManager->setSuppressDeletion($pluginManagerSuppressed); + } + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + $output->writeln('<error>Error while resetting user</error>'); + return self::INVALID; + } +} diff --git a/apps/user_ldap/lib/Command/Search.php b/apps/user_ldap/lib/Command/Search.php new file mode 100644 index 00000000000..85906b20e9a --- /dev/null +++ b/apps/user_ldap/lib/Command/Search.php @@ -0,0 +1,115 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\LDAP; +use OCA\User_LDAP\User_Proxy; +use OCP\IConfig; +use OCP\Server; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Search extends Command { + public function __construct( + protected IConfig $ocConfig, + private User_Proxy $userProxy, + private Group_Proxy $groupProxy, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:search') + ->setDescription('executes a user or group search') + ->addArgument( + 'search', + InputArgument::REQUIRED, + 'the search string (can be empty)' + ) + ->addOption( + 'group', + null, + InputOption::VALUE_NONE, + 'searches groups instead of users' + ) + ->addOption( + 'offset', + null, + InputOption::VALUE_REQUIRED, + 'The offset of the result set. Needs to be a multiple of limit. defaults to 0.', + '0' + ) + ->addOption( + 'limit', + null, + InputOption::VALUE_REQUIRED, + 'limit the results. 0 means no limit, defaults to 15', + '15' + ) + ; + } + + /** + * Tests whether the offset and limit options are valid + * + * @throws \InvalidArgumentException + */ + protected function validateOffsetAndLimit(int $offset, int $limit): void { + if ($limit < 0) { + throw new \InvalidArgumentException('limit must be 0 or greater'); + } + if ($offset < 0) { + throw new \InvalidArgumentException('offset must be 0 or greater'); + } + if ($limit === 0 && $offset !== 0) { + throw new \InvalidArgumentException('offset must be 0 if limit is also set to 0'); + } + if ($offset > 0 && ($offset % $limit !== 0)) { + throw new \InvalidArgumentException('offset must be a multiple of limit'); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $helper = Server::get(Helper::class); + $configPrefixes = $helper->getServerConfigurationPrefixes(true); + $ldapWrapper = new LDAP(); + + $offset = (int)$input->getOption('offset'); + $limit = (int)$input->getOption('limit'); + $this->validateOffsetAndLimit($offset, $limit); + + if ($input->getOption('group')) { + $proxy = $this->groupProxy; + $getMethod = 'getGroups'; + $printID = false; + // convert the limit of groups to null. This will show all the groups available instead of + // nothing, and will match the same behaviour the search for users has. + if ($limit === 0) { + $limit = null; + } + } else { + $proxy = $this->userProxy; + $getMethod = 'getDisplayNames'; + $printID = true; + } + + $result = $proxy->$getMethod($input->getArgument('search'), $limit, $offset); + foreach ($result as $id => $name) { + $line = $name . ($printID ? ' (' . $id . ')' : ''); + $output->writeln($line); + } + return self::SUCCESS; + } +} diff --git a/apps/user_ldap/lib/Command/SetConfig.php b/apps/user_ldap/lib/Command/SetConfig.php new file mode 100644 index 00000000000..7e9efcf34d0 --- /dev/null +++ b/apps/user_ldap/lib/Command/SetConfig.php @@ -0,0 +1,71 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\ConnectionFactory; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\LDAP; +use OCP\Server; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class SetConfig extends Command { + protected function configure(): void { + $this + ->setName('ldap:set-config') + ->setDescription('modifies an LDAP configuration') + ->addArgument( + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) + ->addArgument( + 'configKey', + InputArgument::REQUIRED, + 'the configuration key' + ) + ->addArgument( + 'configValue', + InputArgument::REQUIRED, + 'the new configuration value' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $helper = Server::get(Helper::class); + $availableConfigs = $helper->getServerConfigurationPrefixes(); + $configID = $input->getArgument('configID'); + if (!in_array($configID, $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; + } + + $this->setValue( + $configID, + $input->getArgument('configKey'), + $input->getArgument('configValue') + ); + return self::SUCCESS; + } + + /** + * save the configuration value as provided + */ + protected function setValue(string $configID, string $key, string $value): void { + $configHolder = new Configuration($configID); + $configHolder->$key = $value; + $configHolder->saveConfiguration(); + + $connectionFactory = new ConnectionFactory(new LDAP()); + $connectionFactory->get($configID)->clearCache(); + } +} diff --git a/apps/user_ldap/lib/Command/ShowConfig.php b/apps/user_ldap/lib/Command/ShowConfig.php new file mode 100644 index 00000000000..fa021192ac4 --- /dev/null +++ b/apps/user_ldap/lib/Command/ShowConfig.php @@ -0,0 +1,119 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OC\Core\Command\Base; +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\Helper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ShowConfig extends Base { + public function __construct( + protected Helper $helper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:show-config') + ->setDescription('shows the LDAP configuration') + ->addArgument( + 'configID', + InputArgument::OPTIONAL, + 'will show the configuration of the specified id' + ) + ->addOption( + 'show-password', + null, + InputOption::VALUE_NONE, + 'show ldap bind password' + ) + ->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (table, plain, json or json_pretty, default is table)', + 'table' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $availableConfigs = $this->helper->getServerConfigurationPrefixes(); + $configID = $input->getArgument('configID'); + if (!is_null($configID)) { + $configIDs[] = $configID; + if (!in_array($configIDs[0], $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; + } + } else { + $configIDs = $availableConfigs; + } + + $this->renderConfigs($configIDs, $input, $output); + return self::SUCCESS; + } + + /** + * prints the LDAP configuration(s) + * + * @param string[] $configIDs + */ + protected function renderConfigs( + array $configIDs, + InputInterface $input, + OutputInterface $output, + ): void { + $renderTable = $input->getOption('output') === 'table' or $input->getOption('output') === null; + $showPassword = $input->getOption('show-password'); + + $configs = []; + foreach ($configIDs as $id) { + $configHolder = new Configuration($id); + $configuration = $configHolder->getConfiguration(); + ksort($configuration); + + $rows = []; + if ($renderTable) { + foreach ($configuration as $key => $value) { + if (is_array($value)) { + $value = implode(';', $value); + } + if ($key === 'ldapAgentPassword' && !$showPassword) { + $rows[] = [$key, '***']; + } else { + $rows[] = [$key, $value]; + } + } + $table = new Table($output); + $table->setHeaders(['Configuration', $id]); + $table->setRows($rows); + $table->render(); + continue; + } + + foreach ($configuration as $key => $value) { + if ($key === 'ldapAgentPassword' && !$showPassword) { + $rows[$key] = '***'; + } else { + $rows[$key] = $value; + } + } + $configs[$id] = $rows; + } + if (!$renderTable) { + $this->writeArrayInOutputFormat($input, $output, $configs); + } + } +} diff --git a/apps/user_ldap/lib/Command/ShowRemnants.php b/apps/user_ldap/lib/Command/ShowRemnants.php new file mode 100644 index 00000000000..d255aac1368 --- /dev/null +++ b/apps/user_ldap/lib/Command/ShowRemnants.php @@ -0,0 +1,80 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCP\IDateTimeFormatter; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; + +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ShowRemnants extends Command { + public function __construct( + protected DeletedUsersIndex $dui, + protected IDateTimeFormatter $dateFormatter, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:show-remnants') + ->setDescription('shows which users are not available on LDAP anymore, but have remnants in Nextcloud.') + ->addOption('json', null, InputOption::VALUE_NONE, 'return JSON array instead of pretty table.') + ->addOption('short-date', null, InputOption::VALUE_NONE, 'show dates in Y-m-d format'); + } + + protected function formatDate(int $timestamp, string $default, bool $showShortDate): string { + if (!($timestamp > 0)) { + return $default; + } + if ($showShortDate) { + return date('Y-m-d', $timestamp); + } + return $this->dateFormatter->formatDate($timestamp); + } + + /** + * executes the command, i.e. creates and outputs a table of LDAP users marked as deleted + * + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + /** @var \Symfony\Component\Console\Helper\Table $table */ + $table = new Table($output); + $table->setHeaders([ + 'Nextcloud name', 'Display Name', 'LDAP UID', 'LDAP DN', 'Last Login', + 'Detected on', 'Dir', 'Sharer' + ]); + $rows = []; + $resultSet = $this->dui->getUsers(); + foreach ($resultSet as $user) { + $rows[] = [ + 'ocName' => $user->getOCName(), + 'displayName' => $user->getDisplayName(), + 'uid' => $user->getUID(), + 'dn' => $user->getDN(), + 'lastLogin' => $this->formatDate($user->getLastLogin(), '-', (bool)$input->getOption('short-date')), + 'detectedOn' => $this->formatDate($user->getDetectedOn(), 'unknown', (bool)$input->getOption('short-date')), + 'homePath' => $user->getHomePath(), + 'sharer' => $user->getHasActiveShares() ? 'Y' : 'N', + ]; + } + + if ($input->getOption('json')) { + $output->writeln(json_encode($rows)); + } else { + $table->setRows($rows); + $table->render(); + } + return self::SUCCESS; + } +} diff --git a/apps/user_ldap/lib/Command/TestConfig.php b/apps/user_ldap/lib/Command/TestConfig.php new file mode 100644 index 00000000000..77eaac91d85 --- /dev/null +++ b/apps/user_ldap/lib/Command/TestConfig.php @@ -0,0 +1,94 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\AccessFactory; +use OCA\User_LDAP\Connection; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\ILDAPWrapper; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class TestConfig extends Command { + protected const ESTABLISHED = 0; + protected const CONF_INVALID = 1; + protected const BINDFAILURE = 2; + protected const SEARCHFAILURE = 3; + + public function __construct( + protected AccessFactory $accessFactory, + protected Helper $helper, + protected ILDAPWrapper $ldap, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:test-config') + ->setDescription('tests an LDAP configuration') + ->addArgument( + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $availableConfigs = $this->helper->getServerConfigurationPrefixes(); + $configID = $input->getArgument('configID'); + if (!in_array($configID, $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; + } + + $result = $this->testConfig($configID); + + $message = match ($result) { + static::ESTABLISHED => 'The configuration is valid and the connection could be established!', + static::CONF_INVALID => 'The configuration is invalid. Please have a look at the logs for further details.', + static::BINDFAILURE => 'The configuration is valid, but the bind failed. Please check the server settings and credentials.', + static::SEARCHFAILURE => 'The configuration is valid and the bind passed, but a simple search on the base fails. Please check the server base setting.', + default => 'Your LDAP server was kidnapped by aliens.', + }; + + $output->writeln($message); + + return $result === static::ESTABLISHED + ? self::SUCCESS + : self::FAILURE; + } + + /** + * Tests the specified connection + */ + protected function testConfig(string $configID): int { + $connection = new Connection($this->ldap, $configID); + + // Ensure validation is run before we attempt the bind + $connection->getConfiguration(); + + if (!$connection->setConfiguration([ + 'ldap_configuration_active' => 1, + ])) { + return static::CONF_INVALID; + } + if (!$connection->bind()) { + return static::BINDFAILURE; + } + $access = $this->accessFactory->get($connection); + $result = $access->countObjects(1); + if (!is_int($result) || ($result <= 0)) { + return static::SEARCHFAILURE; + } + return static::ESTABLISHED; + } +} diff --git a/apps/user_ldap/lib/Command/TestUserSettings.php b/apps/user_ldap/lib/Command/TestUserSettings.php new file mode 100644 index 00000000000..12690158f98 --- /dev/null +++ b/apps/user_ldap/lib/Command/TestUserSettings.php @@ -0,0 +1,248 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User_Proxy; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class TestUserSettings extends Command { + public function __construct( + protected User_Proxy $backend, + protected Group_Proxy $groupBackend, + protected Helper $helper, + protected DeletedUsersIndex $dui, + protected UserMapping $mapping, + protected GroupMapping $groupMapping, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:test-user-settings') + ->setDescription('Runs tests and show information about user related LDAP settings') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'the user name as used in Nextcloud, or the LDAP DN' + ) + ->addOption( + 'group', + 'g', + InputOption::VALUE_REQUIRED, + 'A group DN to check if the user is a member or not' + ) + ->addOption( + 'clearcache', + null, + InputOption::VALUE_NONE, + 'Clear the cache of the LDAP connection before the beginning of tests' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $uid = $input->getArgument('user'); + $access = $this->backend->getLDAPAccess($uid); + $connection = $access->getConnection(); + if ($input->getOption('clearcache')) { + $connection->clearCache(); + } + $configPrefix = $connection->getConfigPrefix(); + $knownDn = ''; + if ($access->stringResemblesDN($uid)) { + $knownDn = $uid; + $username = $access->dn2username($uid); + if ($username !== false) { + $uid = $username; + } + } + + $dn = $this->mapping->getDNByName($uid); + if ($dn !== false) { + $output->writeln("User <info>$dn</info> is mapped with account name <info>$uid</info>."); + $uuid = $this->mapping->getUUIDByDN($dn); + $output->writeln("Known UUID is <info>$uuid</info>."); + if ($knownDn === '') { + $knownDn = $dn; + } + } else { + $output->writeln("User <info>$uid</info> is not mapped."); + } + + if ($knownDn === '') { + return self::SUCCESS; + } + + if (!$access->isDNPartOfBase($knownDn, $access->getConnection()->ldapBaseUsers)) { + $output->writeln( + "User <info>$knownDn</info> is not in one of the configured user bases: <info>" + . implode(',', $access->getConnection()->ldapBaseUsers) + . '</info>.' + ); + } + + $output->writeln("Configuration prefix is <info>$configPrefix</info>"); + $output->writeln(''); + + $attributeNames = [ + 'ldapBase', + 'ldapBaseUsers', + 'ldapExpertUsernameAttr', + 'ldapUuidUserAttribute', + 'ldapExpertUUIDUserAttr', + 'ldapQuotaAttribute', + 'ldapEmailAttribute', + 'ldapUserDisplayName', + 'ldapUserDisplayName2', + 'ldapExtStorageHomeAttribute', + 'ldapAttributePhone', + 'ldapAttributeWebsite', + 'ldapAttributeAddress', + 'ldapAttributeTwitter', + 'ldapAttributeFediverse', + 'ldapAttributeOrganisation', + 'ldapAttributeRole', + 'ldapAttributeHeadline', + 'ldapAttributeBiography', + 'ldapAttributeBirthDate', + 'ldapAttributePronouns', + 'ldapGidNumber', + 'hasGidNumber', + ]; + $output->writeln('Attributes set in configuration:'); + foreach ($attributeNames as $attributeName) { + if (($connection->$attributeName !== '') && ($connection->$attributeName !== [])) { + if (\is_string($connection->$attributeName)) { + $output->writeln("- $attributeName: <info>" . $connection->$attributeName . '</info>'); + } else { + $output->writeln("- $attributeName: <info>" . \json_encode($connection->$attributeName) . '</info>'); + } + } + } + + $filter = $connection->ldapUserFilter; + $attrs = $access->userManager->getAttributes(true); + $attrs[] = strtolower($connection->ldapExpertUsernameAttr); + if ($connection->ldapUuidUserAttribute !== 'auto') { + $attrs[] = strtolower($connection->ldapUuidUserAttribute); + } + if ($connection->hasGidNumber) { + $attrs[] = strtolower($connection->ldapGidNumber); + } + $attrs[] = 'memberof'; + $attrs = array_values(array_unique($attrs)); + $attributes = $access->readAttributes($knownDn, $attrs, $filter); + + if ($attributes === false) { + $output->writeln( + "LDAP read on <info>$knownDn</info> with filter <info>$filter</info> failed." + ); + return self::FAILURE; + } + + $output->writeln("Attributes fetched from LDAP using filter <info>$filter</info>:"); + foreach ($attributes as $attribute => $value) { + $output->writeln( + "- $attribute: <info>" . json_encode($value) . '</info>' + ); + } + + $uuid = $access->getUUID($knownDn); + if ($connection->ldapUuidUserAttribute === 'auto') { + $output->writeln('<error>Failed to detect UUID attribute</error>'); + } else { + $output->writeln('Detected UUID attribute: <info>' . $connection->ldapUuidUserAttribute . '</info>'); + } + if ($uuid === false) { + $output->writeln("<error>Failed to find UUID for $knownDn</error>"); + } else { + $output->writeln("UUID for <info>$knownDn</info>: <info>$uuid</info>"); + } + + $groupLdapInstance = $this->groupBackend->getBackend($configPrefix); + + $output->writeln(''); + $output->writeln('Group information:'); + + $attributeNames = [ + 'ldapBaseGroups', + 'ldapDynamicGroupMemberURL', + 'ldapGroupFilter', + 'ldapGroupMemberAssocAttr', + ]; + $output->writeln('Configuration:'); + foreach ($attributeNames as $attributeName) { + if ($connection->$attributeName !== '') { + $output->writeln("- $attributeName: <info>" . $connection->$attributeName . '</info>'); + } + } + + $primaryGroup = $groupLdapInstance->getUserPrimaryGroup($knownDn); + $output->writeln('Primary group: <info>' . ($primaryGroup !== false? $primaryGroup:'') . '</info>'); + + $groupByGid = $groupLdapInstance->getUserGroupByGid($knownDn); + $output->writeln('Group from gidNumber: <info>' . ($groupByGid !== false? $groupByGid:'') . '</info>'); + + $groups = $groupLdapInstance->getUserGroups($uid); + $output->writeln('All known groups: <info>' . json_encode($groups) . '</info>'); + + $memberOfUsed = ((int)$access->connection->hasMemberOfFilterSupport === 1 + && (int)$access->connection->useMemberOfToDetectMembership === 1); + + $output->writeln('MemberOf usage: <info>' . ($memberOfUsed ? 'on' : 'off') . '</info> (' . $access->connection->hasMemberOfFilterSupport . ',' . $access->connection->useMemberOfToDetectMembership . ')'); + + $gid = (string)$input->getOption('group'); + if ($gid === '') { + return self::SUCCESS; + } + + $output->writeln(''); + $output->writeln("Group $gid:"); + $knownGroupDn = ''; + if ($access->stringResemblesDN($gid)) { + $knownGroupDn = $gid; + $groupname = $access->dn2groupname($gid); + if ($groupname !== false) { + $gid = $groupname; + } + } + + $groupDn = $this->groupMapping->getDNByName($gid); + if ($groupDn !== false) { + $output->writeln("Group <info>$groupDn</info> is mapped with name <info>$gid</info>."); + $groupUuid = $this->groupMapping->getUUIDByDN($groupDn); + $output->writeln("Known UUID is <info>$groupUuid</info>."); + if ($knownGroupDn === '') { + $knownGroupDn = $groupDn; + } + } else { + $output->writeln("Group <info>$gid</info> is not mapped."); + } + + $members = $groupLdapInstance->usersInGroup($gid); + $output->writeln('Members: <info>' . json_encode($members) . '</info>'); + + return self::SUCCESS; + + } catch (\Exception $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + } +} diff --git a/apps/user_ldap/lib/Command/UpdateUUID.php b/apps/user_ldap/lib/Command/UpdateUUID.php new file mode 100644 index 00000000000..93dcc37bada --- /dev/null +++ b/apps/user_ldap/lib/Command/UpdateUUID.php @@ -0,0 +1,338 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Access; +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Mapping\AbstractMapping; +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User_Proxy; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use function sprintf; + +class UuidUpdateReport { + public const UNCHANGED = 0; + public const UNKNOWN = 1; + public const UNREADABLE = 2; + public const UPDATED = 3; + public const UNWRITABLE = 4; + public const UNMAPPED = 5; + + public function __construct( + public string $id, + public string $dn, + public bool $isUser, + public int $state, + public string $oldUuid = '', + public string $newUuid = '', + ) { + } +} + +class UpdateUUID extends Command { + /** @var array<UuidUpdateReport[]> */ + protected array $reports = []; + private bool $dryRun = false; + + public function __construct( + private UserMapping $userMapping, + private GroupMapping $groupMapping, + private User_Proxy $userProxy, + private Group_Proxy $groupProxy, + private LoggerInterface $logger, + ) { + $this->reports = [ + UuidUpdateReport::UPDATED => [], + UuidUpdateReport::UNKNOWN => [], + UuidUpdateReport::UNREADABLE => [], + UuidUpdateReport::UNWRITABLE => [], + UuidUpdateReport::UNMAPPED => [], + ]; + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:update-uuid') + ->setDescription('Attempts to update UUIDs of user and group entries. By default, the command attempts to update UUIDs that have been invalidated by a migration step.') + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'updates every user and group. All other options are ignored.' + ) + ->addOption( + 'userId', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a user ID to update' + ) + ->addOption( + 'groupId', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a group ID to update' + ) + ->addOption( + 'dn', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a DN to update' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'UUIDs will not be updated in the database' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->dryRun = $input->getOption('dry-run'); + $entriesToUpdate = $this->estimateNumberOfUpdates($input); + $progress = new ProgressBar($output); + $progress->start($entriesToUpdate); + foreach ($this->handleUpdates($input) as $_) { + $progress->advance(); + } + $progress->finish(); + $output->writeln(''); + $this->printReport($output); + return count($this->reports[UuidUpdateReport::UNMAPPED]) === 0 + && count($this->reports[UuidUpdateReport::UNREADABLE]) === 0 + && count($this->reports[UuidUpdateReport::UNWRITABLE]) === 0 + ? self::SUCCESS + : self::FAILURE; + } + + protected function printReport(OutputInterface $output): void { + if ($output->isQuiet()) { + return; + } + + if (count($this->reports[UuidUpdateReport::UPDATED]) === 0) { + $output->writeln('<info>No record was updated.</info>'); + } else { + $output->writeln(sprintf('<info>%d record(s) were updated.</info>', count($this->reports[UuidUpdateReport::UPDATED]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UPDATED] as $report) { + $output->writeln(sprintf(' %s had their old UUID %s updated to %s', $report->id, $report->oldUuid, $report->newUuid)); + } + $output->writeln(''); + } + } + + if (count($this->reports[UuidUpdateReport::UNMAPPED]) > 0) { + $output->writeln(sprintf('<error>%d provided IDs were not mapped. These were:</error>', count($this->reports[UuidUpdateReport::UNMAPPED]))); + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNMAPPED] as $report) { + if (!empty($report->id)) { + $output->writeln(sprintf(' %s: %s', + $report->isUser ? 'User' : 'Group', $report->id)); + } elseif (!empty($report->dn)) { + $output->writeln(sprintf(' DN: %s', $report->dn)); + } + } + $output->writeln(''); + } + + if (count($this->reports[UuidUpdateReport::UNKNOWN]) > 0) { + $output->writeln(sprintf('<info>%d provided IDs were unknown on LDAP.</info>', count($this->reports[UuidUpdateReport::UNKNOWN]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNKNOWN] as $report) { + $output->writeln(sprintf(' %s: %s', $report->isUser ? 'User' : 'Group', $report->id)); + } + $output->writeln(PHP_EOL . 'Old users can be removed along with their data per occ user:delete.' . PHP_EOL); + } + } + + if (count($this->reports[UuidUpdateReport::UNREADABLE]) > 0) { + $output->writeln(sprintf('<error>For %d records, the UUID could not be read. Double-check your configuration.</error>', count($this->reports[UuidUpdateReport::UNREADABLE]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNREADABLE] as $report) { + $output->writeln(sprintf(' %s: %s', $report->isUser ? 'User' : 'Group', $report->id)); + } + } + } + + if (count($this->reports[UuidUpdateReport::UNWRITABLE]) > 0) { + $output->writeln(sprintf('<error>For %d records, the UUID could not be saved to database. Double-check your configuration.</error>', count($this->reports[UuidUpdateReport::UNWRITABLE]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNWRITABLE] as $report) { + $output->writeln(sprintf(' %s: %s', $report->isUser ? 'User' : 'Group', $report->id)); + } + } + } + } + + protected function handleUpdates(InputInterface $input): \Generator { + if ($input->getOption('all')) { + foreach ($this->handleMappingBasedUpdates(false) as $_) { + yield; + } + } elseif ($input->getOption('userId') + || $input->getOption('groupId') + || $input->getOption('dn') + ) { + foreach ($this->handleUpdatesByUserId($input->getOption('userId')) as $_) { + yield; + } + foreach ($this->handleUpdatesByGroupId($input->getOption('groupId')) as $_) { + yield; + } + foreach ($this->handleUpdatesByDN($input->getOption('dn')) as $_) { + yield; + } + } else { + foreach ($this->handleMappingBasedUpdates(true) as $_) { + yield; + } + } + } + + protected function handleUpdatesByUserId(array $userIds): \Generator { + foreach ($this->handleUpdatesByEntryId($userIds, $this->userMapping) as $_) { + yield; + } + } + + protected function handleUpdatesByGroupId(array $groupIds): \Generator { + foreach ($this->handleUpdatesByEntryId($groupIds, $this->groupMapping) as $_) { + yield; + } + } + + protected function handleUpdatesByDN(array $dns): \Generator { + $userList = $groupList = []; + while ($dn = array_pop($dns)) { + $uuid = $this->userMapping->getUUIDByDN($dn); + if ($uuid) { + $id = $this->userMapping->getNameByDN($dn); + $userList[] = ['name' => $id, 'uuid' => $uuid]; + continue; + } + $uuid = $this->groupMapping->getUUIDByDN($dn); + if ($uuid) { + $id = $this->groupMapping->getNameByDN($dn); + $groupList[] = ['name' => $id, 'uuid' => $uuid]; + continue; + } + $this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport('', $dn, true, UuidUpdateReport::UNMAPPED); + yield; + } + foreach ($this->handleUpdatesByList($this->userMapping, $userList) as $_) { + yield; + } + foreach ($this->handleUpdatesByList($this->groupMapping, $groupList) as $_) { + yield; + } + } + + protected function handleUpdatesByEntryId(array $ids, AbstractMapping $mapping): \Generator { + $isUser = $mapping instanceof UserMapping; + $list = []; + while ($id = array_pop($ids)) { + if (!$dn = $mapping->getDNByName($id)) { + $this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport($id, '', $isUser, UuidUpdateReport::UNMAPPED); + yield; + continue; + } + // Since we know it was mapped the UUID is populated + $uuid = $mapping->getUUIDByDN($dn); + $list[] = ['name' => $id, 'uuid' => $uuid]; + } + foreach ($this->handleUpdatesByList($mapping, $list) as $_) { + yield; + } + } + + protected function handleMappingBasedUpdates(bool $invalidatedOnly): \Generator { + $limit = 1000; + /** @var AbstractMapping $mapping */ + foreach ([$this->userMapping, $this->groupMapping] as $mapping) { + $offset = 0; + do { + $list = $mapping->getList($offset, $limit, $invalidatedOnly); + $offset += $limit; + + foreach ($this->handleUpdatesByList($mapping, $list) as $tick) { + yield; // null, for it only advances progress counter + } + } while (count($list) === $limit); + } + } + + protected function handleUpdatesByList(AbstractMapping $mapping, array $list): \Generator { + if ($mapping instanceof UserMapping) { + $isUser = true; + $backendProxy = $this->userProxy; + } else { + $isUser = false; + $backendProxy = $this->groupProxy; + } + + foreach ($list as $row) { + $access = $backendProxy->getLDAPAccess($row['name']); + if ($access instanceof Access + && $dn = $mapping->getDNByName($row['name'])) { + if ($uuid = $access->getUUID($dn, $isUser)) { + if ($uuid !== $row['uuid']) { + if ($this->dryRun || $mapping->setUUIDbyDN($uuid, $dn)) { + $this->reports[UuidUpdateReport::UPDATED][] + = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UPDATED, $row['uuid'], $uuid); + } else { + $this->reports[UuidUpdateReport::UNWRITABLE][] + = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNWRITABLE, $row['uuid'], $uuid); + } + $this->logger->info('UUID of {id} was updated from {from} to {to}', + [ + 'appid' => 'user_ldap', + 'id' => $row['name'], + 'from' => $row['uuid'], + 'to' => $uuid, + ] + ); + } + } else { + $this->reports[UuidUpdateReport::UNREADABLE][] = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNREADABLE); + } + } else { + $this->reports[UuidUpdateReport::UNKNOWN][] = new UuidUpdateReport($row['name'], '', $isUser, UuidUpdateReport::UNKNOWN); + } + yield; // null, for it only advances progress counter + } + } + + protected function estimateNumberOfUpdates(InputInterface $input): int { + if ($input->getOption('all')) { + return $this->userMapping->count() + $this->groupMapping->count(); + } elseif ($input->getOption('userId') + || $input->getOption('groupId') + || $input->getOption('dn') + ) { + return count($input->getOption('userId')) + + count($input->getOption('groupId')) + + count($input->getOption('dn')); + } else { + return $this->userMapping->countInvalidated() + $this->groupMapping->countInvalidated(); + } + } +} diff --git a/apps/user_ldap/lib/Configuration.php b/apps/user_ldap/lib/Configuration.php new file mode 100644 index 00000000000..b4a5b847204 --- /dev/null +++ b/apps/user_ldap/lib/Configuration.php @@ -0,0 +1,687 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OCP\IConfig; +use OCP\Server; +use Psr\Log\LoggerInterface; + +/** + * @property string $ldapHost + * @property string $ldapPort + * @property string $ldapBackupHost + * @property string $ldapBackupPort + * @property string $ldapBackgroundHost + * @property string $ldapBackgroundPort + * @property array|'' $ldapBase + * @property array|'' $ldapBaseUsers + * @property array|'' $ldapBaseGroups + * @property string $ldapAgentName + * @property string $ldapAgentPassword + * @property string $ldapTLS + * @property string $turnOffCertCheck + * @property string $ldapIgnoreNamingRules + * @property string $ldapUserDisplayName + * @property string $ldapUserDisplayName2 + * @property string $ldapUserAvatarRule + * @property string $ldapGidNumber + * @property array|'' $ldapUserFilterObjectclass + * @property array|'' $ldapUserFilterGroups + * @property string $ldapUserFilter + * @property string $ldapUserFilterMode + * @property string $ldapGroupFilter + * @property string $ldapGroupFilterMode + * @property array|'' $ldapGroupFilterObjectclass + * @property array|'' $ldapGroupFilterGroups + * @property string $ldapGroupDisplayName + * @property string $ldapGroupMemberAssocAttr + * @property string $ldapLoginFilter + * @property string $ldapLoginFilterMode + * @property string $ldapLoginFilterEmail + * @property string $ldapLoginFilterUsername + * @property array|'' $ldapLoginFilterAttributes + * @property string $ldapQuotaAttribute + * @property string $ldapQuotaDefault + * @property string $ldapEmailAttribute + * @property string $ldapCacheTTL + * @property string $ldapUuidUserAttribute + * @property string $ldapUuidGroupAttribute + * @property string $ldapOverrideMainServer + * @property string $ldapConfigurationActive + * @property array|'' $ldapAttributesForUserSearch + * @property array|'' $ldapAttributesForGroupSearch + * @property string $ldapExperiencedAdmin + * @property string $homeFolderNamingRule + * @property string $hasMemberOfFilterSupport + * @property string $useMemberOfToDetectMembership + * @property string $ldapExpertUsernameAttr + * @property string $ldapExpertUUIDUserAttr + * @property string $ldapExpertUUIDGroupAttr + * @property string $markRemnantsAsDisabled + * @property string $lastJpegPhotoLookup + * @property string $ldapNestedGroups + * @property string $ldapPagingSize + * @property string $turnOnPasswordChange + * @property string $ldapDynamicGroupMemberURL + * @property string $ldapDefaultPPolicyDN + * @property string $ldapExtStorageHomeAttribute + * @property string $ldapMatchingRuleInChainState + * @property string $ldapConnectionTimeout + * @property string $ldapAttributePhone + * @property string $ldapAttributeWebsite + * @property string $ldapAttributeAddress + * @property string $ldapAttributeTwitter + * @property string $ldapAttributeFediverse + * @property string $ldapAttributeOrganisation + * @property string $ldapAttributeRole + * @property string $ldapAttributeHeadline + * @property string $ldapAttributeBiography + * @property string $ldapAdminGroup + * @property string $ldapAttributeBirthDate + * @property string $ldapAttributePronouns + */ +class Configuration { + public const AVATAR_PREFIX_DEFAULT = 'default'; + public const AVATAR_PREFIX_NONE = 'none'; + public const AVATAR_PREFIX_DATA_ATTRIBUTE = 'data:'; + + public const LDAP_SERVER_FEATURE_UNKNOWN = 'unknown'; + public const LDAP_SERVER_FEATURE_AVAILABLE = 'available'; + public const LDAP_SERVER_FEATURE_UNAVAILABLE = 'unavailable'; + /** + * @var bool + */ + protected $configRead = false; + /** + * @var string[] + */ + protected array $unsavedChanges = []; + + /** + * @var array<string, mixed> settings + */ + protected $config = [ + 'ldapHost' => null, + 'ldapPort' => null, + 'ldapBackupHost' => null, + 'ldapBackupPort' => null, + 'ldapBackgroundHost' => null, + 'ldapBackgroundPort' => null, + 'ldapBase' => null, + 'ldapBaseUsers' => null, + 'ldapBaseGroups' => null, + 'ldapAgentName' => null, + 'ldapAgentPassword' => null, + 'ldapTLS' => null, + 'turnOffCertCheck' => null, + 'ldapIgnoreNamingRules' => null, + 'ldapUserDisplayName' => null, + 'ldapUserDisplayName2' => null, + 'ldapUserAvatarRule' => null, + 'ldapGidNumber' => null, + 'ldapUserFilterObjectclass' => null, + 'ldapUserFilterGroups' => null, + 'ldapUserFilter' => null, + 'ldapUserFilterMode' => null, + 'ldapGroupFilter' => null, + 'ldapGroupFilterMode' => null, + 'ldapGroupFilterObjectclass' => null, + 'ldapGroupFilterGroups' => null, + 'ldapGroupDisplayName' => null, + 'ldapGroupMemberAssocAttr' => null, + 'ldapLoginFilter' => null, + 'ldapLoginFilterMode' => null, + 'ldapLoginFilterEmail' => null, + 'ldapLoginFilterUsername' => null, + 'ldapLoginFilterAttributes' => null, + 'ldapQuotaAttribute' => null, + 'ldapQuotaDefault' => null, + 'ldapEmailAttribute' => null, + 'ldapCacheTTL' => null, + 'ldapUuidUserAttribute' => 'auto', + 'ldapUuidGroupAttribute' => 'auto', + 'ldapOverrideMainServer' => false, + 'ldapConfigurationActive' => false, + 'ldapAttributesForUserSearch' => null, + 'ldapAttributesForGroupSearch' => null, + 'ldapExperiencedAdmin' => false, + 'homeFolderNamingRule' => null, + 'hasMemberOfFilterSupport' => false, + 'useMemberOfToDetectMembership' => true, + 'ldapExpertUsernameAttr' => null, + 'ldapExpertUUIDUserAttr' => null, + 'ldapExpertUUIDGroupAttr' => null, + 'markRemnantsAsDisabled' => false, + 'lastJpegPhotoLookup' => null, + 'ldapNestedGroups' => false, + 'ldapPagingSize' => null, + 'turnOnPasswordChange' => false, + 'ldapDynamicGroupMemberURL' => null, + 'ldapDefaultPPolicyDN' => null, + 'ldapExtStorageHomeAttribute' => null, + 'ldapMatchingRuleInChainState' => self::LDAP_SERVER_FEATURE_UNKNOWN, + 'ldapConnectionTimeout' => 15, + 'ldapAttributePhone' => null, + 'ldapAttributeWebsite' => null, + 'ldapAttributeAddress' => null, + 'ldapAttributeTwitter' => null, + 'ldapAttributeFediverse' => null, + 'ldapAttributeOrganisation' => null, + 'ldapAttributeRole' => null, + 'ldapAttributeHeadline' => null, + 'ldapAttributeBiography' => null, + 'ldapAdminGroup' => '', + 'ldapAttributeBirthDate' => null, + 'ldapAttributeAnniversaryDate' => null, + 'ldapAttributePronouns' => null, + ]; + + public function __construct( + protected string $configPrefix, + bool $autoRead = true, + ) { + if ($autoRead) { + $this->readConfiguration(); + } + } + + /** + * @param string $name + * @return mixed|null + */ + public function __get($name) { + if (isset($this->config[$name])) { + return $this->config[$name]; + } + return null; + } + + /** + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) { + $this->setConfiguration([$name => $value]); + } + + public function getConfiguration(): array { + return $this->config; + } + + /** + * set LDAP configuration with values delivered by an array, not read + * from configuration. It does not save the configuration! To do so, you + * must call saveConfiguration afterwards. + * @param array $config array that holds the config parameters in an associated + * array + * @param array &$applied optional; array where the set fields will be given to + */ + public function setConfiguration(array $config, ?array &$applied = null): void { + $cta = $this->getConfigTranslationArray(); + foreach ($config as $inputKey => $val) { + if (str_contains($inputKey, '_') && array_key_exists($inputKey, $cta)) { + $key = $cta[$inputKey]; + } elseif (array_key_exists($inputKey, $this->config)) { + $key = $inputKey; + } else { + continue; + } + + $setMethod = 'setValue'; + switch ($key) { + case 'ldapAgentPassword': + $setMethod = 'setRawValue'; + break; + case 'homeFolderNamingRule': + $trimmedVal = trim($val); + if ($trimmedVal !== '' && !str_contains($val, 'attr:')) { + $val = 'attr:' . $trimmedVal; + } + break; + case 'ldapBase': + case 'ldapBaseUsers': + case 'ldapBaseGroups': + case 'ldapAttributesForUserSearch': + case 'ldapAttributesForGroupSearch': + case 'ldapUserFilterObjectclass': + case 'ldapUserFilterGroups': + case 'ldapGroupFilterObjectclass': + case 'ldapGroupFilterGroups': + case 'ldapLoginFilterAttributes': + $setMethod = 'setMultiLine'; + break; + } + $this->$setMethod($key, $val); + if (is_array($applied)) { + $applied[] = $inputKey; + // storing key as index avoids duplication, and as value for simplicity + } + $this->unsavedChanges[$key] = $key; + } + } + + public function readConfiguration(): void { + if (!$this->configRead) { + $cta = array_flip($this->getConfigTranslationArray()); + foreach ($this->config as $key => $val) { + if (!isset($cta[$key])) { + //some are determined + continue; + } + $dbKey = $cta[$key]; + switch ($key) { + case 'ldapBase': + case 'ldapBaseUsers': + case 'ldapBaseGroups': + case 'ldapAttributesForUserSearch': + case 'ldapAttributesForGroupSearch': + case 'ldapUserFilterObjectclass': + case 'ldapUserFilterGroups': + case 'ldapGroupFilterObjectclass': + case 'ldapGroupFilterGroups': + case 'ldapLoginFilterAttributes': + $readMethod = 'getMultiLine'; + break; + case 'ldapIgnoreNamingRules': + $readMethod = 'getSystemValue'; + $dbKey = $key; + break; + case 'ldapAgentPassword': + $readMethod = 'getPwd'; + break; + case 'ldapUserDisplayName2': + case 'ldapGroupDisplayName': + case 'ldapGidNumber': + case 'ldapGroupMemberAssocAttr': + case 'ldapQuotaAttribute': + case 'ldapEmailAttribute': + case 'ldapUuidUserAttribute': + case 'ldapUuidGroupAttribute': + case 'ldapExpertUsernameAttr': + case 'ldapExpertUUIDUserAttr': + case 'ldapExpertUUIDGroupAttr': + case 'ldapExtStorageHomeAttribute': + case 'ldapAttributePhone': + case 'ldapAttributeWebsite': + case 'ldapAttributeAddress': + case 'ldapAttributeTwitter': + case 'ldapAttributeFediverse': + case 'ldapAttributeOrganisation': + case 'ldapAttributeRole': + case 'ldapAttributeHeadline': + case 'ldapAttributeBiography': + case 'ldapAttributeBirthDate': + case 'ldapAttributeAnniversaryDate': + case 'ldapAttributePronouns': + $readMethod = 'getLcValue'; + break; + case 'ldapUserDisplayName': + default: + // user display name does not lower case because + // we rely on an upper case N as indicator whether to + // auto-detect it or not. FIXME + $readMethod = 'getValue'; + break; + } + $this->config[$key] = $this->$readMethod($dbKey); + } + $this->configRead = true; + } + } + + /** + * saves the current config changes in the database + */ + public function saveConfiguration(): void { + $cta = array_flip($this->getConfigTranslationArray()); + $changed = false; + foreach ($this->unsavedChanges as $key) { + $value = $this->config[$key]; + switch ($key) { + case 'ldapAgentPassword': + $value = base64_encode($value); + break; + case 'ldapBase': + case 'ldapBaseUsers': + case 'ldapBaseGroups': + case 'ldapAttributesForUserSearch': + case 'ldapAttributesForGroupSearch': + case 'ldapUserFilterObjectclass': + case 'ldapUserFilterGroups': + case 'ldapGroupFilterObjectclass': + case 'ldapGroupFilterGroups': + case 'ldapLoginFilterAttributes': + if (is_array($value)) { + $value = implode("\n", $value); + } + break; + //following options are not stored but detected, skip them + case 'ldapIgnoreNamingRules': + case 'ldapUuidUserAttribute': + case 'ldapUuidGroupAttribute': + continue 2; + } + if (is_null($value)) { + $value = ''; + } + $changed = true; + $this->saveValue($cta[$key], $value); + } + if ($changed) { + $this->saveValue('_lastChange', (string)time()); + } + $this->unsavedChanges = []; + } + + /** + * @param string $varName + * @return array|string + */ + protected function getMultiLine($varName) { + $value = $this->getValue($varName); + if (empty($value)) { + $value = ''; + } else { + $value = preg_split('/\r\n|\r|\n/', $value); + } + + return $value; + } + + /** + * Sets multi-line values as arrays + * + * @param string $varName name of config-key + * @param array|string $value to set + */ + protected function setMultiLine(string $varName, $value): void { + if (empty($value)) { + $value = ''; + } elseif (!is_array($value)) { + $value = preg_split('/\r\n|\r|\n|;/', $value); + if ($value === false) { + $value = ''; + } + } + + if (!is_array($value)) { + $finalValue = trim($value); + } else { + $finalValue = []; + foreach ($value as $key => $val) { + if (is_string($val)) { + $val = trim($val); + if ($val !== '') { + //accidental line breaks are not wanted and can cause + // odd behaviour. Thus, away with them. + $finalValue[] = $val; + } + } else { + $finalValue[] = $val; + } + } + } + + $this->setRawValue($varName, $finalValue); + } + + protected function getPwd(string $varName): string { + return base64_decode($this->getValue($varName)); + } + + protected function getLcValue(string $varName): string { + return mb_strtolower($this->getValue($varName), 'UTF-8'); + } + + protected function getSystemValue(string $varName): string { + //FIXME: if another system value is added, softcode the default value + return Server::get(IConfig::class)->getSystemValue($varName, false); + } + + protected function getValue(string $varName): string { + static $defaults; + if (is_null($defaults)) { + $defaults = $this->getDefaults(); + } + return Server::get(IConfig::class)->getAppValue('user_ldap', + $this->configPrefix . $varName, + $defaults[$varName]); + } + + /** + * Sets a scalar value. + * + * @param string $varName name of config key + * @param mixed $value to set + */ + protected function setValue(string $varName, $value): void { + if (is_string($value)) { + $value = trim($value); + } + $this->config[$varName] = $value; + } + + /** + * Sets a scalar value without trimming. + * + * @param string $varName name of config key + * @param mixed $value to set + */ + protected function setRawValue(string $varName, $value): void { + $this->config[$varName] = $value; + } + + protected function saveValue(string $varName, string $value): bool { + Server::get(IConfig::class)->setAppValue( + 'user_ldap', + $this->configPrefix . $varName, + $value + ); + return true; + } + + /** + * @return array an associative array with the default values. Keys are correspond + * to config-value entries in the database table + */ + public function getDefaults(): array { + return [ + 'ldap_host' => '', + 'ldap_port' => '', + 'ldap_backup_host' => '', + 'ldap_backup_port' => '', + 'ldap_background_host' => '', + 'ldap_background_port' => '', + 'ldap_override_main_server' => '', + 'ldap_dn' => '', + 'ldap_agent_password' => '', + 'ldap_base' => '', + 'ldap_base_users' => '', + 'ldap_base_groups' => '', + 'ldap_userlist_filter' => '', + 'ldap_user_filter_mode' => 0, + 'ldap_userfilter_objectclass' => '', + 'ldap_userfilter_groups' => '', + 'ldap_login_filter' => '', + 'ldap_login_filter_mode' => 0, + 'ldap_loginfilter_email' => 0, + 'ldap_loginfilter_username' => 1, + 'ldap_loginfilter_attributes' => '', + 'ldap_group_filter' => '', + 'ldap_group_filter_mode' => 0, + 'ldap_groupfilter_objectclass' => '', + 'ldap_groupfilter_groups' => '', + 'ldap_gid_number' => 'gidNumber', + 'ldap_display_name' => 'displayName', + 'ldap_user_display_name_2' => '', + 'ldap_group_display_name' => 'cn', + 'ldap_tls' => 0, + 'ldap_quota_def' => '', + 'ldap_quota_attr' => '', + 'ldap_email_attr' => '', + 'ldap_group_member_assoc_attribute' => '', + 'ldap_cache_ttl' => 600, + 'ldap_uuid_user_attribute' => 'auto', + 'ldap_uuid_group_attribute' => 'auto', + 'home_folder_naming_rule' => '', + 'ldap_turn_off_cert_check' => 0, + 'ldap_configuration_active' => 0, + 'ldap_attributes_for_user_search' => '', + 'ldap_attributes_for_group_search' => '', + 'ldap_expert_username_attr' => '', + 'ldap_expert_uuid_user_attr' => '', + 'ldap_expert_uuid_group_attr' => '', + 'has_memberof_filter_support' => 0, + 'use_memberof_to_detect_membership' => 1, + 'ldap_mark_remnants_as_disabled' => 0, + 'last_jpegPhoto_lookup' => 0, + 'ldap_nested_groups' => 0, + 'ldap_paging_size' => 500, + 'ldap_turn_on_pwd_change' => 0, + 'ldap_experienced_admin' => 0, + 'ldap_dynamic_group_member_url' => '', + 'ldap_default_ppolicy_dn' => '', + 'ldap_user_avatar_rule' => 'default', + 'ldap_ext_storage_home_attribute' => '', + 'ldap_matching_rule_in_chain_state' => self::LDAP_SERVER_FEATURE_UNKNOWN, + 'ldap_connection_timeout' => 15, + 'ldap_attr_phone' => '', + 'ldap_attr_website' => '', + 'ldap_attr_address' => '', + 'ldap_attr_twitter' => '', + 'ldap_attr_fediverse' => '', + 'ldap_attr_organisation' => '', + 'ldap_attr_role' => '', + 'ldap_attr_headline' => '', + 'ldap_attr_biography' => '', + 'ldap_admin_group' => '', + 'ldap_attr_birthdate' => '', + 'ldap_attr_anniversarydate' => '', + 'ldap_attr_pronouns' => '', + ]; + } + + /** + * @return array that maps internal variable names to database fields + */ + public function getConfigTranslationArray(): array { + //TODO: merge them into one representation + static $array = [ + 'ldap_host' => 'ldapHost', + 'ldap_port' => 'ldapPort', + 'ldap_backup_host' => 'ldapBackupHost', + 'ldap_backup_port' => 'ldapBackupPort', + 'ldap_background_host' => 'ldapBackgroundHost', + 'ldap_background_port' => 'ldapBackgroundPort', + 'ldap_override_main_server' => 'ldapOverrideMainServer', + 'ldap_dn' => 'ldapAgentName', + 'ldap_agent_password' => 'ldapAgentPassword', + 'ldap_base' => 'ldapBase', + 'ldap_base_users' => 'ldapBaseUsers', + 'ldap_base_groups' => 'ldapBaseGroups', + 'ldap_userfilter_objectclass' => 'ldapUserFilterObjectclass', + 'ldap_userfilter_groups' => 'ldapUserFilterGroups', + 'ldap_userlist_filter' => 'ldapUserFilter', + 'ldap_user_filter_mode' => 'ldapUserFilterMode', + 'ldap_user_avatar_rule' => 'ldapUserAvatarRule', + 'ldap_login_filter' => 'ldapLoginFilter', + 'ldap_login_filter_mode' => 'ldapLoginFilterMode', + 'ldap_loginfilter_email' => 'ldapLoginFilterEmail', + 'ldap_loginfilter_username' => 'ldapLoginFilterUsername', + 'ldap_loginfilter_attributes' => 'ldapLoginFilterAttributes', + 'ldap_group_filter' => 'ldapGroupFilter', + 'ldap_group_filter_mode' => 'ldapGroupFilterMode', + 'ldap_groupfilter_objectclass' => 'ldapGroupFilterObjectclass', + 'ldap_groupfilter_groups' => 'ldapGroupFilterGroups', + 'ldap_gid_number' => 'ldapGidNumber', + 'ldap_display_name' => 'ldapUserDisplayName', + 'ldap_user_display_name_2' => 'ldapUserDisplayName2', + 'ldap_group_display_name' => 'ldapGroupDisplayName', + 'ldap_tls' => 'ldapTLS', + 'ldap_quota_def' => 'ldapQuotaDefault', + 'ldap_quota_attr' => 'ldapQuotaAttribute', + 'ldap_email_attr' => 'ldapEmailAttribute', + 'ldap_group_member_assoc_attribute' => 'ldapGroupMemberAssocAttr', + 'ldap_cache_ttl' => 'ldapCacheTTL', + 'home_folder_naming_rule' => 'homeFolderNamingRule', + 'ldap_turn_off_cert_check' => 'turnOffCertCheck', + 'ldap_configuration_active' => 'ldapConfigurationActive', + 'ldap_attributes_for_user_search' => 'ldapAttributesForUserSearch', + 'ldap_attributes_for_group_search' => 'ldapAttributesForGroupSearch', + 'ldap_expert_username_attr' => 'ldapExpertUsernameAttr', + 'ldap_expert_uuid_user_attr' => 'ldapExpertUUIDUserAttr', + 'ldap_expert_uuid_group_attr' => 'ldapExpertUUIDGroupAttr', + 'has_memberof_filter_support' => 'hasMemberOfFilterSupport', + 'use_memberof_to_detect_membership' => 'useMemberOfToDetectMembership', + 'ldap_mark_remnants_as_disabled' => 'markRemnantsAsDisabled', + 'last_jpegPhoto_lookup' => 'lastJpegPhotoLookup', + 'ldap_nested_groups' => 'ldapNestedGroups', + 'ldap_paging_size' => 'ldapPagingSize', + 'ldap_turn_on_pwd_change' => 'turnOnPasswordChange', + 'ldap_experienced_admin' => 'ldapExperiencedAdmin', + 'ldap_dynamic_group_member_url' => 'ldapDynamicGroupMemberURL', + 'ldap_default_ppolicy_dn' => 'ldapDefaultPPolicyDN', + 'ldap_ext_storage_home_attribute' => 'ldapExtStorageHomeAttribute', + 'ldap_matching_rule_in_chain_state' => 'ldapMatchingRuleInChainState', + 'ldapIgnoreNamingRules' => 'ldapIgnoreNamingRules', // sysconfig + 'ldap_connection_timeout' => 'ldapConnectionTimeout', + 'ldap_attr_phone' => 'ldapAttributePhone', + 'ldap_attr_website' => 'ldapAttributeWebsite', + 'ldap_attr_address' => 'ldapAttributeAddress', + 'ldap_attr_twitter' => 'ldapAttributeTwitter', + 'ldap_attr_fediverse' => 'ldapAttributeFediverse', + 'ldap_attr_organisation' => 'ldapAttributeOrganisation', + 'ldap_attr_role' => 'ldapAttributeRole', + 'ldap_attr_headline' => 'ldapAttributeHeadline', + 'ldap_attr_biography' => 'ldapAttributeBiography', + 'ldap_admin_group' => 'ldapAdminGroup', + 'ldap_attr_birthdate' => 'ldapAttributeBirthDate', + 'ldap_attr_anniversarydate' => 'ldapAttributeAnniversaryDate', + 'ldap_attr_pronouns' => 'ldapAttributePronouns', + ]; + return $array; + } + + /** + * @throws \RuntimeException + */ + public function resolveRule(string $rule): array { + if ($rule === 'avatar') { + return $this->getAvatarAttributes(); + } + throw new \RuntimeException('Invalid rule'); + } + + public function getAvatarAttributes(): array { + $value = $this->ldapUserAvatarRule ?: self::AVATAR_PREFIX_DEFAULT; + $defaultAttributes = ['jpegphoto', 'thumbnailphoto']; + + if ($value === self::AVATAR_PREFIX_NONE) { + return []; + } + if (str_starts_with($value, self::AVATAR_PREFIX_DATA_ATTRIBUTE)) { + $attribute = trim(substr($value, strlen(self::AVATAR_PREFIX_DATA_ATTRIBUTE))); + if ($attribute === '') { + return $defaultAttributes; + } + return [strtolower($attribute)]; + } + if ($value !== self::AVATAR_PREFIX_DEFAULT) { + Server::get(LoggerInterface::class)->warning('Invalid config value to ldapUserAvatarRule; falling back to default.'); + } + return $defaultAttributes; + } + + /** + * Returns TRUE if the ldapHost variable starts with 'ldapi://' + */ + public function usesLdapi(): bool { + $host = $this->config['ldapHost']; + return is_string($host) && (substr($host, 0, strlen('ldapi://')) === 'ldapi://'); + } +} diff --git a/apps/user_ldap/lib/Connection.php b/apps/user_ldap/lib/Connection.php new file mode 100644 index 00000000000..336179ac341 --- /dev/null +++ b/apps/user_ldap/lib/Connection.php @@ -0,0 +1,780 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OC\ServerNotAvailableException; +use OCA\User_LDAP\Exceptions\ConfigurationIssueException; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IL10N; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** + * magic properties + * responsible for LDAP connections in context with the provided configuration + * + * @property string $ldapHost + * @property string $ldapPort + * @property string $ldapBackupHost + * @property string $ldapBackupPort + * @property string $ldapBackgroundHost + * @property string $ldapBackgroundPort + * @property array|'' $ldapBase + * @property array|'' $ldapBaseUsers + * @property array|'' $ldapBaseGroups + * @property string $ldapAgentName + * @property string $ldapAgentPassword + * @property string $ldapTLS + * @property string $turnOffCertCheck + * @property string $ldapIgnoreNamingRules + * @property string $ldapUserDisplayName + * @property string $ldapUserDisplayName2 + * @property string $ldapUserAvatarRule + * @property string $ldapGidNumber + * @property array|'' $ldapUserFilterObjectclass + * @property array|'' $ldapUserFilterGroups + * @property string $ldapUserFilter + * @property string $ldapUserFilterMode + * @property string $ldapGroupFilter + * @property string $ldapGroupFilterMode + * @property array|'' $ldapGroupFilterObjectclass + * @property array|'' $ldapGroupFilterGroups + * @property string $ldapGroupDisplayName + * @property string $ldapGroupMemberAssocAttr + * @property string $ldapLoginFilter + * @property string $ldapLoginFilterMode + * @property string $ldapLoginFilterEmail + * @property string $ldapLoginFilterUsername + * @property array|'' $ldapLoginFilterAttributes + * @property string $ldapQuotaAttribute + * @property string $ldapQuotaDefault + * @property string $ldapEmailAttribute + * @property string $ldapCacheTTL + * @property string $ldapUuidUserAttribute + * @property string $ldapUuidGroupAttribute + * @property string $ldapOverrideMainServer + * @property string $ldapConfigurationActive + * @property array|'' $ldapAttributesForUserSearch + * @property array|'' $ldapAttributesForGroupSearch + * @property string $ldapExperiencedAdmin + * @property string $homeFolderNamingRule + * @property string $hasMemberOfFilterSupport + * @property string $useMemberOfToDetectMembership + * @property string $ldapExpertUsernameAttr + * @property string $ldapExpertUUIDUserAttr + * @property string $ldapExpertUUIDGroupAttr + * @property string $markRemnantsAsDisabled + * @property string $lastJpegPhotoLookup + * @property string $ldapNestedGroups + * @property string $ldapPagingSize + * @property string $turnOnPasswordChange + * @property string $ldapDynamicGroupMemberURL + * @property string $ldapDefaultPPolicyDN + * @property string $ldapExtStorageHomeAttribute + * @property string $ldapMatchingRuleInChainState + * @property string $ldapConnectionTimeout + * @property string $ldapAttributePhone + * @property string $ldapAttributeWebsite + * @property string $ldapAttributeAddress + * @property string $ldapAttributeTwitter + * @property string $ldapAttributeFediverse + * @property string $ldapAttributeOrganisation + * @property string $ldapAttributeRole + * @property string $ldapAttributeHeadline + * @property string $ldapAttributeBiography + * @property string $ldapAdminGroup + * @property string $ldapAttributeBirthDate + * @property string $ldapAttributePronouns + */ +class Connection extends LDAPUtility { + private ?\LDAP\Connection $ldapConnectionRes = null; + private bool $configured = false; + + /** + * @var bool whether connection should be kept on __destruct + */ + private bool $dontDestruct = false; + + /** + * @var bool runtime flag that indicates whether supported primary groups are available + */ + public $hasPrimaryGroups = true; + + /** + * @var bool runtime flag that indicates whether supported POSIX gidNumber are available + */ + public $hasGidNumber = true; + + /** + * @var ICache|null + */ + protected $cache = null; + + /** @var Configuration settings handler * */ + protected $configuration; + + /** + * @var bool + */ + protected $doNotValidate = false; + + /** + * @var bool + */ + protected $ignoreValidation = false; + + /** + * @var array{sum?: string, result?: bool} + */ + protected $bindResult = []; + + protected LoggerInterface $logger; + private IL10N $l10n; + + /** + * Constructor + * @param string $configPrefix a string with the prefix for the configkey column (appconfig table) + * @param string|null $configID a string with the value for the appid column (appconfig table) or null for on-the-fly connections + */ + public function __construct( + ILDAPWrapper $ldap, + private string $configPrefix = '', + private ?string $configID = 'user_ldap', + ) { + parent::__construct($ldap); + $this->configuration = new Configuration($this->configPrefix, !is_null($this->configID)); + $memcache = Server::get(ICacheFactory::class); + if ($memcache->isAvailable()) { + $this->cache = $memcache->createDistributed(); + } + $helper = Server::get(Helper::class); + $this->doNotValidate = !in_array($this->configPrefix, + $helper->getServerConfigurationPrefixes()); + $this->logger = Server::get(LoggerInterface::class); + $this->l10n = Util::getL10N('user_ldap'); + } + + public function __destruct() { + if (!$this->dontDestruct && $this->ldap->isResource($this->ldapConnectionRes)) { + @$this->ldap->unbind($this->ldapConnectionRes); + $this->bindResult = []; + } + } + + /** + * defines behaviour when the instance is cloned + */ + public function __clone() { + $this->configuration = new Configuration($this->configPrefix, + !is_null($this->configID)); + if (count($this->bindResult) !== 0 && $this->bindResult['result'] === true) { + $this->bindResult = []; + } + $this->ldapConnectionRes = null; + $this->dontDestruct = true; + } + + public function __get(string $name) { + if (!$this->configured) { + $this->readConfiguration(); + } + + return $this->configuration->$name; + } + + /** + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) { + $this->doNotValidate = false; + $before = $this->configuration->$name; + $this->configuration->$name = $value; + $after = $this->configuration->$name; + if ($before !== $after) { + if ($this->configID !== '' && $this->configID !== null) { + $this->configuration->saveConfiguration(); + } + $this->validateConfiguration(); + } + } + + /** + * @param string $rule + * @return array + * @throws \RuntimeException + */ + public function resolveRule($rule) { + return $this->configuration->resolveRule($rule); + } + + /** + * sets whether the result of the configuration validation shall + * be ignored when establishing the connection. Used by the Wizard + * in early configuration state. + * @param bool $state + */ + public function setIgnoreValidation($state) { + $this->ignoreValidation = (bool)$state; + } + + /** + * initializes the LDAP backend + * @param bool $force read the config settings no matter what + */ + public function init($force = false) { + $this->readConfiguration($force); + $this->establishConnection(); + } + + /** + * @return \LDAP\Connection The LDAP resource + */ + public function getConnectionResource(): \LDAP\Connection { + if (!$this->ldapConnectionRes) { + $this->init(); + } + if (is_null($this->ldapConnectionRes)) { + $this->logger->error( + 'No LDAP Connection to server ' . $this->configuration->ldapHost, + ['app' => 'user_ldap'] + ); + throw new ServerNotAvailableException('Connection to LDAP server could not be established'); + } + return $this->ldapConnectionRes; + } + + /** + * resets the connection resource + */ + public function resetConnectionResource(): void { + if (!is_null($this->ldapConnectionRes)) { + @$this->ldap->unbind($this->ldapConnectionRes); + $this->ldapConnectionRes = null; + $this->bindResult = []; + } + } + + /** + * @param string|null $key + */ + private function getCacheKey($key): string { + $prefix = 'LDAP-' . $this->configID . '-' . $this->configPrefix . '-'; + if (is_null($key)) { + return $prefix; + } + return $prefix . hash('sha256', $key); + } + + /** + * @param string $key + * @return mixed|null + */ + public function getFromCache($key) { + if (!$this->configured) { + $this->readConfiguration(); + } + if (is_null($this->cache) || !$this->configuration->ldapCacheTTL) { + return null; + } + $key = $this->getCacheKey($key); + + return json_decode(base64_decode($this->cache->get($key) ?? ''), true); + } + + public function getConfigPrefix(): string { + return $this->configPrefix; + } + + /** + * @param string $key + * @param mixed $value + */ + public function writeToCache($key, $value, ?int $ttlOverride = null): void { + if (!$this->configured) { + $this->readConfiguration(); + } + if (is_null($this->cache) + || !$this->configuration->ldapCacheTTL + || !$this->configuration->ldapConfigurationActive) { + return; + } + $key = $this->getCacheKey($key); + $value = base64_encode(json_encode($value)); + $ttl = $ttlOverride ?? $this->configuration->ldapCacheTTL; + $this->cache->set($key, $value, $ttl); + } + + public function clearCache() { + if (!is_null($this->cache)) { + $this->cache->clear($this->getCacheKey(null)); + } + } + + /** + * Caches the general LDAP configuration. + * @param bool $force optional. true, if the re-read should be forced. defaults + * to false. + */ + private function readConfiguration(bool $force = false): void { + if ((!$this->configured || $force) && !is_null($this->configID)) { + $this->configuration->readConfiguration(); + $this->configured = $this->validateConfiguration(); + } + } + + /** + * set LDAP configuration with values delivered by an array, not read from configuration + * @param array $config array that holds the config parameters in an associated array + * @param array &$setParameters optional; array where the set fields will be given to + * @param bool $throw if true, throw ConfigurationIssueException with details instead of returning false + * @return bool true if config validates, false otherwise. Check with $setParameters for detailed success on single parameters + */ + public function setConfiguration(array $config, ?array &$setParameters = null, bool $throw = false): bool { + if (is_null($setParameters)) { + $setParameters = []; + } + $this->doNotValidate = false; + $this->configuration->setConfiguration($config, $setParameters); + if (count($setParameters) > 0) { + $this->configured = $this->validateConfiguration($throw); + } + + + return $this->configured; + } + + /** + * saves the current Configuration in the database and empties the + * cache + * @return null + */ + public function saveConfiguration() { + $this->configuration->saveConfiguration(); + $this->clearCache(); + } + + /** + * get the current LDAP configuration + * @return array + */ + public function getConfiguration() { + $this->readConfiguration(); + $config = $this->configuration->getConfiguration(); + $cta = $this->configuration->getConfigTranslationArray(); + $result = []; + foreach ($cta as $dbkey => $configkey) { + switch ($configkey) { + case 'homeFolderNamingRule': + if (str_starts_with($config[$configkey], 'attr:')) { + $result[$dbkey] = substr($config[$configkey], 5); + } else { + $result[$dbkey] = ''; + } + break; + case 'ldapBase': + case 'ldapBaseUsers': + case 'ldapBaseGroups': + case 'ldapAttributesForUserSearch': + case 'ldapAttributesForGroupSearch': + if (is_array($config[$configkey])) { + $result[$dbkey] = implode("\n", $config[$configkey]); + break; + } //else follows default + // no break + default: + $result[$dbkey] = $config[$configkey]; + } + } + return $result; + } + + private function doSoftValidation(): void { + //if User or Group Base are not set, take over Base DN setting + foreach (['ldapBaseUsers', 'ldapBaseGroups'] as $keyBase) { + $val = $this->configuration->$keyBase; + if (empty($val)) { + $this->configuration->$keyBase = $this->configuration->ldapBase; + } + } + + foreach (['ldapExpertUUIDUserAttr' => 'ldapUuidUserAttribute', + 'ldapExpertUUIDGroupAttr' => 'ldapUuidGroupAttribute'] as $expertSetting => $effectiveSetting) { + $uuidOverride = $this->configuration->$expertSetting; + if (!empty($uuidOverride)) { + $this->configuration->$effectiveSetting = $uuidOverride; + } else { + $uuidAttributes = Access::UUID_ATTRIBUTES; + array_unshift($uuidAttributes, 'auto'); + if (!in_array($this->configuration->$effectiveSetting, $uuidAttributes) + && !is_null($this->configID)) { + $this->configuration->$effectiveSetting = 'auto'; + $this->configuration->saveConfiguration(); + $this->logger->info( + 'Illegal value for the ' . $effectiveSetting . ', reset to autodetect.', + ['app' => 'user_ldap'] + ); + } + } + } + + $backupPort = (int)$this->configuration->ldapBackupPort; + if ($backupPort <= 0) { + $this->configuration->ldapBackupPort = $this->configuration->ldapPort; + } + + //make sure empty search attributes are saved as simple, empty array + $saKeys = ['ldapAttributesForUserSearch', + 'ldapAttributesForGroupSearch']; + foreach ($saKeys as $key) { + $val = $this->configuration->$key; + if (is_array($val) && count($val) === 1 && empty($val[0])) { + $this->configuration->$key = []; + } + } + + if ((stripos((string)$this->configuration->ldapHost, 'ldaps://') === 0) + && $this->configuration->ldapTLS) { + $this->configuration->ldapTLS = (string)false; + $this->logger->info( + 'LDAPS (already using secure connection) and TLS do not work together. Switched off TLS.', + ['app' => 'user_ldap'] + ); + } + } + + /** + * @throws ConfigurationIssueException + */ + private function doCriticalValidation(): void { + //options that shall not be empty + $options = ['ldapHost', 'ldapUserDisplayName', + 'ldapGroupDisplayName', 'ldapLoginFilter']; + + //ldapPort should not be empty either unless ldapHost is pointing to a socket + if (!$this->configuration->usesLdapi()) { + $options[] = 'ldapPort'; + } + + foreach ($options as $key) { + $val = $this->configuration->$key; + if (empty($val)) { + switch ($key) { + case 'ldapHost': + $subj = 'LDAP Host'; + break; + case 'ldapPort': + $subj = 'LDAP Port'; + break; + case 'ldapUserDisplayName': + $subj = 'LDAP User Display Name'; + break; + case 'ldapGroupDisplayName': + $subj = 'LDAP Group Display Name'; + break; + case 'ldapLoginFilter': + $subj = 'LDAP Login Filter'; + break; + default: + $subj = $key; + break; + } + throw new ConfigurationIssueException( + 'No ' . $subj . ' given!', + $this->l10n->t('Mandatory field "%s" left empty', $subj), + ); + } + } + + //combinations + $agent = $this->configuration->ldapAgentName; + $pwd = $this->configuration->ldapAgentPassword; + if ($agent === '' && $pwd !== '') { + throw new ConfigurationIssueException( + 'A password is given, but not an LDAP agent', + $this->l10n->t('A password is given, but not an LDAP agent'), + ); + } + if ($agent !== '' && $pwd === '') { + throw new ConfigurationIssueException( + 'No password is given for the user agent', + $this->l10n->t('No password is given for the user agent'), + ); + } + + $base = $this->configuration->ldapBase; + $baseUsers = $this->configuration->ldapBaseUsers; + $baseGroups = $this->configuration->ldapBaseGroups; + + if (empty($base)) { + throw new ConfigurationIssueException( + 'Not a single Base DN given', + $this->l10n->t('No LDAP base DN was given'), + ); + } + + if (!empty($baseUsers) && !$this->checkBasesAreValid($baseUsers, $base)) { + throw new ConfigurationIssueException( + 'User base is not in root base', + $this->l10n->t('User base DN is not a subnode of global base DN'), + ); + } + + if (!empty($baseGroups) && !$this->checkBasesAreValid($baseGroups, $base)) { + throw new ConfigurationIssueException( + 'Group base is not in root base', + $this->l10n->t('Group base DN is not a subnode of global base DN'), + ); + } + + if (mb_strpos((string)$this->configuration->ldapLoginFilter, '%uid', 0, 'UTF-8') === false) { + throw new ConfigurationIssueException( + 'Login filter does not contain %uid placeholder.', + $this->l10n->t('Login filter does not contain %s placeholder.', ['%uid']), + ); + } + } + + /** + * Checks that all bases are subnodes of one of the root bases + */ + private function checkBasesAreValid(array $bases, array $rootBases): bool { + foreach ($bases as $base) { + $ok = false; + foreach ($rootBases as $rootBase) { + if (str_ends_with($base, $rootBase)) { + $ok = true; + break; + } + } + if (!$ok) { + return false; + } + } + return true; + } + + /** + * Validates the user specified configuration + * @return bool true if configuration seems OK, false otherwise + */ + private function validateConfiguration(bool $throw = false): bool { + if ($this->doNotValidate) { + //don't do a validation if it is a new configuration with pure + //default values. Will be allowed on changes via __set or + //setConfiguration + return false; + } + + // first step: "soft" checks: settings that are not really + // necessary, but advisable. If left empty, give an info message + $this->doSoftValidation(); + + //second step: critical checks. If left empty or filled wrong, mark as + //not configured and give a warning. + try { + $this->doCriticalValidation(); + return true; + } catch (ConfigurationIssueException $e) { + if ($throw) { + throw $e; + } + $this->logger->warning( + 'Configuration Error (prefix ' . $this->configPrefix . '): ' . $e->getMessage(), + ['exception' => $e] + ); + return false; + } + } + + + /** + * Connects and Binds to LDAP + * + * @throws ServerNotAvailableException + */ + private function establishConnection(): ?bool { + if (!$this->configuration->ldapConfigurationActive) { + return null; + } + static $phpLDAPinstalled = true; + if (!$phpLDAPinstalled) { + return false; + } + if (!$this->ignoreValidation && !$this->configured) { + $this->logger->warning( + 'Configuration is invalid, cannot connect', + ['app' => 'user_ldap'] + ); + return false; + } + if (!$this->ldapConnectionRes) { + if (!$this->ldap->areLDAPFunctionsAvailable()) { + $phpLDAPinstalled = false; + $this->logger->error( + 'function ldap_connect is not available. Make sure that the PHP ldap module is installed.', + ['app' => 'user_ldap'] + ); + + return false; + } + + $hasBackupHost = (trim($this->configuration->ldapBackupHost ?? '') !== ''); + $hasBackgroundHost = (trim($this->configuration->ldapBackgroundHost ?? '') !== ''); + $useBackgroundHost = (\OC::$CLI && $hasBackgroundHost); + $overrideCacheKey = ($useBackgroundHost ? 'overrideBackgroundServer' : 'overrideMainServer'); + $forceBackupHost = ($this->configuration->ldapOverrideMainServer || $this->getFromCache($overrideCacheKey)); + $bindStatus = false; + if (!$forceBackupHost) { + try { + $host = $this->configuration->ldapHost ?? ''; + $port = $this->configuration->ldapPort ?? ''; + if ($useBackgroundHost) { + $host = $this->configuration->ldapBackgroundHost ?? ''; + $port = $this->configuration->ldapBackgroundPort ?? ''; + } + $this->doConnect($host, $port); + return $this->bind(); + } catch (ServerNotAvailableException $e) { + if (!$hasBackupHost) { + throw $e; + } + } + $this->logger->warning( + 'Main LDAP not reachable, connecting to backup: {msg}', + [ + 'app' => 'user_ldap', + 'msg' => $e->getMessage(), + 'exception' => $e, + ] + ); + } + + // if LDAP server is not reachable, try the Backup (Replica!) Server + $this->doConnect($this->configuration->ldapBackupHost ?? '', $this->configuration->ldapBackupPort ?? ''); + $this->bindResult = []; + $bindStatus = $this->bind(); + $error = $this->ldap->isResource($this->ldapConnectionRes) + ? $this->ldap->errno($this->ldapConnectionRes) : -1; + if ($bindStatus && $error === 0 && !$forceBackupHost) { + //when bind to backup server succeeded and failed to main server, + //skip contacting it for 15min + $this->writeToCache($overrideCacheKey, true, 60 * 15); + } + + return $bindStatus; + } + return null; + } + + /** + * @param string $host + * @param string $port + * @throws \OC\ServerNotAvailableException + */ + private function doConnect($host, $port): bool { + if ($host === '') { + return false; + } + + $this->ldapConnectionRes = $this->ldap->connect($host, $port) ?: null; + + if ($this->ldapConnectionRes === null) { + throw new ServerNotAvailableException('Connection failed'); + } + + if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_PROTOCOL_VERSION, 3)) { + throw new ServerNotAvailableException('Could not set required LDAP Protocol version.'); + } + + if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_REFERRALS, 0)) { + throw new ServerNotAvailableException('Could not disable LDAP referrals.'); + } + + if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_NETWORK_TIMEOUT, $this->configuration->ldapConnectionTimeout)) { + throw new ServerNotAvailableException('Could not set network timeout'); + } + + if ($this->configuration->ldapTLS) { + if ($this->configuration->turnOffCertCheck) { + if ($this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER)) { + $this->logger->debug( + 'Turned off SSL certificate validation successfully.', + ['app' => 'user_ldap'] + ); + } else { + $this->logger->warning( + 'Could not turn off SSL certificate validation.', + ['app' => 'user_ldap'] + ); + } + } + + if (!$this->ldap->startTls($this->ldapConnectionRes)) { + throw new ServerNotAvailableException('Start TLS failed, when connecting to LDAP host ' . $host . '.'); + } + } + + return true; + } + + /** + * Binds to LDAP + */ + public function bind() { + if (!$this->configuration->ldapConfigurationActive) { + return false; + } + $cr = $this->ldapConnectionRes; + if (!$this->ldap->isResource($cr)) { + $cr = $this->getConnectionResource(); + } + + if ( + count($this->bindResult) !== 0 + && $this->bindResult['sum'] === md5($this->configuration->ldapAgentName . $this->configPrefix . $this->configuration->ldapAgentPassword) + ) { + // don't attempt to bind again with the same data as before + // bind might have been invoked via getConnectionResource(), + // but we need results specifically for e.g. user login + return $this->bindResult['result']; + } + + $ldapLogin = @$this->ldap->bind($cr, + $this->configuration->ldapAgentName, + $this->configuration->ldapAgentPassword); + + $this->bindResult = [ + 'sum' => md5($this->configuration->ldapAgentName . $this->configPrefix . $this->configuration->ldapAgentPassword), + 'result' => $ldapLogin, + ]; + + if (!$ldapLogin) { + $errno = $this->ldap->errno($cr); + + $this->logger->warning( + 'Bind failed: ' . $errno . ': ' . $this->ldap->error($cr), + ['app' => 'user_ldap'] + ); + + // Set to failure mode, if LDAP error code is not one of + // - LDAP_SUCCESS (0) + // - LDAP_INVALID_CREDENTIALS (49) + // - LDAP_INSUFFICIENT_ACCESS (50, spotted Apple Open Directory) + // - LDAP_UNWILLING_TO_PERFORM (53, spotted eDirectory) + if (!in_array($errno, [0, 49, 50, 53], true)) { + $this->ldapConnectionRes = null; + } + + return false; + } + return true; + } +} diff --git a/apps/user_ldap/lib/ConnectionFactory.php b/apps/user_ldap/lib/ConnectionFactory.php new file mode 100644 index 00000000000..dd0ad31920a --- /dev/null +++ b/apps/user_ldap/lib/ConnectionFactory.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +class ConnectionFactory { + public function __construct( + private ILDAPWrapper $ldap, + ) { + } + + public function get($prefix) { + return new Connection($this->ldap, $prefix, 'user_ldap'); + } +} diff --git a/apps/user_ldap/lib/Controller/ConfigAPIController.php b/apps/user_ldap/lib/Controller/ConfigAPIController.php new file mode 100644 index 00000000000..d98e6d41b52 --- /dev/null +++ b/apps/user_ldap/lib/Controller/ConfigAPIController.php @@ -0,0 +1,256 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Controller; + +use OC\CapabilitiesManager; +use OC\Core\Controller\OCSController; +use OC\Security\IdentityProof\Manager; +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\ConnectionFactory; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\Settings\Admin; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\ServerVersion; +use Psr\Log\LoggerInterface; + +class ConfigAPIController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + CapabilitiesManager $capabilitiesManager, + IUserSession $userSession, + IUserManager $userManager, + Manager $keyManager, + ServerVersion $serverVersion, + private Helper $ldapHelper, + private LoggerInterface $logger, + private ConnectionFactory $connectionFactory, + ) { + parent::__construct( + $appName, + $request, + $capabilitiesManager, + $userSession, + $userManager, + $keyManager, + $serverVersion, + ); + } + + /** + * Create a new (empty) configuration and return the resulting prefix + * + * @return DataResponse<Http::STATUS_OK, array{configID: string}, array{}> + * @throws OCSException + * + * 200: Config created successfully + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function create() { + try { + $configPrefix = $this->ldapHelper->getNextServerConfigurationPrefix(); + $configHolder = new Configuration($configPrefix); + $configHolder->ldapConfigurationActive = false; + $configHolder->saveConfiguration(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSException('An issue occurred when creating the new config.'); + } + return new DataResponse(['configID' => $configPrefix]); + } + + /** + * Delete a LDAP configuration + * + * @param string $configID ID of the config + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSException + * @throws OCSNotFoundException Config not found + * + * 200: Config deleted successfully + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function delete($configID) { + try { + $this->ensureConfigIDExists($configID); + if (!$this->ldapHelper->deleteServerConfiguration($configID)) { + throw new OCSException('Could not delete configuration'); + } + } catch (OCSException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSException('An issue occurred when deleting the config.'); + } + + return new DataResponse(); + } + + /** + * Modify a configuration + * + * @param string $configID ID of the config + * @param array<string, mixed> $configData New config + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSException + * @throws OCSBadRequestException Modifying config is not possible + * @throws OCSNotFoundException Config not found + * + * 200: Config returned + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function modify($configID, $configData) { + try { + $this->ensureConfigIDExists($configID); + + if (!is_array($configData)) { + throw new OCSBadRequestException('configData is not properly set'); + } + + $configuration = new Configuration($configID); + $configKeys = $configuration->getConfigTranslationArray(); + + foreach ($configKeys as $i => $key) { + if (isset($configData[$key])) { + $configuration->$key = $configData[$key]; + } + } + + $configuration->saveConfiguration(); + $this->connectionFactory->get($configID)->clearCache(); + } catch (OCSException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSException('An issue occurred when modifying the config.'); + } + + return new DataResponse(); + } + + /** + * Get a configuration + * + * Output can look like this: + * <?xml version="1.0"?> + * <ocs> + * <meta> + * <status>ok</status> + * <statuscode>200</statuscode> + * <message>OK</message> + * </meta> + * <data> + * <ldapHost>ldaps://my.ldap.server</ldapHost> + * <ldapPort>7770</ldapPort> + * <ldapBackupHost></ldapBackupHost> + * <ldapBackupPort></ldapBackupPort> + * <ldapBase>ou=small,dc=my,dc=ldap,dc=server</ldapBase> + * <ldapBaseUsers>ou=users,ou=small,dc=my,dc=ldap,dc=server</ldapBaseUsers> + * <ldapBaseGroups>ou=small,dc=my,dc=ldap,dc=server</ldapBaseGroups> + * <ldapAgentName>cn=root,dc=my,dc=ldap,dc=server</ldapAgentName> + * <ldapAgentPassword>clearTextWithShowPassword=1</ldapAgentPassword> + * <ldapTLS>1</ldapTLS> + * <turnOffCertCheck>0</turnOffCertCheck> + * <ldapIgnoreNamingRules/> + * <ldapUserDisplayName>displayname</ldapUserDisplayName> + * <ldapUserDisplayName2>uid</ldapUserDisplayName2> + * <ldapUserFilterObjectclass>inetOrgPerson</ldapUserFilterObjectclass> + * <ldapUserFilterGroups></ldapUserFilterGroups> + * <ldapUserFilter>(&(objectclass=nextcloudUser)(nextcloudEnabled=TRUE))</ldapUserFilter> + * <ldapUserFilterMode>1</ldapUserFilterMode> + * <ldapGroupFilter>(&(|(objectclass=nextcloudGroup)))</ldapGroupFilter> + * <ldapGroupFilterMode>0</ldapGroupFilterMode> + * <ldapGroupFilterObjectclass>nextcloudGroup</ldapGroupFilterObjectclass> + * <ldapGroupFilterGroups></ldapGroupFilterGroups> + * <ldapGroupDisplayName>cn</ldapGroupDisplayName> + * <ldapGroupMemberAssocAttr>memberUid</ldapGroupMemberAssocAttr> + * <ldapLoginFilter>(&(|(objectclass=inetOrgPerson))(uid=%uid))</ldapLoginFilter> + * <ldapLoginFilterMode>0</ldapLoginFilterMode> + * <ldapLoginFilterEmail>0</ldapLoginFilterEmail> + * <ldapLoginFilterUsername>1</ldapLoginFilterUsername> + * <ldapLoginFilterAttributes></ldapLoginFilterAttributes> + * <ldapQuotaAttribute></ldapQuotaAttribute> + * <ldapQuotaDefault></ldapQuotaDefault> + * <ldapEmailAttribute>mail</ldapEmailAttribute> + * <ldapCacheTTL>20</ldapCacheTTL> + * <ldapUuidUserAttribute>auto</ldapUuidUserAttribute> + * <ldapUuidGroupAttribute>auto</ldapUuidGroupAttribute> + * <ldapOverrideMainServer></ldapOverrideMainServer> + * <ldapConfigurationActive>1</ldapConfigurationActive> + * <ldapAttributesForUserSearch>uid;sn;givenname</ldapAttributesForUserSearch> + * <ldapAttributesForGroupSearch></ldapAttributesForGroupSearch> + * <ldapExperiencedAdmin>0</ldapExperiencedAdmin> + * <homeFolderNamingRule></homeFolderNamingRule> + * <hasMemberOfFilterSupport></hasMemberOfFilterSupport> + * <useMemberOfToDetectMembership>1</useMemberOfToDetectMembership> + * <ldapExpertUsernameAttr>uid</ldapExpertUsernameAttr> + * <ldapExpertUUIDUserAttr>uid</ldapExpertUUIDUserAttr> + * <ldapExpertUUIDGroupAttr></ldapExpertUUIDGroupAttr> + * <lastJpegPhotoLookup>0</lastJpegPhotoLookup> + * <ldapNestedGroups>0</ldapNestedGroups> + * <ldapPagingSize>500</ldapPagingSize> + * <turnOnPasswordChange>1</turnOnPasswordChange> + * <ldapDynamicGroupMemberURL></ldapDynamicGroupMemberURL> + * </data> + * </ocs> + * + * @param string $configID ID of the config + * @param bool $showPassword Whether to show the password + * @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}> + * @throws OCSException + * @throws OCSNotFoundException Config not found + * + * 200: Config returned + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + public function show($configID, $showPassword = false) { + try { + $this->ensureConfigIDExists($configID); + + $config = new Configuration($configID); + $data = $config->getConfiguration(); + if (!$showPassword) { + $data['ldapAgentPassword'] = '***'; + } + foreach ($data as $key => $value) { + if (is_array($value)) { + $value = implode(';', $value); + $data[$key] = $value; + } + } + } catch (OCSException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSException('An issue occurred when modifying the config.'); + } + + return new DataResponse($data); + } + + /** + * If the given config ID is not available, an exception is thrown + * + * @param string $configID + * @throws OCSNotFoundException + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + private function ensureConfigIDExists($configID): void { + $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(); + if (!in_array($configID, $prefixes, true)) { + throw new OCSNotFoundException('Config ID not found'); + } + } +} diff --git a/apps/user_ldap/lib/Controller/RenewPasswordController.php b/apps/user_ldap/lib/Controller/RenewPasswordController.php new file mode 100644 index 00000000000..8389a362b8f --- /dev/null +++ b/apps/user_ldap/lib/Controller/RenewPasswordController.php @@ -0,0 +1,153 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\UseSession; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\HintException; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class RenewPasswordController extends Controller { + /** + * @param string $appName + * @param IRequest $request + * @param IUserManager $userManager + * @param IConfig $config + * @param IURLGenerator $urlGenerator + */ + public function __construct( + $appName, + IRequest $request, + private IUserManager $userManager, + private IConfig $config, + protected IL10N $l10n, + private ISession $session, + private IURLGenerator $urlGenerator, + ) { + parent::__construct($appName, $request); + } + + /** + * @return RedirectResponse + */ + #[PublicPage] + #[NoCSRFRequired] + public function cancel() { + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); + } + + /** + * @param string $user + * + * @return TemplateResponse|RedirectResponse + */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] + public function showRenewPasswordForm($user) { + if ($this->config->getUserValue($user, 'user_ldap', 'needsPasswordReset') !== 'true') { + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); + } + $parameters = []; + $renewPasswordMessages = $this->session->get('renewPasswordMessages'); + $errors = []; + $messages = []; + if (is_array($renewPasswordMessages)) { + [$errors, $messages] = $renewPasswordMessages; + } + $this->session->remove('renewPasswordMessages'); + foreach ($errors as $value) { + $parameters[$value] = true; + } + + $parameters['messages'] = $messages; + $parameters['user'] = $user; + + $parameters['canResetPassword'] = true; + $parameters['resetPasswordLink'] = $this->config->getSystemValue('lost_password_link', ''); + if (!$parameters['resetPasswordLink']) { + $userObj = $this->userManager->get($user); + if ($userObj instanceof IUser) { + $parameters['canResetPassword'] = $userObj->canChangePassword(); + } + } + $parameters['cancelLink'] = $this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm'); + + return new TemplateResponse( + $this->appName, 'renewpassword', $parameters, 'guest' + ); + } + + /** + * @param string $user + * @param string $oldPassword + * @param string $newPassword + * + * @return RedirectResponse + */ + #[PublicPage] + #[UseSession] + public function tryRenewPassword($user, $oldPassword, $newPassword) { + if ($this->config->getUserValue($user, 'user_ldap', 'needsPasswordReset') !== 'true') { + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); + } + $args = !is_null($user) ? ['user' => $user] : []; + $loginResult = $this->userManager->checkPassword($user, $oldPassword); + if ($loginResult === false) { + $this->session->set('renewPasswordMessages', [ + ['invalidpassword'], [] + ]); + return new RedirectResponse($this->urlGenerator->linkToRoute('user_ldap.renewPassword.showRenewPasswordForm', $args)); + } + + try { + if (!is_null($newPassword) && \OC_User::setPassword($user, $newPassword)) { + $this->session->set('loginMessages', [ + [], [$this->l10n->t('Please login with the new password')] + ]); + $this->config->setUserValue($user, 'user_ldap', 'needsPasswordReset', 'false'); + return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm', $args)); + } else { + $this->session->set('renewPasswordMessages', [ + ['internalexception'], [] + ]); + } + } catch (HintException $e) { + $this->session->set('renewPasswordMessages', [ + [], [$e->getHint()] + ]); + } + + return new RedirectResponse($this->urlGenerator->linkToRoute('user_ldap.renewPassword.showRenewPasswordForm', $args)); + } + + /** + * @return RedirectResponse + */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] + public function showLoginFormInvalidPassword($user) { + $args = !is_null($user) ? ['user' => $user] : []; + $this->session->set('loginMessages', [ + ['invalidpassword'], [] + ]); + return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm', $args)); + } +} diff --git a/apps/user_ldap/lib/DataCollector/LdapDataCollector.php b/apps/user_ldap/lib/DataCollector/LdapDataCollector.php new file mode 100644 index 00000000000..2f74a628a32 --- /dev/null +++ b/apps/user_ldap/lib/DataCollector/LdapDataCollector.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types = 1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\DataCollector; + +use OC\AppFramework\Http\Request; +use OCP\AppFramework\Http\Response; +use OCP\DataCollector\AbstractDataCollector; + +class LdapDataCollector extends AbstractDataCollector { + public function startLdapRequest(string $query, array $args, array $backtrace): void { + $this->data[] = [ + 'start' => microtime(true), + 'query' => $query, + 'args' => $args, + 'end' => microtime(true), + 'backtrace' => $backtrace, + ]; + } + + public function stopLastLdapRequest(): void { + $this->data[count($this->data) - 1]['end'] = microtime(true); + } + + public function getName(): string { + return 'ldap'; + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void { + } +} diff --git a/apps/user_ldap/lib/Db/GroupMembership.php b/apps/user_ldap/lib/Db/GroupMembership.php new file mode 100644 index 00000000000..6141f1b18c9 --- /dev/null +++ b/apps/user_ldap/lib/Db/GroupMembership.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method void setUserid(string $userid) + * @method string getUserid() + * @method void setGroupid(string $groupid) + * @method string getGroupid() + */ +class GroupMembership extends Entity { + /** @var string */ + protected $groupid; + + /** @var string */ + protected $userid; + + public function __construct() { + $this->addType('groupid', 'string'); + $this->addType('userid', 'string'); + } +} diff --git a/apps/user_ldap/lib/Db/GroupMembershipMapper.php b/apps/user_ldap/lib/Db/GroupMembershipMapper.php new file mode 100644 index 00000000000..b3d6c31dda6 --- /dev/null +++ b/apps/user_ldap/lib/Db/GroupMembershipMapper.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<GroupMembership> + */ +class GroupMembershipMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ldap_group_membership', GroupMembership::class); + } + + /** + * @return string[] + */ + public function getKnownGroups(): array { + $query = $this->db->getQueryBuilder(); + $result = $query->selectDistinct('groupid') + ->from($this->getTableName()) + ->executeQuery(); + + $groups = array_column($result->fetchAll(), 'groupid'); + $result->closeCursor(); + return $groups; + } + + /** + * @return GroupMembership[] + */ + public function findGroupMemberships(string $groupid): array { + $qb = $this->db->getQueryBuilder(); + $select = $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('groupid', $qb->createNamedParameter($groupid))); + + return $this->findEntities($select); + } + + /** + * @return GroupMembership[] + */ + public function findGroupMembershipsForUser(string $userid): array { + $qb = $this->db->getQueryBuilder(); + $select = $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('userid', $qb->createNamedParameter($userid))); + + return $this->findEntities($select); + } + + public function deleteGroups(array $removedGroups): void { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->in('groupid', $query->createParameter('groupids'))); + + foreach (array_chunk($removedGroups, 1000) as $removedGroupsChunk) { + $query->setParameter('groupids', $removedGroupsChunk, IQueryBuilder::PARAM_STR_ARRAY); + $query->executeStatement(); + } + } +} diff --git a/apps/user_ldap/lib/Events/GroupBackendRegistered.php b/apps/user_ldap/lib/Events/GroupBackendRegistered.php new file mode 100644 index 00000000000..a94c239c1b3 --- /dev/null +++ b/apps/user_ldap/lib/Events/GroupBackendRegistered.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Events; + +use OCA\User_LDAP\GroupPluginManager; +use OCA\User_LDAP\IGroupLDAP; +use OCP\EventDispatcher\Event; + +/** + * This event is triggered right after the LDAP group backend is registered. + * + * @since 20.0.0 + */ +class GroupBackendRegistered extends Event { + + public function __construct( + private IGroupLDAP $backend, + private GroupPluginManager $pluginManager, + ) { + } + + public function getBackend(): IGroupLDAP { + return $this->backend; + } + + public function getPluginManager(): GroupPluginManager { + return $this->pluginManager; + } +} diff --git a/apps/user_ldap/lib/Events/UserBackendRegistered.php b/apps/user_ldap/lib/Events/UserBackendRegistered.php new file mode 100644 index 00000000000..a26e23f8f83 --- /dev/null +++ b/apps/user_ldap/lib/Events/UserBackendRegistered.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Events; + +use OCA\User_LDAP\IUserLDAP; +use OCA\User_LDAP\UserPluginManager; +use OCP\EventDispatcher\Event; + +/** + * This event is triggered right after the LDAP user backend is registered. + * + * @since 20.0.0 + */ +class UserBackendRegistered extends Event { + + public function __construct( + private IUserLDAP $backend, + private UserPluginManager $pluginManager, + ) { + } + + public function getBackend(): IUserLDAP { + return $this->backend; + } + + public function getPluginManager(): UserPluginManager { + return $this->pluginManager; + } +} diff --git a/apps/user_ldap/lib/Exceptions/AttributeNotSet.php b/apps/user_ldap/lib/Exceptions/AttributeNotSet.php new file mode 100644 index 00000000000..4d6053eda66 --- /dev/null +++ b/apps/user_ldap/lib/Exceptions/AttributeNotSet.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Exceptions; + +class AttributeNotSet extends \RuntimeException { +} diff --git a/apps/user_ldap/lib/Exceptions/ConfigurationIssueException.php b/apps/user_ldap/lib/Exceptions/ConfigurationIssueException.php new file mode 100644 index 00000000000..efeb426b13d --- /dev/null +++ b/apps/user_ldap/lib/Exceptions/ConfigurationIssueException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Exceptions; + +use OCP\HintException; + +class ConfigurationIssueException extends HintException { +} diff --git a/apps/user_ldap/lib/Exceptions/ConstraintViolationException.php b/apps/user_ldap/lib/Exceptions/ConstraintViolationException.php new file mode 100644 index 00000000000..d0d384c31de --- /dev/null +++ b/apps/user_ldap/lib/Exceptions/ConstraintViolationException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Exceptions; + +class ConstraintViolationException extends \Exception { +} diff --git a/apps/user_ldap/lib/Exceptions/NoMoreResults.php b/apps/user_ldap/lib/Exceptions/NoMoreResults.php new file mode 100644 index 00000000000..b5621d86eb6 --- /dev/null +++ b/apps/user_ldap/lib/Exceptions/NoMoreResults.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Exceptions; + +class NoMoreResults extends \Exception { +} diff --git a/apps/user_ldap/lib/Exceptions/NotOnLDAP.php b/apps/user_ldap/lib/Exceptions/NotOnLDAP.php new file mode 100644 index 00000000000..cd74e918829 --- /dev/null +++ b/apps/user_ldap/lib/Exceptions/NotOnLDAP.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Exceptions; + +class NotOnLDAP extends \Exception { +} diff --git a/apps/user_ldap/lib/GroupPluginManager.php b/apps/user_ldap/lib/GroupPluginManager.php new file mode 100644 index 00000000000..9e8ae6805a4 --- /dev/null +++ b/apps/user_ldap/lib/GroupPluginManager.php @@ -0,0 +1,171 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OCP\GroupInterface; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class GroupPluginManager { + private int $respondToActions = 0; + + /** @var array<int, ?ILDAPGroupPlugin> */ + private array $which = [ + GroupInterface::CREATE_GROUP => null, + GroupInterface::DELETE_GROUP => null, + GroupInterface::ADD_TO_GROUP => null, + GroupInterface::REMOVE_FROM_GROUP => null, + GroupInterface::COUNT_USERS => null, + GroupInterface::GROUP_DETAILS => null + ]; + + private bool $suppressDeletion = false; + + /** + * @return int All implemented actions + */ + public function getImplementedActions() { + return $this->respondToActions; + } + + /** + * Registers a group plugin that may implement some actions, overriding User_LDAP's group actions. + * @param ILDAPGroupPlugin $plugin + */ + public function register(ILDAPGroupPlugin $plugin) { + $respondToActions = $plugin->respondToActions(); + $this->respondToActions |= $respondToActions; + + foreach ($this->which as $action => $v) { + if ((bool)($respondToActions & $action)) { + $this->which[$action] = $plugin; + Server::get(LoggerInterface::class)->debug('Registered action ' . $action . ' to plugin ' . get_class($plugin), ['app' => 'user_ldap']); + } + } + } + + /** + * Signal if there is a registered plugin that implements some given actions + * @param int $actions Actions defined in \OCP\GroupInterface, like GroupInterface::REMOVE_FROM_GROUP + * @return bool + */ + public function implementsActions($actions) { + return ($actions & $this->respondToActions) == $actions; + } + + /** + * Create a group + * @param string $gid Group Id + * @return string | null The group DN if group creation was successful. + * @throws \Exception + */ + public function createGroup($gid) { + $plugin = $this->which[GroupInterface::CREATE_GROUP]; + + if ($plugin) { + return $plugin->createGroup($gid); + } + throw new \Exception('No plugin implements createGroup in this LDAP Backend.'); + } + + public function canDeleteGroup(): bool { + return !$this->suppressDeletion && $this->implementsActions(GroupInterface::DELETE_GROUP); + } + + /** + * @return bool – the value before the change + */ + public function setSuppressDeletion(bool $value): bool { + $old = $this->suppressDeletion; + $this->suppressDeletion = $value; + return $old; + } + + /** + * Delete a group + * + * @throws \Exception + */ + public function deleteGroup(string $gid): bool { + $plugin = $this->which[GroupInterface::DELETE_GROUP]; + + if ($plugin) { + if ($this->suppressDeletion) { + return false; + } + return $plugin->deleteGroup($gid); + } + throw new \Exception('No plugin implements deleteGroup in this LDAP Backend.'); + } + + /** + * Add a user to a group + * @param string $uid ID of the user to add to group + * @param string $gid ID of the group in which add the user + * @return bool + * @throws \Exception + * + * Adds a user to a group. + */ + public function addToGroup($uid, $gid) { + $plugin = $this->which[GroupInterface::ADD_TO_GROUP]; + + if ($plugin) { + return $plugin->addToGroup($uid, $gid); + } + throw new \Exception('No plugin implements addToGroup in this LDAP Backend.'); + } + + /** + * Removes a user from a group + * @param string $uid ID of the user to remove from group + * @param string $gid ID of the group from which remove the user + * @return bool + * @throws \Exception + * + * removes the user from a group. + */ + public function removeFromGroup($uid, $gid) { + $plugin = $this->which[GroupInterface::REMOVE_FROM_GROUP]; + + if ($plugin) { + return $plugin->removeFromGroup($uid, $gid); + } + throw new \Exception('No plugin implements removeFromGroup in this LDAP Backend.'); + } + + /** + * get the number of all users matching the search string in a group + * @param string $gid ID of the group + * @param string $search query string + * @return int|false + * @throws \Exception + */ + public function countUsersInGroup($gid, $search = '') { + $plugin = $this->which[GroupInterface::COUNT_USERS]; + + if ($plugin) { + return $plugin->countUsersInGroup($gid, $search); + } + throw new \Exception('No plugin implements countUsersInGroup in this LDAP Backend.'); + } + + /** + * get an array with group details + * @param string $gid + * @return array|false + * @throws \Exception + */ + public function getGroupDetails($gid) { + $plugin = $this->which[GroupInterface::GROUP_DETAILS]; + + if ($plugin) { + return $plugin->getGroupDetails($gid); + } + throw new \Exception('No plugin implements getGroupDetails in this LDAP Backend.'); + } +} diff --git a/apps/user_ldap/lib/Group_LDAP.php b/apps/user_ldap/lib/Group_LDAP.php new file mode 100644 index 00000000000..271cc96afbd --- /dev/null +++ b/apps/user_ldap/lib/Group_LDAP.php @@ -0,0 +1,1422 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use Exception; +use OC\ServerNotAvailableException; +use OCA\User_LDAP\User\OfflineUser; +use OCP\Cache\CappedMemoryCache; +use OCP\Group\Backend\ABackend; +use OCP\Group\Backend\IDeleteGroupBackend; +use OCP\Group\Backend\IGetDisplayNameBackend; +use OCP\Group\Backend\IIsAdminBackend; +use OCP\GroupInterface; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Server; +use Psr\Log\LoggerInterface; +use function json_decode; + +class Group_LDAP extends ABackend implements GroupInterface, IGroupLDAP, IGetDisplayNameBackend, IDeleteGroupBackend, IIsAdminBackend { + protected bool $enabled = false; + + /** @var CappedMemoryCache<string[]> $cachedGroupMembers array of user DN with gid as key */ + protected CappedMemoryCache $cachedGroupMembers; + /** @var CappedMemoryCache<array[]> $cachedGroupsByMember array of groups with user DN as key */ + protected CappedMemoryCache $cachedGroupsByMember; + /** @var CappedMemoryCache<string[]> $cachedNestedGroups array of groups with gid (DN) as key */ + protected CappedMemoryCache $cachedNestedGroups; + protected LoggerInterface $logger; + + /** + * @var string $ldapGroupMemberAssocAttr contains the LDAP setting (in lower case) with the same name + */ + protected string $ldapGroupMemberAssocAttr; + + public function __construct( + protected Access $access, + protected GroupPluginManager $groupPluginManager, + private IConfig $config, + private IUserManager $ncUserManager, + ) { + $filter = $this->access->connection->ldapGroupFilter; + $gAssoc = $this->access->connection->ldapGroupMemberAssocAttr; + if (!empty($filter) && !empty($gAssoc)) { + $this->enabled = true; + } + + $this->cachedGroupMembers = new CappedMemoryCache(); + $this->cachedGroupsByMember = new CappedMemoryCache(); + $this->cachedNestedGroups = new CappedMemoryCache(); + $this->logger = Server::get(LoggerInterface::class); + $this->ldapGroupMemberAssocAttr = strtolower((string)$gAssoc); + } + + /** + * Check if user is in group + * + * @param string $uid uid of the user + * @param string $gid gid of the group + * @throws Exception + * @throws ServerNotAvailableException + */ + public function inGroup($uid, $gid): bool { + if (!$this->enabled) { + return false; + } + $cacheKey = 'inGroup' . $uid . ':' . $gid; + $inGroup = $this->access->connection->getFromCache($cacheKey); + if (!is_null($inGroup)) { + return (bool)$inGroup; + } + + $userDN = $this->access->username2dn($uid); + + if (isset($this->cachedGroupMembers[$gid])) { + return in_array($userDN, $this->cachedGroupMembers[$gid]); + } + + $cacheKeyMembers = 'inGroup-members:' . $gid; + $members = $this->access->connection->getFromCache($cacheKeyMembers); + if (!is_null($members)) { + $this->cachedGroupMembers[$gid] = $members; + $isInGroup = in_array($userDN, $members, true); + $this->access->connection->writeToCache($cacheKey, $isInGroup); + return $isInGroup; + } + + $groupDN = $this->access->groupname2dn($gid); + // just in case + if (!$groupDN || !$userDN) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + //check primary group first + if ($gid === $this->getUserPrimaryGroup($userDN)) { + $this->access->connection->writeToCache($cacheKey, true); + return true; + } + + //usually, LDAP attributes are said to be case insensitive. But there are exceptions of course. + $members = $this->_groupMembers($groupDN); + + //extra work if we don't get back user DNs + switch ($this->ldapGroupMemberAssocAttr) { + case 'memberuid': + case 'zimbramailforwardingaddress': + $requestAttributes = $this->access->userManager->getAttributes(true); + $users = []; + $filterParts = []; + $bytes = 0; + foreach ($members as $mid) { + if ($this->ldapGroupMemberAssocAttr === 'zimbramailforwardingaddress') { + $parts = explode('@', $mid); //making sure we get only the uid + $mid = $parts[0]; + } + $filter = str_replace('%uid', $mid, $this->access->connection->ldapLoginFilter); + $filterParts[] = $filter; + $bytes += strlen($filter); + if ($bytes >= 9000000) { + // AD has a default input buffer of 10 MB, we do not want + // to take even the chance to exceed it + // so we fetch results with the filterParts we collected so far + $filter = $this->access->combineFilterWithOr($filterParts); + $search = $this->access->fetchListOfUsers($filter, $requestAttributes, count($filterParts)); + $bytes = 0; + $filterParts = []; + $users = array_merge($users, $search); + } + } + + if (count($filterParts) > 0) { + // if there are filterParts left we need to add their result + $filter = $this->access->combineFilterWithOr($filterParts); + $search = $this->access->fetchListOfUsers($filter, $requestAttributes, count($filterParts)); + $users = array_merge($users, $search); + } + + // now we cleanup the users array to get only dns + $dns = []; + foreach ($users as $record) { + $dns[$record['dn'][0]] = 1; + } + $members = array_keys($dns); + + break; + } + + if (count($members) === 0) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + $isInGroup = in_array($userDN, $members); + $this->access->connection->writeToCache($cacheKey, $isInGroup); + $this->access->connection->writeToCache($cacheKeyMembers, $members); + $this->cachedGroupMembers[$gid] = $members; + + return $isInGroup; + } + + /** + * For a group that has user membership defined by an LDAP search url + * attribute returns the users that match the search url otherwise returns + * an empty array. + * + * @throws ServerNotAvailableException + */ + public function getDynamicGroupMembers(string $dnGroup): array { + $dynamicGroupMemberURL = strtolower((string)$this->access->connection->ldapDynamicGroupMemberURL); + + if (empty($dynamicGroupMemberURL)) { + return []; + } + + $dynamicMembers = []; + $memberURLs = $this->access->readAttribute( + $dnGroup, + $dynamicGroupMemberURL, + $this->access->connection->ldapGroupFilter + ); + if ($memberURLs !== false) { + // this group has the 'memberURL' attribute so this is a dynamic group + // example 1: ldap:///cn=users,cn=accounts,dc=dcsubbase,dc=dcbase??one?(o=HeadOffice) + // example 2: ldap:///cn=users,cn=accounts,dc=dcsubbase,dc=dcbase??one?(&(o=HeadOffice)(uidNumber>=500)) + $pos = strpos($memberURLs[0], '('); + if ($pos !== false) { + $memberUrlFilter = substr($memberURLs[0], $pos); + $foundMembers = $this->access->searchUsers($memberUrlFilter, ['dn']); + $dynamicMembers = []; + foreach ($foundMembers as $value) { + $dynamicMembers[$value['dn'][0]] = 1; + } + } else { + $this->logger->debug('No search filter found on member url of group {dn}', + [ + 'app' => 'user_ldap', + 'dn' => $dnGroup, + ] + ); + } + } + return $dynamicMembers; + } + + /** + * Get group members from dn. + * @psalm-param array<string, bool> $seen List of DN that have already been processed. + * @throws ServerNotAvailableException + */ + private function _groupMembers(string $dnGroup, array $seen = [], bool &$recursive = false): array { + if (isset($seen[$dnGroup])) { + $recursive = true; + return []; + } + $seen[$dnGroup] = true; + + // used extensively in cron job, caching makes sense for nested groups + $cacheKey = '_groupMembers' . $dnGroup; + $groupMembers = $this->access->connection->getFromCache($cacheKey); + if ($groupMembers !== null) { + return $groupMembers; + } + + if ($this->access->connection->ldapNestedGroups + && $this->access->connection->useMemberOfToDetectMembership + && $this->access->connection->hasMemberOfFilterSupport + && $this->access->connection->ldapMatchingRuleInChainState !== Configuration::LDAP_SERVER_FEATURE_UNAVAILABLE + ) { + $attemptedLdapMatchingRuleInChain = true; + // Use matching rule 1.2.840.113556.1.4.1941 if available (LDAP_MATCHING_RULE_IN_CHAIN) + $filter = $this->access->combineFilterWithAnd([ + $this->access->connection->ldapUserFilter, + $this->access->connection->ldapUserDisplayName . '=*', + 'memberof:1.2.840.113556.1.4.1941:=' . $dnGroup + ]); + $memberRecords = $this->access->fetchListOfUsers( + $filter, + $this->access->userManager->getAttributes(true) + ); + $result = array_reduce($memberRecords, function ($carry, $record) { + $carry[] = $record['dn'][0]; + return $carry; + }, []); + if ($this->access->connection->ldapMatchingRuleInChainState === Configuration::LDAP_SERVER_FEATURE_AVAILABLE) { + $this->access->connection->writeToCache($cacheKey, $result); + return $result; + } elseif (!empty($memberRecords)) { + $this->access->connection->ldapMatchingRuleInChainState = Configuration::LDAP_SERVER_FEATURE_AVAILABLE; + $this->access->connection->saveConfiguration(); + $this->access->connection->writeToCache($cacheKey, $result); + return $result; + } + // when feature availability is unknown, and the result is empty, continue and test with original approach + } + + $allMembers = []; + $members = $this->access->readAttribute($dnGroup, $this->access->connection->ldapGroupMemberAssocAttr); + if (is_array($members)) { + if ((int)$this->access->connection->ldapNestedGroups === 1) { + while ($recordDn = array_shift($members)) { + $nestedMembers = $this->_groupMembers($recordDn, $seen, $recursive); + if (!empty($nestedMembers)) { + // Group, queue its members for processing + $members = array_merge($members, $nestedMembers); + } else { + // User (or empty group, or previously seen group), add it to the member list + $allMembers[] = $recordDn; + } + } + } else { + $allMembers = $members; + } + } + + $allMembers += $this->getDynamicGroupMembers($dnGroup); + + $allMembers = array_unique($allMembers); + + // A group cannot be a member of itself + $index = array_search($dnGroup, $allMembers, true); + if ($index !== false) { + unset($allMembers[$index]); + } + + if (!$recursive) { + $this->access->connection->writeToCache($cacheKey, $allMembers); + } + + if (isset($attemptedLdapMatchingRuleInChain) + && $this->access->connection->ldapMatchingRuleInChainState === Configuration::LDAP_SERVER_FEATURE_UNKNOWN + && !empty($allMembers) + ) { + $this->access->connection->ldapMatchingRuleInChainState = Configuration::LDAP_SERVER_FEATURE_UNAVAILABLE; + $this->access->connection->saveConfiguration(); + } + + return $allMembers; + } + + /** + * @return string[] + * @throws ServerNotAvailableException + */ + private function _getGroupDNsFromMemberOf(string $dn, array &$seen = []): array { + if (isset($seen[$dn])) { + return []; + } + $seen[$dn] = true; + + if (isset($this->cachedNestedGroups[$dn])) { + return $this->cachedNestedGroups[$dn]; + } + + $allGroups = []; + $groups = $this->access->readAttribute($dn, 'memberOf'); + if (is_array($groups)) { + if ((int)$this->access->connection->ldapNestedGroups === 1) { + while ($recordDn = array_shift($groups)) { + $nestedParents = $this->_getGroupDNsFromMemberOf($recordDn, $seen); + $groups = array_merge($groups, $nestedParents); + $allGroups[] = $recordDn; + } + } else { + $allGroups = $groups; + } + } + + // We do not perform array_unique here at it is done in getUserGroups later + $this->cachedNestedGroups[$dn] = $allGroups; + return $this->filterValidGroups($allGroups); + } + + /** + * Translates a gidNumber into the Nextcloud internal name. + * + * @return string|false The nextcloud internal name. + * @throws Exception + * @throws ServerNotAvailableException + */ + public function gidNumber2Name(string $gid, string $dn) { + $cacheKey = 'gidNumberToName' . $gid; + $groupName = $this->access->connection->getFromCache($cacheKey); + if (!is_null($groupName) && isset($groupName)) { + return $groupName; + } + + //we need to get the DN from LDAP + $filter = $this->access->combineFilterWithAnd([ + $this->access->connection->ldapGroupFilter, + 'objectClass=posixGroup', + $this->access->connection->ldapGidNumber . '=' . $gid + ]); + return $this->getNameOfGroup($filter, $cacheKey) ?? false; + } + + /** + * @return string|null|false The name of the group + * @throws ServerNotAvailableException + * @throws Exception + */ + private function getNameOfGroup(string $filter, string $cacheKey) { + $result = $this->access->searchGroups($filter, ['dn'], 1); + if (empty($result)) { + $this->access->connection->writeToCache($cacheKey, false); + return null; + } + $dn = $result[0]['dn'][0]; + + //and now the group name + //NOTE once we have separate Nextcloud group IDs and group names we can + //directly read the display name attribute instead of the DN + $name = $this->access->dn2groupname($dn); + + $this->access->connection->writeToCache($cacheKey, $name); + + return $name; + } + + /** + * @return string|bool The entry's gidNumber + * @throws ServerNotAvailableException + */ + private function getEntryGidNumber(string $dn, string $attribute) { + $value = $this->access->readAttribute($dn, $attribute); + if (is_array($value) && !empty($value)) { + return $value[0]; + } + return false; + } + + /** + * @return string|bool The group's gidNumber + * @throws ServerNotAvailableException + */ + public function getGroupGidNumber(string $dn) { + return $this->getEntryGidNumber($dn, 'gidNumber'); + } + + /** + * @return string|bool The user's gidNumber + * @throws ServerNotAvailableException + */ + public function getUserGidNumber(string $dn) { + $gidNumber = false; + if ($this->access->connection->hasGidNumber) { + // FIXME: when $dn does not exist on LDAP anymore, this will be set wrongly to false :/ + $gidNumber = $this->getEntryGidNumber($dn, $this->access->connection->ldapGidNumber); + if ($gidNumber === false) { + $this->access->connection->hasGidNumber = false; + } + } + return $gidNumber; + } + + /** + * @throws ServerNotAvailableException + * @throws Exception + */ + private function prepareFilterForUsersHasGidNumber(string $groupDN, string $search = ''): string { + $groupID = $this->getGroupGidNumber($groupDN); + if ($groupID === false) { + throw new Exception('Not a valid group'); + } + + $filterParts = []; + $filterParts[] = $this->access->getFilterForUserCount(); + if ($search !== '') { + $filterParts[] = $this->access->getFilterPartForUserSearch($search); + } + $filterParts[] = $this->access->connection->ldapGidNumber . '=' . $groupID; + + return $this->access->combineFilterWithAnd($filterParts); + } + + /** + * @return array<int,string> A list of users that have the given group as gid number + * @throws ServerNotAvailableException + */ + public function getUsersInGidNumber( + string $groupDN, + string $search = '', + ?int $limit = -1, + ?int $offset = 0, + ): array { + try { + $filter = $this->prepareFilterForUsersHasGidNumber($groupDN, $search); + $users = $this->access->fetchListOfUsers( + $filter, + $this->access->userManager->getAttributes(true), + $limit, + $offset + ); + return $this->access->nextcloudUserNames($users); + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { + return []; + } + } + + /** + * @throws ServerNotAvailableException + * @return false|string + */ + public function getUserGroupByGid(string $dn) { + $groupID = $this->getUserGidNumber($dn); + if ($groupID !== false) { + $groupName = $this->gidNumber2Name($groupID, $dn); + if ($groupName !== false) { + return $groupName; + } + } + + return false; + } + + /** + * Translates a primary group ID into an Nextcloud internal name + * + * @return string|false + * @throws Exception + * @throws ServerNotAvailableException + */ + public function primaryGroupID2Name(string $gid, string $dn) { + $cacheKey = 'primaryGroupIDtoName_' . $gid; + $groupName = $this->access->connection->getFromCache($cacheKey); + if (!is_null($groupName)) { + return $groupName; + } + + $domainObjectSid = $this->access->getSID($dn); + if ($domainObjectSid === false) { + return false; + } + + //we need to get the DN from LDAP + $filter = $this->access->combineFilterWithAnd([ + $this->access->connection->ldapGroupFilter, + 'objectsid=' . $domainObjectSid . '-' . $gid + ]); + return $this->getNameOfGroup($filter, $cacheKey) ?? false; + } + + /** + * @return string|false The entry's group Id + * @throws ServerNotAvailableException + */ + private function getEntryGroupID(string $dn, string $attribute) { + $value = $this->access->readAttribute($dn, $attribute); + if (is_array($value) && !empty($value)) { + return $value[0]; + } + return false; + } + + /** + * @return string|false The entry's primary group Id + * @throws ServerNotAvailableException + */ + public function getGroupPrimaryGroupID(string $dn) { + return $this->getEntryGroupID($dn, 'primaryGroupToken'); + } + + /** + * @return string|false + * @throws ServerNotAvailableException + */ + public function getUserPrimaryGroupIDs(string $dn) { + $primaryGroupID = false; + if ($this->access->connection->hasPrimaryGroups) { + $primaryGroupID = $this->getEntryGroupID($dn, 'primaryGroupID'); + if ($primaryGroupID === false) { + $this->access->connection->hasPrimaryGroups = false; + } + } + return $primaryGroupID; + } + + /** + * @throws Exception + * @throws ServerNotAvailableException + */ + private function prepareFilterForUsersInPrimaryGroup(string $groupDN, string $search = ''): string { + $groupID = $this->getGroupPrimaryGroupID($groupDN); + if ($groupID === false) { + throw new Exception('Not a valid group'); + } + + $filterParts = []; + $filterParts[] = $this->access->getFilterForUserCount(); + if ($search !== '') { + $filterParts[] = $this->access->getFilterPartForUserSearch($search); + } + $filterParts[] = 'primaryGroupID=' . $groupID; + + return $this->access->combineFilterWithAnd($filterParts); + } + + /** + * @throws ServerNotAvailableException + * @return array<int,string> + */ + public function getUsersInPrimaryGroup( + string $groupDN, + string $search = '', + ?int $limit = -1, + ?int $offset = 0, + ): array { + try { + $filter = $this->prepareFilterForUsersInPrimaryGroup($groupDN, $search); + $users = $this->access->fetchListOfUsers( + $filter, + $this->access->userManager->getAttributes(true), + $limit, + $offset + ); + return $this->access->nextcloudUserNames($users); + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { + return []; + } + } + + /** + * @throws ServerNotAvailableException + */ + public function countUsersInPrimaryGroup( + string $groupDN, + string $search = '', + int $limit = -1, + int $offset = 0, + ): int { + try { + $filter = $this->prepareFilterForUsersInPrimaryGroup($groupDN, $search); + $users = $this->access->countUsers($filter, ['dn'], $limit, $offset); + return (int)$users; + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { + return 0; + } + } + + /** + * @return string|false + * @throws ServerNotAvailableException + */ + public function getUserPrimaryGroup(string $dn) { + $groupID = $this->getUserPrimaryGroupIDs($dn); + if ($groupID !== false) { + $groupName = $this->primaryGroupID2Name($groupID, $dn); + if ($groupName !== false) { + return $groupName; + } + } + + return false; + } + + private function isUserOnLDAP(string $uid): bool { + // forces a user exists check - but does not help if a positive result is cached, while group info is not + $ncUser = $this->ncUserManager->get($uid); + if ($ncUser === null) { + return false; + } + $backend = $ncUser->getBackend(); + if ($backend instanceof User_Proxy) { + // ignoring cache as safeguard (and we are behind the group cache check anyway) + return $backend->userExistsOnLDAP($uid, true); + } + return false; + } + + /** + * @param string $uid + * @return list<string> + */ + protected function getCachedGroupsForUserId(string $uid): array { + $groupStr = $this->config->getUserValue($uid, 'user_ldap', 'cached-group-memberships-' . $this->access->connection->getConfigPrefix(), '[]'); + return json_decode($groupStr, true) ?? []; + } + + /** + * This function fetches all groups a user belongs to. It does not check + * if the user exists at all. + * + * This function includes groups based on dynamic group membership. + * + * @param string $uid Name of the user + * @return list<string> Group names + * @throws Exception + * @throws ServerNotAvailableException + */ + public function getUserGroups($uid): array { + if (!$this->enabled) { + return []; + } + $ncUid = $uid; + + $cacheKey = 'getUserGroups' . $uid; + $userGroups = $this->access->connection->getFromCache($cacheKey); + if (!is_null($userGroups)) { + return $userGroups; + } + + $user = $this->access->userManager->get($uid); + if ($user instanceof OfflineUser) { + // We load known group memberships from configuration for remnants, + // because LDAP server does not contain them anymore + return $this->getCachedGroupsForUserId($uid); + } + + $userDN = $this->access->username2dn($uid); + if (!$userDN) { + $this->access->connection->writeToCache($cacheKey, []); + return []; + } + + $groups = []; + $primaryGroup = $this->getUserPrimaryGroup($userDN); + $gidGroupName = $this->getUserGroupByGid($userDN); + + $dynamicGroupMemberURL = strtolower($this->access->connection->ldapDynamicGroupMemberURL); + + if (!empty($dynamicGroupMemberURL)) { + // look through dynamic groups to add them to the result array if needed + $groupsToMatch = $this->access->fetchListOfGroups( + $this->access->connection->ldapGroupFilter, ['dn', $dynamicGroupMemberURL]); + foreach ($groupsToMatch as $dynamicGroup) { + if (!isset($dynamicGroup[$dynamicGroupMemberURL][0])) { + continue; + } + $pos = strpos($dynamicGroup[$dynamicGroupMemberURL][0], '('); + if ($pos !== false) { + $memberUrlFilter = substr($dynamicGroup[$dynamicGroupMemberURL][0], $pos); + // apply filter via ldap search to see if this user is in this + // dynamic group + $userMatch = $this->access->readAttribute( + $userDN, + $this->access->connection->ldapUserDisplayName, + $memberUrlFilter + ); + if ($userMatch !== false) { + // match found so this user is in this group + $groupName = $this->access->dn2groupname($dynamicGroup['dn'][0]); + if (is_string($groupName)) { + // be sure to never return false if the dn could not be + // resolved to a name, for whatever reason. + $groups[] = $groupName; + } + } + } else { + $this->logger->debug('No search filter found on member url of group {dn}', + [ + 'app' => 'user_ldap', + 'dn' => $dynamicGroup, + ] + ); + } + } + } + + // if possible, read out membership via memberOf. It's far faster than + // performing a search, which still is a fallback later. + // memberof doesn't support memberuid, so skip it here. + if ((int)$this->access->connection->hasMemberOfFilterSupport === 1 + && (int)$this->access->connection->useMemberOfToDetectMembership === 1 + && $this->ldapGroupMemberAssocAttr !== 'memberuid' + && $this->ldapGroupMemberAssocAttr !== 'zimbramailforwardingaddress') { + $groupDNs = $this->_getGroupDNsFromMemberOf($userDN); + foreach ($groupDNs as $dn) { + $groupName = $this->access->dn2groupname($dn); + if (is_string($groupName)) { + // be sure to never return false if the dn could not be + // resolved to a name, for whatever reason. + $groups[] = $groupName; + } + } + } else { + // uniqueMember takes DN, memberuid the uid, so we need to distinguish + switch ($this->ldapGroupMemberAssocAttr) { + case 'uniquemember': + case 'member': + $uid = $userDN; + break; + + case 'memberuid': + case 'zimbramailforwardingaddress': + $result = $this->access->readAttribute($userDN, 'uid'); + if ($result === false) { + $this->logger->debug('No uid attribute found for DN {dn} on {host}', + [ + 'app' => 'user_ldap', + 'dn' => $userDN, + 'host' => $this->access->connection->ldapHost, + ] + ); + $uid = false; + } else { + $uid = $result[0]; + } + break; + + default: + // just in case + $uid = $userDN; + break; + } + + if ($uid !== false) { + $groupsByMember = array_values($this->getGroupsByMember($uid)); + $groupsByMember = $this->access->nextcloudGroupNames($groupsByMember); + $groups = array_merge($groups, $groupsByMember); + } + } + + if ($primaryGroup !== false) { + $groups[] = $primaryGroup; + } + if ($gidGroupName !== false) { + $groups[] = $gidGroupName; + } + + if (empty($groups) && !$this->isUserOnLDAP($ncUid)) { + // Groups are enabled, but you user has none? Potentially suspicious: + // it could be that the user was deleted from LDAP, but we are not + // aware of it yet. + $groups = $this->getCachedGroupsForUserId($ncUid); + $this->access->connection->writeToCache($cacheKey, $groups); + return $groups; + } + + $groups = array_values(array_unique($groups, SORT_LOCALE_STRING)); + $this->access->connection->writeToCache($cacheKey, $groups); + + $groupStr = \json_encode($groups); + $this->config->setUserValue($ncUid, 'user_ldap', 'cached-group-memberships-' . $this->access->connection->getConfigPrefix(), $groupStr); + + return $groups; + } + + /** + * @return array[] + * @throws ServerNotAvailableException + */ + private function getGroupsByMember(string $dn, array &$seen = []): array { + if (isset($seen[$dn])) { + return []; + } + $seen[$dn] = true; + + if (isset($this->cachedGroupsByMember[$dn])) { + return $this->cachedGroupsByMember[$dn]; + } + + $filter = $this->access->connection->ldapGroupMemberAssocAttr . '=' . $dn; + + if ($this->ldapGroupMemberAssocAttr === 'zimbramailforwardingaddress') { + //in this case the member entries are email addresses + $filter .= '@*'; + } + + $nesting = (int)$this->access->connection->ldapNestedGroups; + if ($nesting === 0) { + $filter = $this->access->combineFilterWithAnd([$filter, $this->access->connection->ldapGroupFilter]); + } + + $allGroups = []; + $groups = $this->access->fetchListOfGroups($filter, + [strtolower($this->access->connection->ldapGroupMemberAssocAttr), $this->access->connection->ldapGroupDisplayName, 'dn']); + + if ($nesting === 1) { + while ($record = array_shift($groups)) { + // Note: this has no effect when ldapGroupMemberAssocAttr is uid based + $nestedParents = $this->getGroupsByMember($record['dn'][0], $seen); + $groups = array_merge($groups, $nestedParents); + $allGroups[] = $record; + } + } else { + $allGroups = $groups; + } + + $visibleGroups = $this->filterValidGroups($allGroups); + $this->cachedGroupsByMember[$dn] = $visibleGroups; + return $visibleGroups; + } + + /** + * get a list of all users in a group + * + * @param string $gid + * @param string $search + * @param int $limit + * @param int $offset + * @return array<int,string> user ids + * @throws Exception + * @throws ServerNotAvailableException + */ + public function usersInGroup($gid, $search = '', $limit = -1, $offset = 0) { + if (!$this->enabled) { + return []; + } + if (!$this->groupExists($gid)) { + return []; + } + $search = $this->access->escapeFilterPart($search, true); + $cacheKey = 'usersInGroup-' . $gid . '-' . $search . '-' . $limit . '-' . $offset; + // check for cache of the exact query + $groupUsers = $this->access->connection->getFromCache($cacheKey); + if (!is_null($groupUsers)) { + return $groupUsers; + } + + if ($limit === -1) { + $limit = null; + } + // check for cache of the query without limit and offset + $groupUsers = $this->access->connection->getFromCache('usersInGroup-' . $gid . '-' . $search); + if (!is_null($groupUsers)) { + $groupUsers = array_slice($groupUsers, $offset, $limit); + $this->access->connection->writeToCache($cacheKey, $groupUsers); + return $groupUsers; + } + + $groupDN = $this->access->groupname2dn($gid); + if (!$groupDN) { + // group couldn't be found, return empty result-set + $this->access->connection->writeToCache($cacheKey, []); + return []; + } + + $primaryUsers = $this->getUsersInPrimaryGroup($groupDN, $search, $limit, $offset); + $posixGroupUsers = $this->getUsersInGidNumber($groupDN, $search, $limit, $offset); + $members = $this->_groupMembers($groupDN); + if (!$members && empty($posixGroupUsers) && empty($primaryUsers)) { + //in case users could not be retrieved, return empty result set + $this->access->connection->writeToCache($cacheKey, []); + return []; + } + + $groupUsers = []; + $attrs = $this->access->userManager->getAttributes(true); + foreach ($members as $member) { + switch ($this->ldapGroupMemberAssocAttr) { + /** @noinspection PhpMissingBreakStatementInspection */ + case 'zimbramailforwardingaddress': + //we get email addresses and need to convert them to uids + $parts = explode('@', $member); + $member = $parts[0]; + //no break needed because we just needed to remove the email part and now we have uids + case 'memberuid': + //we got uids, need to get their DNs to 'translate' them to user names + $filter = $this->access->combineFilterWithAnd([ + str_replace('%uid', trim($member), $this->access->connection->ldapLoginFilter), + $this->access->combineFilterWithAnd([ + $this->access->getFilterPartForUserSearch($search), + $this->access->connection->ldapUserFilter + ]) + ]); + $ldap_users = $this->access->fetchListOfUsers($filter, $attrs, 1); + if (empty($ldap_users)) { + break; + } + $uid = $this->access->dn2username($ldap_users[0]['dn'][0]); + if (!$uid) { + break; + } + $groupUsers[] = $uid; + break; + default: + //we got DNs, check if we need to filter by search or we can give back all of them + $uid = $this->access->dn2username($member); + if (!$uid) { + break; + } + + $cacheKey = 'userExistsOnLDAP' . $uid; + $userExists = $this->access->connection->getFromCache($cacheKey); + if ($userExists === false) { + break; + } + if ($userExists === null || $search !== '') { + if (!$this->access->readAttribute($member, + $this->access->connection->ldapUserDisplayName, + $this->access->combineFilterWithAnd([ + $this->access->getFilterPartForUserSearch($search), + $this->access->connection->ldapUserFilter + ]))) { + if ($search === '') { + $this->access->connection->writeToCache($cacheKey, false); + } + break; + } + $this->access->connection->writeToCache($cacheKey, true); + } + $groupUsers[] = $uid; + break; + } + } + + $groupUsers = array_unique(array_merge($groupUsers, $primaryUsers, $posixGroupUsers)); + natsort($groupUsers); + $this->access->connection->writeToCache('usersInGroup-' . $gid . '-' . $search, $groupUsers); + $groupUsers = array_slice($groupUsers, $offset, $limit); + + $this->access->connection->writeToCache($cacheKey, $groupUsers); + + return $groupUsers; + } + + /** + * returns the number of users in a group, who match the search term + * + * @param string $gid the internal group name + * @param string $search optional, a search string + * @return int|bool + * @throws Exception + * @throws ServerNotAvailableException + */ + public function countUsersInGroup($gid, $search = '') { + if ($this->groupPluginManager->implementsActions(GroupInterface::COUNT_USERS)) { + return $this->groupPluginManager->countUsersInGroup($gid, $search); + } + + $cacheKey = 'countUsersInGroup-' . $gid . '-' . $search; + if (!$this->enabled || !$this->groupExists($gid)) { + return false; + } + $groupUsers = $this->access->connection->getFromCache($cacheKey); + if (!is_null($groupUsers)) { + return $groupUsers; + } + + $groupDN = $this->access->groupname2dn($gid); + if (!$groupDN) { + // group couldn't be found, return empty result set + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + $members = $this->_groupMembers($groupDN); + $primaryUserCount = $this->countUsersInPrimaryGroup($groupDN, ''); + if (!$members && $primaryUserCount === 0) { + //in case users could not be retrieved, return empty result set + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + if ($search === '') { + $groupUsers = count($members) + $primaryUserCount; + $this->access->connection->writeToCache($cacheKey, $groupUsers); + return $groupUsers; + } + $search = $this->access->escapeFilterPart($search, true); + $isMemberUid + = ($this->ldapGroupMemberAssocAttr === 'memberuid' + || $this->ldapGroupMemberAssocAttr === 'zimbramailforwardingaddress'); + + //we need to apply the search filter + //alternatives that need to be checked: + //a) get all users by search filter and array_intersect them + //b) a, but only when less than 1k 10k ?k users like it is + //c) put all DNs|uids in a LDAP filter, combine with the search string + // and let it count. + //For now this is not important, because the only use of this method + //does not supply a search string + $groupUsers = []; + foreach ($members as $member) { + if ($isMemberUid) { + if ($this->ldapGroupMemberAssocAttr === 'zimbramailforwardingaddress') { + //we get email addresses and need to convert them to uids + $parts = explode('@', $member); + $member = $parts[0]; + } + //we got uids, need to get their DNs to 'translate' them to user names + $filter = $this->access->combineFilterWithAnd([ + str_replace('%uid', $member, $this->access->connection->ldapLoginFilter), + $this->access->getFilterPartForUserSearch($search) + ]); + $ldap_users = $this->access->fetchListOfUsers($filter, ['dn'], 1); + if (count($ldap_users) < 1) { + continue; + } + $groupUsers[] = $this->access->dn2username($ldap_users[0]); + } else { + //we need to apply the search filter now + if (!$this->access->readAttribute($member, + $this->access->connection->ldapUserDisplayName, + $this->access->getFilterPartForUserSearch($search))) { + continue; + } + // dn2username will also check if the users belong to the allowed base + if ($ncGroupId = $this->access->dn2username($member)) { + $groupUsers[] = $ncGroupId; + } + } + } + + //and get users that have the group as primary + $primaryUsers = $this->countUsersInPrimaryGroup($groupDN, $search); + + return count($groupUsers) + $primaryUsers; + } + + /** + * get a list of all groups using a paged search + * + * @param string $search + * @param int $limit + * @param int $offset + * @return array with group names + * + * Returns a list with all groups + * Uses a paged search if available to override a + * server side search limit. + * (active directory has a limit of 1000 by default) + * @throws Exception + */ + public function getGroups($search = '', $limit = -1, $offset = 0) { + if (!$this->enabled) { + return []; + } + $search = $this->access->escapeFilterPart($search, true); + $cacheKey = 'getGroups-' . $search . '-' . $limit . '-' . $offset; + + //Check cache before driving unnecessary searches + $ldap_groups = $this->access->connection->getFromCache($cacheKey); + if (!is_null($ldap_groups)) { + return $ldap_groups; + } + + // if we'd pass -1 to LDAP search, we'd end up in a Protocol + // error. With a limit of 0, we get 0 results. So we pass null. + if ($limit <= 0) { + $limit = null; + } + $filter = $this->access->combineFilterWithAnd([ + $this->access->connection->ldapGroupFilter, + $this->access->getFilterPartForGroupSearch($search) + ]); + $ldap_groups = $this->access->fetchListOfGroups($filter, + [$this->access->connection->ldapGroupDisplayName, 'dn'], + $limit, + $offset); + $ldap_groups = $this->access->nextcloudGroupNames($ldap_groups); + + $this->access->connection->writeToCache($cacheKey, $ldap_groups); + return $ldap_groups; + } + + /** + * check if a group exists + * + * @param string $gid + * @return bool + * @throws ServerNotAvailableException + */ + public function groupExists($gid) { + return $this->groupExistsOnLDAP($gid, false); + } + + /** + * Check if a group exists + * + * @throws ServerNotAvailableException + */ + public function groupExistsOnLDAP(string $gid, bool $ignoreCache = false): bool { + $cacheKey = 'groupExists' . $gid; + if (!$ignoreCache) { + $groupExists = $this->access->connection->getFromCache($cacheKey); + if (!is_null($groupExists)) { + return (bool)$groupExists; + } + } + + //getting dn, if false the group does not exist. If dn, it may be mapped + //only, requires more checking. + $dn = $this->access->groupname2dn($gid); + if (!$dn) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + if (!$this->access->isDNPartOfBase($dn, $this->access->connection->ldapBaseGroups)) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + //if group really still exists, we will be able to read its objectClass + if (!is_array($this->access->readAttribute($dn, '', $this->access->connection->ldapGroupFilter))) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + + $this->access->connection->writeToCache($cacheKey, true); + return true; + } + + /** + * @template T + * @param array<array-key, T> $listOfGroups + * @return array<array-key, T> + * @throws ServerNotAvailableException + * @throws Exception + */ + protected function filterValidGroups(array $listOfGroups): array { + $validGroupDNs = []; + foreach ($listOfGroups as $key => $item) { + $dn = is_string($item) ? $item : $item['dn'][0]; + if (is_array($item) && !isset($item[$this->access->connection->ldapGroupDisplayName][0])) { + continue; + } + $name = $item[$this->access->connection->ldapGroupDisplayName][0] ?? null; + $gid = $this->access->dn2groupname($dn, $name, false); + if (!$gid) { + continue; + } + if ($this->groupExists($gid)) { + $validGroupDNs[$key] = $item; + } + } + return $validGroupDNs; + } + + /** + * Check if backend implements actions + * + * @param int $actions bitwise-or'ed actions + * @return boolean + * + * Returns the supported actions as int to be + * compared with GroupInterface::CREATE_GROUP etc. + */ + public function implementsActions($actions): bool { + return (bool)((GroupInterface::COUNT_USERS + | GroupInterface::DELETE_GROUP + | GroupInterface::IS_ADMIN + | $this->groupPluginManager->getImplementedActions()) & $actions); + } + + /** + * Return access for LDAP interaction. + * + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($gid) { + return $this->access; + } + + /** + * create a group + * + * @param string $gid + * @return bool + * @throws Exception + * @throws ServerNotAvailableException + */ + public function createGroup($gid) { + if ($this->groupPluginManager->implementsActions(GroupInterface::CREATE_GROUP)) { + if ($dn = $this->groupPluginManager->createGroup($gid)) { + //updates group mapping + $uuid = $this->access->getUUID($dn, false); + if (is_string($uuid)) { + $this->access->mapAndAnnounceIfApplicable( + $this->access->getGroupMapper(), + $dn, + $gid, + $uuid, + false + ); + $this->access->cacheGroupExists($gid); + } + } + return $dn != null; + } + throw new Exception('Could not create group in LDAP backend.'); + } + + /** + * delete a group + * + * @param string $gid gid of the group to delete + * @throws Exception + */ + public function deleteGroup(string $gid): bool { + if ($this->groupPluginManager->canDeleteGroup()) { + if ($ret = $this->groupPluginManager->deleteGroup($gid)) { + // Delete group in nextcloud internal db + $this->access->getGroupMapper()->unmap($gid); + $this->access->connection->writeToCache('groupExists' . $gid, false); + } + return $ret; + } + + // Getting dn, if false the group is not mapped + $dn = $this->access->groupname2dn($gid); + if (!$dn) { + throw new Exception('Could not delete unknown group ' . $gid . ' in LDAP backend.'); + } + + if (!$this->groupExists($gid)) { + // The group does not exist in the LDAP, remove the mapping + $this->access->getGroupMapper()->unmap($gid); + $this->access->connection->writeToCache('groupExists' . $gid, false); + return true; + } + + throw new Exception('Could not delete existing group ' . $gid . ' in LDAP backend.'); + } + + /** + * Add a user to a group + * + * @param string $uid Name of the user to add to group + * @param string $gid Name of the group in which add the user + * @return bool + * @throws Exception + */ + public function addToGroup($uid, $gid) { + if ($this->groupPluginManager->implementsActions(GroupInterface::ADD_TO_GROUP)) { + if ($ret = $this->groupPluginManager->addToGroup($uid, $gid)) { + $this->access->connection->clearCache(); + unset($this->cachedGroupMembers[$gid]); + } + return $ret; + } + throw new Exception('Could not add user to group in LDAP backend.'); + } + + /** + * Removes a user from a group + * + * @param string $uid Name of the user to remove from group + * @param string $gid Name of the group from which remove the user + * @return bool + * @throws Exception + */ + public function removeFromGroup($uid, $gid) { + if ($this->groupPluginManager->implementsActions(GroupInterface::REMOVE_FROM_GROUP)) { + if ($ret = $this->groupPluginManager->removeFromGroup($uid, $gid)) { + $this->access->connection->clearCache(); + unset($this->cachedGroupMembers[$gid]); + } + return $ret; + } + throw new Exception('Could not remove user from group in LDAP backend.'); + } + + /** + * Gets group details + * + * @param string $gid Name of the group + * @return array|false + * @throws Exception + */ + public function getGroupDetails($gid) { + if ($this->groupPluginManager->implementsActions(GroupInterface::GROUP_DETAILS)) { + return $this->groupPluginManager->getGroupDetails($gid); + } + throw new Exception('Could not get group details in LDAP backend.'); + } + + /** + * Return LDAP connection resource from a cloned connection. + * The cloned connection needs to be closed manually. + * of the current access. + * + * @param string $gid + * @return \LDAP\Connection The LDAP connection + * @throws ServerNotAvailableException + */ + public function getNewLDAPConnection($gid): \LDAP\Connection { + $connection = clone $this->access->getConnection(); + return $connection->getConnectionResource(); + } + + /** + * @throws ServerNotAvailableException + */ + public function getDisplayName(string $gid): string { + if ($this->groupPluginManager instanceof IGetDisplayNameBackend) { + return $this->groupPluginManager->getDisplayName($gid); + } + + $cacheKey = 'group_getDisplayName' . $gid; + if (!is_null($displayName = $this->access->connection->getFromCache($cacheKey))) { + return $displayName; + } + + $displayName = $this->access->readAttribute( + $this->access->groupname2dn($gid), + $this->access->connection->ldapGroupDisplayName); + + if (($displayName !== false) && (count($displayName) > 0)) { + $displayName = $displayName[0]; + } else { + $displayName = ''; + } + + $this->access->connection->writeToCache($cacheKey, $displayName); + return $displayName; + } + + /** + * returns the groupname for the given LDAP DN, if available + */ + public function dn2GroupName(string $dn): string|false { + return $this->access->dn2groupname($dn); + } + + public function addRelationshipToCaches(string $uid, ?string $dnUser, string $gid): void { + $dnGroup = $this->access->groupname2dn($gid); + $dnUser ??= $this->access->username2dn($uid); + if ($dnUser === false || $dnGroup === false) { + return; + } + if (isset($this->cachedGroupMembers[$gid])) { + $this->cachedGroupMembers[$gid] = array_merge($this->cachedGroupMembers[$gid], [$dnUser]); + } + unset($this->cachedGroupsByMember[$dnUser]); + unset($this->cachedNestedGroups[$gid]); + $cacheKey = 'inGroup' . $uid . ':' . $gid; + $this->access->connection->writeToCache($cacheKey, true); + $cacheKeyMembers = 'inGroup-members:' . $gid; + if (!is_null($data = $this->access->connection->getFromCache($cacheKeyMembers))) { + $this->access->connection->writeToCache($cacheKeyMembers, array_merge($data, [$dnUser])); + } + $cacheKey = '_groupMembers' . $dnGroup; + if (!is_null($data = $this->access->connection->getFromCache($cacheKey))) { + $this->access->connection->writeToCache($cacheKey, array_merge($data, [$dnUser])); + } + $cacheKey = 'getUserGroups' . $uid; + if (!is_null($data = $this->access->connection->getFromCache($cacheKey))) { + $this->access->connection->writeToCache($cacheKey, array_merge($data, [$gid])); + } + // These cache keys cannot be easily updated: + // $cacheKey = 'usersInGroup-' . $gid . '-' . $search . '-' . $limit . '-' . $offset; + // $cacheKey = 'usersInGroup-' . $gid . '-' . $search; + // $cacheKey = 'countUsersInGroup-' . $gid . '-' . $search; + } + + /** + * @throws ServerNotAvailableException + */ + public function isAdmin(string $uid): bool { + if (!$this->enabled) { + return false; + } + $ldapAdminGroup = $this->access->connection->ldapAdminGroup; + if ($ldapAdminGroup === '') { + return false; + } + return $this->inGroup($uid, $ldapAdminGroup); + } +} diff --git a/apps/user_ldap/lib/Group_Proxy.php b/apps/user_ldap/lib/Group_Proxy.php new file mode 100644 index 00000000000..f0cdc7a465d --- /dev/null +++ b/apps/user_ldap/lib/Group_Proxy.php @@ -0,0 +1,360 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OC\ServerNotAvailableException; +use OCP\Group\Backend\IBatchMethodsBackend; +use OCP\Group\Backend\IDeleteGroupBackend; +use OCP\Group\Backend\IGetDisplayNameBackend; +use OCP\Group\Backend\IGroupDetailsBackend; +use OCP\Group\Backend\IIsAdminBackend; +use OCP\Group\Backend\INamedBackend; +use OCP\GroupInterface; +use OCP\IConfig; +use OCP\IUserManager; + +/** + * @template-extends Proxy<Group_LDAP> + */ +class Group_Proxy extends Proxy implements GroupInterface, IGroupLDAP, IGetDisplayNameBackend, INamedBackend, IDeleteGroupBackend, IBatchMethodsBackend, IIsAdminBackend { + public function __construct( + private Helper $helper, + ILDAPWrapper $ldap, + AccessFactory $accessFactory, + private GroupPluginManager $groupPluginManager, + private IConfig $config, + private IUserManager $ncUserManager, + ) { + parent::__construct($helper, $ldap, $accessFactory); + } + + + protected function newInstance(string $configPrefix): Group_LDAP { + return new Group_LDAP($this->getAccess($configPrefix), $this->groupPluginManager, $this->config, $this->ncUserManager); + } + + /** + * Tries the backends one after the other until a positive result is returned from the specified method + * + * @param string $id the gid connected to the request + * @param string $method the method of the group backend that shall be called + * @param array $parameters an array of parameters to be passed + * @return mixed the result of the method or false + */ + protected function walkBackends($id, $method, $parameters) { + $this->setup(); + + $gid = $id; + $cacheKey = $this->getGroupCacheKey($gid); + foreach ($this->backends as $configPrefix => $backend) { + if ($result = call_user_func_array([$backend, $method], $parameters)) { + if (!$this->isSingleBackend()) { + $this->writeToCache($cacheKey, $configPrefix); + } + return $result; + } + } + return false; + } + + /** + * Asks the backend connected to the server that supposely takes care of the gid from the request. + * + * @param string $id the gid connected to the request + * @param string $method the method of the group backend that shall be called + * @param array $parameters an array of parameters to be passed + * @param mixed $passOnWhen the result matches this variable + * @return mixed the result of the method or false + */ + protected function callOnLastSeenOn($id, $method, $parameters, $passOnWhen) { + $this->setup(); + + $gid = $id; + $cacheKey = $this->getGroupCacheKey($gid); + $prefix = $this->getFromCache($cacheKey); + //in case the uid has been found in the past, try this stored connection first + if (!is_null($prefix)) { + if (isset($this->backends[$prefix])) { + $result = call_user_func_array([$this->backends[$prefix], $method], $parameters); + if ($result === $passOnWhen) { + //not found here, reset cache to null if group vanished + //because sometimes methods return false with a reason + $groupExists = call_user_func_array( + [$this->backends[$prefix], 'groupExists'], + [$gid] + ); + if (!$groupExists) { + $this->writeToCache($cacheKey, null); + } + } + return $result; + } + } + return false; + } + + protected function activeBackends(): int { + $this->setup(); + return count($this->backends); + } + + /** + * is user in group? + * + * @param string $uid uid of the user + * @param string $gid gid of the group + * @return bool + * + * Checks whether the user is member of a group or not. + */ + public function inGroup($uid, $gid) { + return $this->handleRequest($gid, 'inGroup', [$uid, $gid]); + } + + /** + * Get all groups a user belongs to + * + * @param string $uid Name of the user + * @return list<string> with group names + * + * This function fetches all groups a user belongs to. It does not check + * if the user exists at all. + */ + public function getUserGroups($uid) { + $this->setup(); + + $groups = []; + foreach ($this->backends as $backend) { + $backendGroups = $backend->getUserGroups($uid); + $groups = array_merge($groups, $backendGroups); + } + + return array_values(array_unique($groups)); + } + + /** + * get a list of all users in a group + * + * @return array<int,string> user ids + */ + public function usersInGroup($gid, $search = '', $limit = -1, $offset = 0) { + $this->setup(); + + $users = []; + foreach ($this->backends as $backend) { + $backendUsers = $backend->usersInGroup($gid, $search, $limit, $offset); + if (is_array($backendUsers)) { + $users = array_merge($users, $backendUsers); + } + } + + return $users; + } + + /** + * @param string $gid + * @return bool + */ + public function createGroup($gid) { + return $this->handleRequest( + $gid, 'createGroup', [$gid]); + } + + /** + * delete a group + */ + public function deleteGroup(string $gid): bool { + return $this->handleRequest( + $gid, 'deleteGroup', [$gid]); + } + + /** + * Add a user to a group + * + * @param string $uid Name of the user to add to group + * @param string $gid Name of the group in which add the user + * @return bool + * + * Adds a user to a group. + */ + public function addToGroup($uid, $gid) { + return $this->handleRequest( + $gid, 'addToGroup', [$uid, $gid]); + } + + /** + * Removes a user from a group + * + * @param string $uid Name of the user to remove from group + * @param string $gid Name of the group from which remove the user + * @return bool + * + * removes the user from a group. + */ + public function removeFromGroup($uid, $gid) { + return $this->handleRequest( + $gid, 'removeFromGroup', [$uid, $gid]); + } + + /** + * returns the number of users in a group, who match the search term + * + * @param string $gid the internal group name + * @param string $search optional, a search string + * @return int|bool + */ + public function countUsersInGroup($gid, $search = '') { + return $this->handleRequest( + $gid, 'countUsersInGroup', [$gid, $search]); + } + + /** + * get an array with group details + * + * @param string $gid + * @return array|false + */ + public function getGroupDetails($gid) { + return $this->handleRequest( + $gid, 'getGroupDetails', [$gid]); + } + + /** + * {@inheritdoc} + */ + public function getGroupsDetails(array $gids): array { + if (!($this instanceof IGroupDetailsBackend || $this->implementsActions(GroupInterface::GROUP_DETAILS))) { + throw new \Exception('Should not have been called'); + } + + $groupData = []; + foreach ($gids as $gid) { + $groupData[$gid] = $this->handleRequest($gid, 'getGroupDetails', [$gid]); + } + return $groupData; + } + + /** + * get a list of all groups + * + * @return string[] with group names + * + * Returns a list with all groups + */ + public function getGroups($search = '', $limit = -1, $offset = 0) { + $this->setup(); + + $groups = []; + foreach ($this->backends as $backend) { + $backendGroups = $backend->getGroups($search, $limit, $offset); + if (is_array($backendGroups)) { + $groups = array_merge($groups, $backendGroups); + } + } + + return $groups; + } + + /** + * check if a group exists + * + * @param string $gid + * @return bool + */ + public function groupExists($gid) { + return $this->handleRequest($gid, 'groupExists', [$gid]); + } + + /** + * Check if a group exists + * + * @throws ServerNotAvailableException + */ + public function groupExistsOnLDAP(string $gid, bool $ignoreCache = false): bool { + return $this->handleRequest($gid, 'groupExistsOnLDAP', [$gid, $ignoreCache]); + } + + /** + * returns the groupname for the given LDAP DN, if available + */ + public function dn2GroupName(string $dn): string|false { + $id = 'DN,' . $dn; + return $this->handleRequest($id, 'dn2GroupName', [$dn]); + } + + /** + * {@inheritdoc} + */ + public function groupsExists(array $gids): array { + return array_values(array_filter( + $gids, + fn (string $gid): bool => $this->handleRequest($gid, 'groupExists', [$gid]), + )); + } + + /** + * Check if backend implements actions + * + * @param int $actions bitwise-or'ed actions + * @return boolean + * + * Returns the supported actions as int to be + * compared with \OCP\GroupInterface::CREATE_GROUP etc. + */ + public function implementsActions($actions) { + $this->setup(); + //it's the same across all our user backends obviously + return $this->refBackend->implementsActions($actions); + } + + /** + * Return access for LDAP interaction. + * + * @param string $gid + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($gid) { + return $this->handleRequest($gid, 'getLDAPAccess', [$gid]); + } + + /** + * Return a new LDAP connection for the specified group. + * The connection needs to be closed manually. + * + * @param string $gid + * @return \LDAP\Connection The LDAP connection + */ + public function getNewLDAPConnection($gid): \LDAP\Connection { + return $this->handleRequest($gid, 'getNewLDAPConnection', [$gid]); + } + + public function getDisplayName(string $gid): string { + return $this->handleRequest($gid, 'getDisplayName', [$gid]); + } + + /** + * Backend name to be shown in group management + * @return string the name of the backend to be shown + * @since 22.0.0 + */ + public function getBackendName(): string { + return 'LDAP'; + } + + public function searchInGroup(string $gid, string $search = '', int $limit = -1, int $offset = 0): array { + return $this->handleRequest($gid, 'searchInGroup', [$gid, $search, $limit, $offset]); + } + + public function addRelationshipToCaches(string $uid, ?string $dnUser, string $gid): void { + $this->handleRequest($gid, 'addRelationshipToCaches', [$uid, $dnUser, $gid]); + } + + public function isAdmin(string $uid): bool { + return $this->handleRequest($uid, 'isAdmin', [$uid]); + } +} diff --git a/apps/user_ldap/lib/Handler/ExtStorageConfigHandler.php b/apps/user_ldap/lib/Handler/ExtStorageConfigHandler.php new file mode 100644 index 00000000000..8b63d54aa66 --- /dev/null +++ b/apps/user_ldap/lib/Handler/ExtStorageConfigHandler.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Handler; + +use OCA\Files_External\Config\IConfigHandler; +use OCA\Files_External\Config\SimpleSubstitutionTrait; +use OCA\Files_External\Config\UserContext; +use OCA\User_LDAP\User_Proxy; + +class ExtStorageConfigHandler extends UserContext implements IConfigHandler { + use SimpleSubstitutionTrait; + + /** + * @param mixed $optionValue + * @return mixed the same type as $optionValue + * @since 16.0.0 + * @throws \Exception + */ + public function handle($optionValue) { + $this->placeholder = 'home'; + $user = $this->getUser(); + + if ($user === null) { + return $optionValue; + } + + $backend = $user->getBackend(); + if (!$backend instanceof User_Proxy) { + return $optionValue; + } + + $access = $backend->getLDAPAccess($user->getUID()); + if (!$access) { + return $optionValue; + } + + $attribute = $access->connection->ldapExtStorageHomeAttribute; + if (empty($attribute)) { + return $optionValue; + } + + $ldapUser = $access->userManager->get($user->getUID()); + $extHome = $ldapUser !== null ? $ldapUser->getExtStorageHome() : ''; + + return $this->processInput($optionValue, $extHome); + } +} diff --git a/apps/user_ldap/lib/Helper.php b/apps/user_ldap/lib/Helper.php new file mode 100644 index 00000000000..d3abf04fd1e --- /dev/null +++ b/apps/user_ldap/lib/Helper.php @@ -0,0 +1,303 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OCP\Cache\CappedMemoryCache; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Server; + +class Helper { + /** @var CappedMemoryCache<string> */ + protected CappedMemoryCache $sanitizeDnCache; + + public function __construct( + private IAppConfig $appConfig, + private IDBConnection $connection, + ) { + $this->sanitizeDnCache = new CappedMemoryCache(10000); + } + + /** + * returns prefixes for each saved LDAP/AD server configuration. + * + * @param bool $activeConfigurations optional, whether only active configuration shall be + * retrieved, defaults to false + * @return array with a list of the available prefixes + * + * Configuration prefixes are used to set up configurations for n LDAP or + * AD servers. Since configuration is stored in the database, table + * appconfig under appid user_ldap, the common identifiers in column + * 'configkey' have a prefix. The prefix for the very first server + * configuration is empty. + * Configkey Examples: + * Server 1: ldap_login_filter + * Server 2: s1_ldap_login_filter + * Server 3: s2_ldap_login_filter + * + * The prefix needs to be passed to the constructor of Connection class, + * except the default (first) server shall be connected to. + * + */ + public function getServerConfigurationPrefixes(bool $activeConfigurations = false): array { + $all = $this->getAllServerConfigurationPrefixes(); + if (!$activeConfigurations) { + return $all; + } + return array_values(array_filter( + $all, + fn (string $prefix): bool => ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') === '1') + )); + } + + protected function getAllServerConfigurationPrefixes(): array { + $unfilled = ['UNFILLED']; + $prefixes = $this->appConfig->getValueArray('user_ldap', 'configuration_prefixes', $unfilled); + if ($prefixes !== $unfilled) { + return $prefixes; + } + + /* Fallback to browsing key for migration from Nextcloud<32 */ + $referenceConfigkey = 'ldap_configuration_active'; + + $keys = $this->getServersConfig($referenceConfigkey); + + $prefixes = []; + foreach ($keys as $key) { + $len = strlen($key) - strlen($referenceConfigkey); + $prefixes[] = substr($key, 0, $len); + } + sort($prefixes); + + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes); + + return $prefixes; + } + + /** + * + * determines the host for every configured connection + * + * @return array<string,string> an array with configprefix as keys + * + */ + public function getServerConfigurationHosts(): array { + $prefixes = $this->getServerConfigurationPrefixes(); + + $referenceConfigkey = 'ldap_host'; + $result = []; + foreach ($prefixes as $prefix) { + $result[$prefix] = $this->appConfig->getValueString('user_ldap', $prefix . $referenceConfigkey); + } + + return $result; + } + + /** + * return the next available configuration prefix and register it as used + */ + public function getNextServerConfigurationPrefix(): string { + $prefixes = $this->getServerConfigurationPrefixes(); + + if (count($prefixes) === 0) { + $prefix = 's01'; + } else { + sort($prefixes); + $lastKey = array_pop($prefixes); + $lastNumber = (int)str_replace('s', '', $lastKey); + $prefix = 's' . str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT); + } + + $prefixes[] = $prefix; + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes); + return $prefix; + } + + private function getServersConfig(string $value): array { + $regex = '/' . $value . '$/S'; + + $keys = $this->appConfig->getKeys('user_ldap'); + $result = []; + foreach ($keys as $key) { + if (preg_match($regex, $key) === 1) { + $result[] = $key; + } + } + + return $result; + } + + /** + * deletes a given saved LDAP/AD server configuration. + * + * @param string $prefix the configuration prefix of the config to delete + * @return bool true on success, false otherwise + */ + public function deleteServerConfiguration($prefix) { + $prefixes = $this->getServerConfigurationPrefixes(); + $index = array_search($prefix, $prefixes); + if ($index === false) { + return false; + } + + $query = $this->connection->getQueryBuilder(); + $query->delete('appconfig') + ->where($query->expr()->eq('appid', $query->createNamedParameter('user_ldap'))) + ->andWhere($query->expr()->like('configkey', $query->createNamedParameter((string)$prefix . '%'))) + ->andWhere($query->expr()->notIn('configkey', $query->createNamedParameter([ + 'enabled', + 'installed_version', + 'types', + 'bgjUpdateGroupsLastRun', + ], IQueryBuilder::PARAM_STR_ARRAY))); + + if (empty($prefix)) { + $query->andWhere($query->expr()->notLike('configkey', $query->createNamedParameter('s%'))); + } + + $deletedRows = $query->executeStatement(); + + unset($prefixes[$index]); + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', array_values($prefixes)); + + return $deletedRows !== 0; + } + + /** + * checks whether there is one or more disabled LDAP configurations + */ + public function haveDisabledConfigurations(): bool { + $all = $this->getServerConfigurationPrefixes(); + foreach ($all as $prefix) { + if ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') !== '1') { + return true; + } + } + return false; + } + + /** + * extracts the domain from a given URL + * + * @param string $url the URL + * @return string|false domain as string on success, false otherwise + */ + public function getDomainFromURL($url) { + $uinfo = parse_url($url); + if (!is_array($uinfo)) { + return false; + } + + $domain = false; + if (isset($uinfo['host'])) { + $domain = $uinfo['host']; + } elseif (isset($uinfo['path'])) { + $domain = $uinfo['path']; + } + + return $domain; + } + + /** + * sanitizes a DN received from the LDAP server + * + * This is used and done to have a stable format of DNs that can be compared + * and identified again. The input DN value is modified as following: + * + * 1) whitespaces after commas are removed + * 2) the DN is turned to lower-case + * 3) the DN is escaped according to RFC 2253 + * + * When a future DN is supposed to be used as a base parameter, it has to be + * run through DNasBaseParameter() first, to recode \5c into a backslash + * again, otherwise the search or read operation will fail with LDAP error + * 32, NO_SUCH_OBJECT. Regular usage in LDAP filters requires the backslash + * being escaped, however. + * + * Internally, DNs are stored in their sanitized form. + * + * @param array|string $dn the DN in question + * @return array|string the sanitized DN + */ + public function sanitizeDN($dn) { + //treating multiple base DNs + if (is_array($dn)) { + $result = []; + foreach ($dn as $singleDN) { + $result[] = $this->sanitizeDN($singleDN); + } + return $result; + } + + if (!is_string($dn)) { + throw new \LogicException('String expected ' . \gettype($dn) . ' given'); + } + + if (($sanitizedDn = $this->sanitizeDnCache->get($dn)) !== null) { + return $sanitizedDn; + } + + //OID sometimes gives back DNs with whitespace after the comma + // a la "uid=foo, cn=bar, dn=..." We need to tackle this! + $sanitizedDn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn); + + //make comparisons and everything work + $sanitizedDn = mb_strtolower($sanitizedDn, 'UTF-8'); + + //escape DN values according to RFC 2253 – this is already done by ldap_explode_dn + //to use the DN in search filters, \ needs to be escaped to \5c additionally + //to use them in bases, we convert them back to simple backslashes in readAttribute() + $replacements = [ + '\,' => '\5c2C', + '\=' => '\5c3D', + '\+' => '\5c2B', + '\<' => '\5c3C', + '\>' => '\5c3E', + '\;' => '\5c3B', + '\"' => '\5c22', + '\#' => '\5c23', + '(' => '\28', + ')' => '\29', + '*' => '\2A', + ]; + $sanitizedDn = str_replace(array_keys($replacements), array_values($replacements), $sanitizedDn); + $this->sanitizeDnCache->set($dn, $sanitizedDn); + + return $sanitizedDn; + } + + /** + * converts a stored DN so it can be used as base parameter for LDAP queries, internally we store them for usage in LDAP filters + * + * @param string $dn the DN + * @return string + */ + public function DNasBaseParameter($dn) { + return str_ireplace('\\5c', '\\', $dn); + } + + /** + * listens to a hook thrown by server2server sharing and replaces the given + * login name by a username, if it matches an LDAP user. + * + * @param array $param contains a reference to a $uid var under 'uid' key + * @throws \Exception + */ + public static function loginName2UserName($param): void { + if (!isset($param['uid'])) { + throw new \Exception('key uid is expected to be set in $param'); + } + + $userBackend = Server::get(User_Proxy::class); + $uid = $userBackend->loginName2UserName($param['uid']); + if ($uid !== false) { + $param['uid'] = $uid; + } + } +} diff --git a/apps/user_ldap/lib/IGroupLDAP.php b/apps/user_ldap/lib/IGroupLDAP.php new file mode 100644 index 00000000000..667eb421004 --- /dev/null +++ b/apps/user_ldap/lib/IGroupLDAP.php @@ -0,0 +1,26 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +interface IGroupLDAP { + + //Used by LDAPProvider + + /** + * Return access for LDAP interaction. + * @param string $gid + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($gid); + + /** + * Return a new LDAP connection for the specified group. + * @param string $gid + * @return \LDAP\Connection The LDAP connection + */ + public function getNewLDAPConnection($gid); +} diff --git a/apps/user_ldap/lib/ILDAPGroupPlugin.php b/apps/user_ldap/lib/ILDAPGroupPlugin.php new file mode 100644 index 00000000000..261b9383dc1 --- /dev/null +++ b/apps/user_ldap/lib/ILDAPGroupPlugin.php @@ -0,0 +1,67 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +interface ILDAPGroupPlugin { + + /** + * Check if plugin implements actions + * @return int + * + * Returns the supported actions as int to be + * compared with OC_GROUP_BACKEND_CREATE_GROUP etc. + */ + public function respondToActions(); + + /** + * @param string $gid + * @return string|null The group DN if group creation was successful. + */ + public function createGroup($gid); + + /** + * delete a group + * @param string $gid gid of the group to delete + * @return bool + */ + public function deleteGroup($gid); + + /** + * Add a user to a group + * @param string $uid Name of the user to add to group + * @param string $gid Name of the group in which add the user + * @return bool + * + * Adds a user to a group. + */ + public function addToGroup($uid, $gid); + + /** + * Removes a user from a group + * @param string $uid Name of the user to remove from group + * @param string $gid Name of the group from which remove the user + * @return bool + * + * removes the user from a group. + */ + public function removeFromGroup($uid, $gid); + + /** + * get the number of all users matching the search string in a group + * @param string $gid + * @param string $search + * @return int|false + */ + public function countUsersInGroup($gid, $search = ''); + + /** + * get an array with group details + * @param string $gid + * @return array|false + */ + public function getGroupDetails($gid); +} diff --git a/apps/user_ldap/lib/ILDAPUserPlugin.php b/apps/user_ldap/lib/ILDAPUserPlugin.php new file mode 100644 index 00000000000..80437bef452 --- /dev/null +++ b/apps/user_ldap/lib/ILDAPUserPlugin.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +interface ILDAPUserPlugin { + /** + * Check if plugin implements actions + * @return int + * + * Returns the supported actions as int to be + * compared with OC_USER_BACKEND_CREATE_USER etc. + */ + public function respondToActions(); + + /** + * Create a new user in LDAP Backend + * + * @param string $uid The UID of the user to create + * @param string $password The password of the new user + * @return bool|string + */ + public function createUser($uid, $password); + + /** + * Set password + * + * @param string $uid The username + * @param string $password The new password + * @return bool + * + * Change the password of a user + */ + public function setPassword($uid, $password); + + /** + * get the user's home directory + * @param string $uid the username + * @return boolean + */ + public function getHome($uid); + + /** + * get display name of the user + * @param string $uid user ID of the user + * @return string display name + */ + public function getDisplayName($uid); + + /** + * set display name of the user + * @param string $uid user ID of the user + * @param string $displayName new user's display name + * @return string display name + */ + public function setDisplayName($uid, $displayName); + + /** + * checks whether the user is allowed to change their avatar in Nextcloud + * @param string $uid the Nextcloud user name + * @return boolean either the user can or cannot + */ + public function canChangeAvatar($uid); + + /** + * Count the number of users + * @return int|false + */ + public function countUsers(); +} diff --git a/apps/user_ldap/lib/ILDAPWrapper.php b/apps/user_ldap/lib/ILDAPWrapper.php new file mode 100644 index 00000000000..de2b9c50241 --- /dev/null +++ b/apps/user_ldap/lib/ILDAPWrapper.php @@ -0,0 +1,190 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +interface ILDAPWrapper { + //LDAP functions in use + + /** + * Bind to LDAP directory + * @param \LDAP\Connection $link LDAP link resource + * @param string $dn an RDN to log in with + * @param string $password the password + * @return bool true on success, false otherwise + * + * with $dn and $password as null a anonymous bind is attempted. + */ + public function bind($link, $dn, $password); + + /** + * connect to an LDAP server + * @param string $host The host to connect to + * @param string $port The port to connect to + * @return \LDAP\Connection|false a link resource on success, otherwise false + */ + public function connect($host, $port); + + /** + * Retrieve the LDAP pagination cookie + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\Result $result LDAP result resource + * @param string &$cookie structure sent by LDAP server + * @return bool true on success, false otherwise + * + * Corresponds to ldap_control_paged_result_response + */ + public function controlPagedResultResponse($link, $result, &$cookie); + + /** + * Count the number of entries in a search + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\Result $result LDAP result resource + * @return int|false number of results on success, false otherwise + */ + public function countEntries($link, $result); + + /** + * Return the LDAP error number of the last LDAP command + * @param \LDAP\Connection $link LDAP link resource + * @return int error code + */ + public function errno($link); + + /** + * Return the LDAP error message of the last LDAP command + * @param \LDAP\Connection $link LDAP link resource + * @return string error message + */ + public function error($link); + + /** + * Splits DN into its component parts + * @param string $dn + * @param int @withAttrib + * @return array|false + * @link https://www.php.net/manual/en/function.ldap-explode-dn.php + */ + public function explodeDN($dn, $withAttrib); + + /** + * Return first result id + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\Result $result LDAP result resource + * @return \LDAP\ResultEntry an LDAP entry resource + * */ + public function firstEntry($link, $result); + + /** + * Get attributes from a search result entry + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\ResultEntry $result LDAP result resource + * @return array|false containing the results, false on error + * */ + public function getAttributes($link, $result); + + /** + * Get the DN of a result entry + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\ResultEntry $result LDAP result resource + * @return string|false containing the DN, false on error + */ + public function getDN($link, $result); + + /** + * Get all result entries + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\Result $result LDAP result resource + * @return array|false containing the results, false on error + */ + public function getEntries($link, $result); + + /** + * Return next result id + * @param \LDAP\Connection $link LDAP link resource + * @param \LDAP\ResultEntry $result LDAP result resource + * @return \LDAP\ResultEntry an LDAP entry resource + * */ + public function nextEntry($link, $result); + + /** + * Read an entry + * @param \LDAP\Connection $link LDAP link resource + * @param string $baseDN The DN of the entry to read from + * @param string $filter An LDAP filter + * @param array $attr array of the attributes to read + * @return \LDAP\Result an LDAP search result resource + */ + public function read($link, $baseDN, $filter, $attr); + + /** + * Search LDAP tree + * @param \LDAP\Connection $link LDAP link resource + * @param string $baseDN The DN of the entry to read from + * @param string $filter An LDAP filter + * @param array $attr array of the attributes to read + * @param int $attrsOnly optional, 1 if only attribute types shall be returned + * @param int $limit optional, limits the result entries + * @return \LDAP\Result|false an LDAP search result resource, false on error + */ + public function search($link, string $baseDN, string $filter, array $attr, int $attrsOnly = 0, int $limit = 0, int $pageSize = 0, string $cookie = ''); + + /** + * Replace the value of a userPassword by $password + * @param \LDAP\Connection $link LDAP link resource + * @param string $userDN the DN of the user whose password is to be replaced + * @param string $password the new value for the userPassword + * @return bool true on success, false otherwise + */ + public function modReplace($link, $userDN, $password); + + /** + * Performs a PASSWD extended operation. + * @param \LDAP\Connection $link LDAP link resource + * @return bool|string The generated password if new_password is empty or omitted. Otherwise true on success and false on failure. + */ + public function exopPasswd($link, string $userDN, string $oldPassword, string $password); + + /** + * Sets the value of the specified option to be $value + * @param \LDAP\Connection $link LDAP link resource + * @param int $option a defined LDAP Server option + * @param mixed $value the new value for the option + * @return bool true on success, false otherwise + */ + public function setOption($link, $option, $value); + + /** + * establish Start TLS + * @param \LDAP\Connection $link LDAP link resource + * @return bool true on success, false otherwise + */ + public function startTls($link); + + /** + * Unbind from LDAP directory + * @param \LDAP\Connection $link LDAP link resource + * @return bool true on success, false otherwise + */ + public function unbind($link); + + //additional required methods in Nextcloud + + /** + * Checks whether the server supports LDAP + * @return bool true if it the case, false otherwise + * */ + public function areLDAPFunctionsAvailable(); + + /** + * Checks whether the submitted parameter is a resource + * @param mixed $resource the resource variable to check + * @psalm-assert-if-true object $resource + * @return bool true if it is a resource or LDAP object, false otherwise + */ + public function isResource($resource); +} diff --git a/apps/user_ldap/lib/IUserLDAP.php b/apps/user_ldap/lib/IUserLDAP.php new file mode 100644 index 00000000000..5e8e29c3adf --- /dev/null +++ b/apps/user_ldap/lib/IUserLDAP.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +interface IUserLDAP { + + //Functions used by LDAPProvider + + /** + * Return access for LDAP interaction. + * @param string $uid + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($uid); + + /** + * Return a new LDAP connection for the specified user. + * @param string $uid + * @return \LDAP\Connection of the LDAP connection + */ + public function getNewLDAPConnection($uid); + + /** + * Return the username for the given LDAP DN, if available. + * @param string $dn + * @return string|false with the username + */ + public function dn2UserName($dn); +} diff --git a/apps/user_ldap/lib/Jobs/CleanUp.php b/apps/user_ldap/lib/Jobs/CleanUp.php new file mode 100644 index 00000000000..76277b43c0b --- /dev/null +++ b/apps/user_ldap/lib/Jobs/CleanUp.php @@ -0,0 +1,196 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Jobs; + +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User_Proxy; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Server; + +/** + * Class CleanUp + * + * a Background job to clean up deleted users + * + * @package OCA\User_LDAP\Jobs; + */ +class CleanUp extends TimedJob { + /** @var ?int $limit amount of users that should be checked per run */ + protected $limit; + + /** @var int $defaultIntervalMin default interval in minutes */ + protected $defaultIntervalMin = 60; + + /** @var IConfig $ocConfig */ + protected $ocConfig; + + /** @var IDBConnection $db */ + protected $db; + + /** @var Helper $ldapHelper */ + protected $ldapHelper; + + /** @var UserMapping */ + protected $mapping; + + public function __construct( + ITimeFactory $timeFactory, + protected User_Proxy $userBackend, + protected DeletedUsersIndex $dui, + ) { + parent::__construct($timeFactory); + $minutes = Server::get(IConfig::class)->getSystemValue( + 'ldapUserCleanupInterval', (string)$this->defaultIntervalMin); + $this->setInterval((int)$minutes * 60); + } + + /** + * assigns the instances passed to run() to the class properties + * @param array $arguments + */ + public function setArguments($arguments): void { + //Dependency Injection is not possible, because the constructor will + //only get values that are serialized to JSON. I.e. whatever we would + //pass in app.php we do add here, except something else is passed e.g. + //in tests. + + if (isset($arguments['helper'])) { + $this->ldapHelper = $arguments['helper']; + } else { + $this->ldapHelper = Server::get(Helper::class); + } + + if (isset($arguments['ocConfig'])) { + $this->ocConfig = $arguments['ocConfig']; + } else { + $this->ocConfig = Server::get(IConfig::class); + } + + if (isset($arguments['userBackend'])) { + $this->userBackend = $arguments['userBackend']; + } + + if (isset($arguments['db'])) { + $this->db = $arguments['db']; + } else { + $this->db = Server::get(IDBConnection::class); + } + + if (isset($arguments['mapping'])) { + $this->mapping = $arguments['mapping']; + } else { + $this->mapping = Server::get(UserMapping::class); + } + + if (isset($arguments['deletedUsersIndex'])) { + $this->dui = $arguments['deletedUsersIndex']; + } + } + + /** + * makes the background job do its work + * @param array $argument + */ + public function run($argument): void { + $this->setArguments($argument); + + if (!$this->isCleanUpAllowed()) { + return; + } + $users = $this->mapping->getList($this->getOffset(), $this->getChunkSize()); + $resetOffset = $this->isOffsetResetNecessary(count($users)); + $this->checkUsers($users); + $this->setOffset($resetOffset); + } + + /** + * checks whether next run should start at 0 again + */ + public function isOffsetResetNecessary(int $resultCount): bool { + return $resultCount < $this->getChunkSize(); + } + + /** + * checks whether cleaning up LDAP users is allowed + */ + public function isCleanUpAllowed(): bool { + try { + if ($this->ldapHelper->haveDisabledConfigurations()) { + return false; + } + } catch (\Exception $e) { + return false; + } + + return $this->isCleanUpEnabled(); + } + + /** + * checks whether clean up is enabled by configuration + */ + private function isCleanUpEnabled(): bool { + return (bool)$this->ocConfig->getSystemValue( + 'ldapUserCleanupInterval', (string)$this->defaultIntervalMin); + } + + /** + * checks users whether they are still existing + * @param array $users result from getMappedUsers() + */ + private function checkUsers(array $users): void { + foreach ($users as $user) { + $this->checkUser($user); + } + } + + /** + * checks whether a user is still existing in LDAP + * @param string[] $user + */ + private function checkUser(array $user): void { + if ($this->userBackend->userExistsOnLDAP($user['name'])) { + //still available, all good + + return; + } + + $this->dui->markUser($user['name']); + } + + /** + * gets the offset to fetch users from the mappings table + */ + private function getOffset(): int { + return (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobOffset', '0'); + } + + /** + * sets the new offset for the next run + * @param bool $reset whether the offset should be set to 0 + */ + public function setOffset(bool $reset = false): void { + $newOffset = $reset ? 0 + : $this->getOffset() + $this->getChunkSize(); + $this->ocConfig->setAppValue('user_ldap', 'cleanUpJobOffset', (string)$newOffset); + } + + /** + * returns the chunk size (limit in DB speak) + */ + public function getChunkSize(): int { + if ($this->limit === null) { + $this->limit = (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobChunkSize', '50'); + } + return $this->limit; + } +} diff --git a/apps/user_ldap/lib/Jobs/Sync.php b/apps/user_ldap/lib/Jobs/Sync.php new file mode 100644 index 00000000000..26888ae96ae --- /dev/null +++ b/apps/user_ldap/lib/Jobs/Sync.php @@ -0,0 +1,272 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Jobs; + +use OC\ServerNotAvailableException; +use OCA\User_LDAP\AccessFactory; +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\ConnectionFactory; +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\LDAP; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IUserManager; +use OCP\Notification\IManager; +use Psr\Log\LoggerInterface; + +class Sync extends TimedJob { + public const MAX_INTERVAL = 12 * 60 * 60; // 12h + public const MIN_INTERVAL = 30 * 60; // 30min + + protected LDAP $ldap; + + public function __construct( + ITimeFactory $timeFactory, + private IEventDispatcher $dispatcher, + private IConfig $config, + private IDBConnection $dbc, + private IAvatarManager $avatarManager, + private IUserManager $ncUserManager, + private LoggerInterface $logger, + private IManager $notificationManager, + private UserMapping $mapper, + private Helper $ldapHelper, + private ConnectionFactory $connectionFactory, + private AccessFactory $accessFactory, + ) { + parent::__construct($timeFactory); + $this->setInterval( + (int)$this->config->getAppValue( + 'user_ldap', + 'background_sync_interval', + (string)self::MIN_INTERVAL + ) + ); + $this->ldap = new LDAP($this->config->getSystemValueString('ldap_log_file')); + } + + /** + * Updates the interval + * + * The idea is to adjust the interval depending on the amount of known users + * and the attempt to update each user one day. At most it would run every + * 30 minutes, and at least every 12 hours. + */ + public function updateInterval() { + $minPagingSize = $this->getMinPagingSize(); + $mappedUsers = $this->mapper->count(); + + $runsPerDay = ($minPagingSize === 0 || $mappedUsers === 0) ? self::MAX_INTERVAL + : $mappedUsers / $minPagingSize; + $interval = floor(24 * 60 * 60 / $runsPerDay); + $interval = min(max($interval, self::MIN_INTERVAL), self::MAX_INTERVAL); + + $this->config->setAppValue('user_ldap', 'background_sync_interval', (string)$interval); + } + + /** + * returns the smallest configured paging size + */ + protected function getMinPagingSize(): int { + $configKeys = $this->config->getAppKeys('user_ldap'); + $configKeys = array_filter($configKeys, function ($key) { + return str_contains($key, 'ldap_paging_size'); + }); + $minPagingSize = null; + foreach ($configKeys as $configKey) { + $pagingSize = $this->config->getAppValue('user_ldap', $configKey, $minPagingSize); + $minPagingSize = $minPagingSize === null ? $pagingSize : min($minPagingSize, $pagingSize); + } + return (int)$minPagingSize; + } + + /** + * @param array $argument + */ + public function run($argument) { + $isBackgroundJobModeAjax = $this->config + ->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'ajax'; + if ($isBackgroundJobModeAjax) { + return; + } + + $cycleData = $this->getCycle(); + if ($cycleData === null) { + $cycleData = $this->determineNextCycle(); + if ($cycleData === null) { + $this->updateInterval(); + return; + } + } + + if (!$this->qualifiesToRun($cycleData)) { + $this->updateInterval(); + return; + } + + try { + $expectMoreResults = $this->runCycle($cycleData); + if ($expectMoreResults) { + $this->increaseOffset($cycleData); + } else { + $this->determineNextCycle($cycleData); + } + $this->updateInterval(); + } catch (ServerNotAvailableException $e) { + $this->determineNextCycle($cycleData); + } + } + + /** + * @param array{offset: int, prefix: string} $cycleData + * @return bool whether more results are expected from the same configuration + */ + public function runCycle(array $cycleData): bool { + $connection = $this->connectionFactory->get($cycleData['prefix']); + $access = $this->accessFactory->get($connection); + $access->setUserMapper($this->mapper); + + $filter = $access->combineFilterWithAnd([ + $access->connection->ldapUserFilter, + $access->connection->ldapUserDisplayName . '=*', + $access->getFilterPartForUserSearch('') + ]); + $results = $access->fetchListOfUsers( + $filter, + $access->userManager->getAttributes(), + (int)$connection->ldapPagingSize, + $cycleData['offset'], + true + ); + + if ((int)$connection->ldapPagingSize === 0) { + return false; + } + return count($results) >= (int)$connection->ldapPagingSize; + } + + /** + * Returns the info about the current cycle that should be run, if any, + * otherwise null + */ + public function getCycle(): ?array { + $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); + if (count($prefixes) === 0) { + return null; + } + + $cycleData = [ + 'prefix' => $this->config->getAppValue('user_ldap', 'background_sync_prefix', 'none'), + 'offset' => (int)$this->config->getAppValue('user_ldap', 'background_sync_offset', '0'), + ]; + + if ( + $cycleData['prefix'] !== 'none' + && in_array($cycleData['prefix'], $prefixes) + ) { + return $cycleData; + } + + return null; + } + + /** + * Save the provided cycle information in the DB + * + * @param array{prefix: ?string, offset: int} $cycleData + */ + public function setCycle(array $cycleData): void { + $this->config->setAppValue('user_ldap', 'background_sync_prefix', $cycleData['prefix']); + $this->config->setAppValue('user_ldap', 'background_sync_offset', (string)$cycleData['offset']); + } + + /** + * returns data about the next cycle that should run, if any, otherwise + * null. It also always goes for the next LDAP configuration! + * + * @param ?array{prefix: string, offset: int} $cycleData the old cycle + * @return ?array{prefix: string, offset: int} + */ + public function determineNextCycle(?array $cycleData = null): ?array { + $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); + if (count($prefixes) === 0) { + return null; + } + + // get the next prefix in line and remember it + $oldPrefix = $cycleData === null ? null : $cycleData['prefix']; + $prefix = $this->getNextPrefix($oldPrefix); + if ($prefix === null) { + return null; + } + $cycleData['prefix'] = $prefix; + $cycleData['offset'] = 0; + $this->setCycle(['prefix' => $prefix, 'offset' => 0]); + + return $cycleData; + } + + /** + * Checks whether the provided cycle should be run. Currently, only the + * last configuration change goes into account (at least one hour). + * + * @param array{prefix: string} $cycleData + */ + public function qualifiesToRun(array $cycleData): bool { + $lastChange = (int)$this->config->getAppValue('user_ldap', $cycleData['prefix'] . '_lastChange', '0'); + if ((time() - $lastChange) > 60 * 30) { + return true; + } + return false; + } + + /** + * Increases the offset of the current cycle for the next run + * + * @param array{prefix: string, offset: int} $cycleData + */ + protected function increaseOffset(array $cycleData): void { + $ldapConfig = new Configuration($cycleData['prefix']); + $cycleData['offset'] += (int)$ldapConfig->ldapPagingSize; + $this->setCycle($cycleData); + } + + /** + * Determines the next configuration prefix based on the last one (if any) + */ + protected function getNextPrefix(?string $lastPrefix): ?string { + $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); + $noOfPrefixes = count($prefixes); + if ($noOfPrefixes === 0) { + return null; + } + $i = $lastPrefix === null ? false : array_search($lastPrefix, $prefixes, true); + if ($i === false) { + $i = -1; + } else { + $i++; + } + + if (!isset($prefixes[$i])) { + $i = 0; + } + return $prefixes[$i]; + } + + /** + * Only used in tests + */ + public function overwritePropertiesForTest(LDAP $ldapWrapper): void { + $this->ldap = $ldapWrapper; + } +} diff --git a/apps/user_ldap/lib/Jobs/UpdateGroups.php b/apps/user_ldap/lib/Jobs/UpdateGroups.php new file mode 100644 index 00000000000..9e72bcd8432 --- /dev/null +++ b/apps/user_ldap/lib/Jobs/UpdateGroups.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Jobs; + +use OCA\User_LDAP\Service\UpdateGroupsService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\Exception; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +class UpdateGroups extends TimedJob { + public function __construct( + private UpdateGroupsService $service, + private LoggerInterface $logger, + IConfig $config, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); + $this->interval = (int)$config->getAppValue('user_ldap', 'bgjRefreshInterval', '3600'); + } + + /** + * @param mixed $argument + * @throws Exception + */ + public function run($argument): void { + $this->logger->debug('Run background job "updateGroups"'); + $this->service->updateGroups(); + } +} diff --git a/apps/user_ldap/lib/LDAP.php b/apps/user_ldap/lib/LDAP.php new file mode 100644 index 00000000000..1cf20c4b939 --- /dev/null +++ b/apps/user_ldap/lib/LDAP.php @@ -0,0 +1,409 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OC\ServerNotAvailableException; +use OCA\User_LDAP\DataCollector\LdapDataCollector; +use OCA\User_LDAP\Exceptions\ConstraintViolationException; +use OCP\IConfig; +use OCP\ILogger; +use OCP\Profiler\IProfiler; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class LDAP implements ILDAPWrapper { + protected array $curArgs = []; + protected LoggerInterface $logger; + protected IConfig $config; + + private ?LdapDataCollector $dataCollector = null; + + public function __construct( + protected string $logFile = '', + ) { + /** @var IProfiler $profiler */ + $profiler = Server::get(IProfiler::class); + if ($profiler->isEnabled()) { + $this->dataCollector = new LdapDataCollector(); + $profiler->add($this->dataCollector); + } + + $this->logger = Server::get(LoggerInterface::class); + $this->config = Server::get(IConfig::class); + } + + /** + * {@inheritDoc} + */ + public function bind($link, $dn, $password) { + return $this->invokeLDAPMethod('bind', $link, $dn, $password); + } + + /** + * {@inheritDoc} + */ + public function connect($host, $port) { + $pos = strpos($host, '://'); + if ($pos === false) { + $host = 'ldap://' . $host; + $pos = 4; + } + if (strpos($host, ':', $pos + 1) === false && !empty($port)) { + //ldap_connect ignores port parameter when URLs are passed + $host .= ':' . $port; + } + return $this->invokeLDAPMethod('connect', $host); + } + + /** + * {@inheritDoc} + */ + public function controlPagedResultResponse($link, $result, &$cookie): bool { + $errorCode = 0; + $errorMsg = ''; + $controls = []; + $matchedDn = null; + $referrals = []; + + /** Cannot use invokeLDAPMethod because arguments are passed by reference */ + $this->preFunctionCall('ldap_parse_result', [$link, $result]); + $success = ldap_parse_result($link, $result, + $errorCode, + $matchedDn, + $errorMsg, + $referrals, + $controls); + if ($errorCode !== 0) { + $this->processLDAPError($link, 'ldap_parse_result', $errorCode, $errorMsg); + } + if ($this->dataCollector !== null) { + $this->dataCollector->stopLastLdapRequest(); + } + + $cookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'] ?? ''; + + return $success; + } + + /** + * {@inheritDoc} + */ + public function countEntries($link, $result) { + return $this->invokeLDAPMethod('count_entries', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function errno($link) { + return $this->invokeLDAPMethod('errno', $link); + } + + /** + * {@inheritDoc} + */ + public function error($link) { + return $this->invokeLDAPMethod('error', $link); + } + + /** + * Splits DN into its component parts + * @param string $dn + * @param int $withAttrib + * @return array|false + * @link https://www.php.net/manual/en/function.ldap-explode-dn.php + */ + public function explodeDN($dn, $withAttrib) { + return $this->invokeLDAPMethod('explode_dn', $dn, $withAttrib); + } + + /** + * {@inheritDoc} + */ + public function firstEntry($link, $result) { + return $this->invokeLDAPMethod('first_entry', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function getAttributes($link, $result) { + return $this->invokeLDAPMethod('get_attributes', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function getDN($link, $result) { + return $this->invokeLDAPMethod('get_dn', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function getEntries($link, $result) { + return $this->invokeLDAPMethod('get_entries', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function nextEntry($link, $result) { + return $this->invokeLDAPMethod('next_entry', $link, $result); + } + + /** + * {@inheritDoc} + */ + public function read($link, $baseDN, $filter, $attr) { + return $this->invokeLDAPMethod('read', $link, $baseDN, $filter, $attr, 0, -1); + } + + /** + * {@inheritDoc} + */ + public function search($link, $baseDN, $filter, $attr, $attrsOnly = 0, $limit = 0, int $pageSize = 0, string $cookie = '') { + if ($pageSize > 0 || $cookie !== '') { + $serverControls = [[ + 'oid' => LDAP_CONTROL_PAGEDRESULTS, + 'value' => [ + 'size' => $pageSize, + 'cookie' => $cookie, + ], + 'iscritical' => false, + ]]; + } else { + $serverControls = []; + } + + /** @psalm-suppress UndefinedVariable $oldHandler is defined when the closure is called but psalm fails to get that */ + $oldHandler = set_error_handler(function ($no, $message, $file, $line) use (&$oldHandler) { + if (str_contains($message, 'Partial search results returned: Sizelimit exceeded')) { + return true; + } + $oldHandler($no, $message, $file, $line); + return true; + }); + try { + $result = $this->invokeLDAPMethod('search', $link, $baseDN, $filter, $attr, $attrsOnly, $limit, -1, LDAP_DEREF_NEVER, $serverControls); + + restore_error_handler(); + return $result; + } catch (\Exception $e) { + restore_error_handler(); + throw $e; + } + } + + /** + * {@inheritDoc} + */ + public function modReplace($link, $userDN, $password) { + return $this->invokeLDAPMethod('mod_replace', $link, $userDN, ['userPassword' => $password]); + } + + /** + * {@inheritDoc} + */ + public function exopPasswd($link, string $userDN, string $oldPassword, string $password) { + return $this->invokeLDAPMethod('exop_passwd', $link, $userDN, $oldPassword, $password); + } + + /** + * {@inheritDoc} + */ + public function setOption($link, $option, $value) { + return $this->invokeLDAPMethod('set_option', $link, $option, $value); + } + + /** + * {@inheritDoc} + */ + public function startTls($link) { + return $this->invokeLDAPMethod('start_tls', $link); + } + + /** + * {@inheritDoc} + */ + public function unbind($link) { + return $this->invokeLDAPMethod('unbind', $link); + } + + /** + * Checks whether the server supports LDAP + * @return boolean if it the case, false otherwise + * */ + public function areLDAPFunctionsAvailable() { + return function_exists('ldap_connect'); + } + + /** + * {@inheritDoc} + */ + public function isResource($resource) { + return is_resource($resource) || is_object($resource); + } + + /** + * Checks whether the return value from LDAP is wrong or not. + * + * When using ldap_search we provide an array, in case multiple bases are + * configured. Thus, we need to check the array elements. + * + * @param mixed $result + */ + protected function isResultFalse(string $functionName, $result): bool { + if ($result === false) { + return true; + } + + if ($functionName === 'ldap_search' && is_array($result)) { + foreach ($result as $singleResult) { + if ($singleResult === false) { + return true; + } + } + } + + return false; + } + + /** + * @param array $arguments + * @return mixed + */ + protected function invokeLDAPMethod(string $func, ...$arguments) { + $func = 'ldap_' . $func; + if (function_exists($func)) { + $this->preFunctionCall($func, $arguments); + $result = call_user_func_array($func, $arguments); + if ($this->isResultFalse($func, $result)) { + $this->postFunctionCall($func); + } + if ($this->dataCollector !== null) { + $this->dataCollector->stopLastLdapRequest(); + } + return $result; + } + return null; + } + + /** + * Turn resources into string, and removes potentially problematic cookie string to avoid breaking logfiles + */ + private function sanitizeFunctionParameters(array $args): array { + return array_map(function ($item) { + if ($this->isResource($item)) { + return '(resource)'; + } + if (isset($item[0]['value']['cookie']) && $item[0]['value']['cookie'] !== '') { + $item[0]['value']['cookie'] = '*opaque cookie*'; + } + return $item; + }, $args); + } + + private function preFunctionCall(string $functionName, array $args): void { + $this->curArgs = $args; + if (strcasecmp($functionName, 'ldap_bind') === 0 || strcasecmp($functionName, 'ldap_exop_passwd') === 0) { + // The arguments are not key value pairs + // \OCA\User_LDAP\LDAP::bind passes 3 arguments, the 3rd being the pw + // Remove it via direct array access for now, although a better solution could be found mebbe? + // @link https://github.com/nextcloud/server/issues/38461 + $args[2] = IConfig::SENSITIVE_VALUE; + } + + if ($this->config->getSystemValue('loglevel') === ILogger::DEBUG) { + /* Only running this if debug loglevel is on, to avoid processing parameters on production */ + $this->logger->debug('Calling LDAP function {func} with parameters {args}', [ + 'app' => 'user_ldap', + 'func' => $functionName, + 'args' => $this->sanitizeFunctionParameters($args), + ]); + } + + if ($this->dataCollector !== null) { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $this->dataCollector->startLdapRequest($functionName, $this->sanitizeFunctionParameters($args), $backtrace); + } + + if ($this->logFile !== '' && is_writable(dirname($this->logFile)) && (!file_exists($this->logFile) || is_writable($this->logFile))) { + file_put_contents( + $this->logFile, + $functionName . '::' . json_encode($this->sanitizeFunctionParameters($args)) . "\n", + FILE_APPEND + ); + } + } + + /** + * Analyzes the returned LDAP error and acts accordingly if not 0 + * + * @param \LDAP\Connection $resource the LDAP Connection resource + * @throws ConstraintViolationException + * @throws ServerNotAvailableException + * @throws \Exception + */ + private function processLDAPError($resource, string $functionName, int $errorCode, string $errorMsg): void { + $this->logger->debug('LDAP error {message} ({code}) after calling {func}', [ + 'app' => 'user_ldap', + 'message' => $errorMsg, + 'code' => $errorCode, + 'func' => $functionName, + ]); + if ($functionName === 'ldap_get_entries' + && $errorCode === -4) { + } elseif ($errorCode === 32) { + //for now + } elseif ($errorCode === 10) { + //referrals, we switch them off, but then there is AD :) + } elseif ($errorCode === -1) { + throw new ServerNotAvailableException('Lost connection to LDAP server.'); + } elseif ($errorCode === 52) { + throw new ServerNotAvailableException('LDAP server is shutting down.'); + } elseif ($errorCode === 48) { + throw new \Exception('LDAP authentication method rejected', $errorCode); + } elseif ($errorCode === 1) { + throw new \Exception('LDAP Operations error', $errorCode); + } elseif ($errorCode === 19) { + ldap_get_option($resource, LDAP_OPT_ERROR_STRING, $extended_error); + throw new ConstraintViolationException(!empty($extended_error) ? $extended_error : $errorMsg, $errorCode); + } + } + + /** + * Called after an ldap method is run to act on LDAP error if necessary + * @throws \Exception + */ + private function postFunctionCall(string $functionName): void { + if ($this->isResource($this->curArgs[0])) { + $resource = $this->curArgs[0]; + } elseif ( + $functionName === 'ldap_search' + && is_array($this->curArgs[0]) + && $this->isResource($this->curArgs[0][0]) + ) { + // we use always the same LDAP connection resource, is enough to + // take the first one. + $resource = $this->curArgs[0][0]; + } else { + return; + } + + $errorCode = ldap_errno($resource); + if ($errorCode === 0) { + return; + } + $errorMsg = ldap_error($resource); + + $this->processLDAPError($resource, $functionName, $errorCode, $errorMsg); + + $this->curArgs = []; + } +} diff --git a/apps/user_ldap/lib/LDAPProvider.php b/apps/user_ldap/lib/LDAPProvider.php new file mode 100644 index 00000000000..d9750ae3fcf --- /dev/null +++ b/apps/user_ldap/lib/LDAPProvider.php @@ -0,0 +1,330 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCP\IServerContainer; +use OCP\LDAP\IDeletionFlagSupport; +use OCP\LDAP\ILDAPProvider; +use Psr\Log\LoggerInterface; + +/** + * LDAP provider for public access to the LDAP backend. + */ +class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { + private $userBackend; + private $groupBackend; + private $logger; + + /** + * Create new LDAPProvider + * @param IServerContainer $serverContainer + * @param Helper $helper + * @param DeletedUsersIndex $deletedUsersIndex + * @throws \Exception if user_ldap app was not enabled + */ + public function __construct( + IServerContainer $serverContainer, + private Helper $helper, + private DeletedUsersIndex $deletedUsersIndex, + ) { + $this->logger = $serverContainer->get(LoggerInterface::class); + $userBackendFound = false; + $groupBackendFound = false; + foreach ($serverContainer->getUserManager()->getBackends() as $backend) { + $this->logger->debug('instance ' . get_class($backend) . ' user backend.', ['app' => 'user_ldap']); + if ($backend instanceof IUserLDAP) { + $this->userBackend = $backend; + $userBackendFound = true; + break; + } + } + foreach ($serverContainer->getGroupManager()->getBackends() as $backend) { + $this->logger->debug('instance ' . get_class($backend) . ' group backend.', ['app' => 'user_ldap']); + if ($backend instanceof IGroupLDAP) { + $this->groupBackend = $backend; + $groupBackendFound = true; + break; + } + } + + if (!$userBackendFound or !$groupBackendFound) { + throw new \Exception('To use the LDAPProvider, user_ldap app must be enabled'); + } + } + + /** + * Translate an user id to LDAP DN + * @param string $uid user id + * @return string with the LDAP DN + * @throws \Exception if translation was unsuccessful + */ + public function getUserDN($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + $result = $this->userBackend->getLDAPAccess($uid)->username2dn($uid); + if (!$result) { + throw new \Exception('Translation to LDAP DN unsuccessful'); + } + return $result; + } + + /** + * Translate a group id to LDAP DN. + * @param string $gid group id + * @return string + * @throws \Exception + */ + public function getGroupDN($gid) { + if (!$this->groupBackend->groupExists($gid)) { + throw new \Exception('Group id not found in LDAP'); + } + $result = $this->groupBackend->getLDAPAccess($gid)->groupname2dn($gid); + if (!$result) { + throw new \Exception('Translation to LDAP DN unsuccessful'); + } + return $result; + } + + /** + * Translate a LDAP DN to an internal user name. If there is no mapping between + * the DN and the user name, a new one will be created. + * @param string $dn LDAP DN + * @return string with the internal user name + * @throws \Exception if translation was unsuccessful + */ + public function getUserName($dn) { + $result = $this->userBackend->dn2UserName($dn); + if (!$result) { + throw new \Exception('Translation to internal user name unsuccessful'); + } + return $result; + } + + /** + * Convert a stored DN so it can be used as base parameter for LDAP queries. + * @param string $dn the DN in question + * @return string + */ + public function DNasBaseParameter($dn) { + return $this->helper->DNasBaseParameter($dn); + } + + /** + * Sanitize a DN received from the LDAP server. + * @param array|string $dn the DN in question + * @return array|string the sanitized DN + */ + public function sanitizeDN($dn) { + return $this->helper->sanitizeDN($dn); + } + + /** + * Return a new LDAP connection resource for the specified user. + * The connection must be closed manually. + * @param string $uid user id + * @return \LDAP\Connection The LDAP connection + * @throws \Exception if user id was not found in LDAP + */ + public function getLDAPConnection($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + return $this->userBackend->getNewLDAPConnection($uid); + } + + /** + * Return a new LDAP connection resource for the specified user. + * The connection must be closed manually. + * @param string $gid group id + * @return \LDAP\Connection The LDAP connection + * @throws \Exception if group id was not found in LDAP + */ + public function getGroupLDAPConnection($gid) { + if (!$this->groupBackend->groupExists($gid)) { + throw new \Exception('Group id not found in LDAP'); + } + return $this->groupBackend->getNewLDAPConnection($gid); + } + + /** + * Get the LDAP base for users. + * @param string $uid user id + * @return string the base for users + * @throws \Exception if user id was not found in LDAP + */ + public function getLDAPBaseUsers($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + $access = $this->userBackend->getLDAPAccess($uid); + $bases = $access->getConnection()->ldapBaseUsers; + $dn = $this->getUserDN($uid); + foreach ($bases as $base) { + if ($access->isDNPartOfBase($dn, [$base])) { + return $base; + } + } + // should not occur, because the user does not qualify to use NC in this case + $this->logger->info( + 'No matching user base found for user {dn}, available: {bases}.', + [ + 'app' => 'user_ldap', + 'dn' => $dn, + 'bases' => $bases, + ] + ); + return array_shift($bases); + } + + /** + * Get the LDAP base for groups. + * @param string $uid user id + * @return string the base for groups + * @throws \Exception if user id was not found in LDAP + */ + public function getLDAPBaseGroups($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + $bases = $this->userBackend->getLDAPAccess($uid)->getConnection()->ldapBaseGroups; + return array_shift($bases); + } + + /** + * Clear the cache if a cache is used, otherwise do nothing. + * @param string $uid user id + * @throws \Exception if user id was not found in LDAP + */ + public function clearCache($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + $this->userBackend->getLDAPAccess($uid)->getConnection()->clearCache(); + } + + /** + * Clear the cache if a cache is used, otherwise do nothing. + * Acts on the LDAP connection of a group + * @param string $gid group id + * @throws \Exception if user id was not found in LDAP + */ + public function clearGroupCache($gid) { + if (!$this->groupBackend->groupExists($gid)) { + throw new \Exception('Group id not found in LDAP'); + } + $this->groupBackend->getLDAPAccess($gid)->getConnection()->clearCache(); + } + + /** + * Check whether a LDAP DN exists + * @param string $dn LDAP DN + * @return bool whether the DN exists + */ + public function dnExists($dn) { + $result = $this->userBackend->dn2UserName($dn); + return !$result ? false : true; + } + + /** + * Flag record for deletion. + * @param string $uid user id + */ + public function flagRecord($uid) { + $this->deletedUsersIndex->markUser($uid); + } + + /** + * Unflag record for deletion. + * @param string $uid user id + */ + public function unflagRecord($uid) { + //do nothing + } + + /** + * Get the LDAP attribute name for the user's display name + * @param string $uid user id + * @return string the display name field + * @throws \Exception if user id was not found in LDAP + */ + public function getLDAPDisplayNameField($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + return $this->userBackend->getLDAPAccess($uid)->getConnection()->getConfiguration()['ldap_display_name']; + } + + /** + * Get the LDAP attribute name for the email + * @param string $uid user id + * @return string the email field + * @throws \Exception if user id was not found in LDAP + */ + public function getLDAPEmailField($uid) { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + return $this->userBackend->getLDAPAccess($uid)->getConnection()->getConfiguration()['ldap_email_attr']; + } + + /** + * Get the LDAP type of association between users and groups + * @param string $gid group id + * @return string the configuration, one of: 'memberUid', 'uniqueMember', 'member', 'gidNumber', '' + * @throws \Exception if group id was not found in LDAP + */ + public function getLDAPGroupMemberAssoc($gid) { + if (!$this->groupBackend->groupExists($gid)) { + throw new \Exception('Group id not found in LDAP'); + } + return $this->groupBackend->getLDAPAccess($gid)->getConnection()->getConfiguration()['ldap_group_member_assoc_attribute']; + } + + /** + * Get an LDAP attribute for a nextcloud user + * + * @throws \Exception if user id was not found in LDAP + */ + public function getUserAttribute(string $uid, string $attribute): ?string { + $values = $this->getMultiValueUserAttribute($uid, $attribute); + if (count($values) === 0) { + return null; + } + return current($values); + } + + /** + * Get a multi-value LDAP attribute for a nextcloud user + * + * @throws \Exception if user id was not found in LDAP + */ + public function getMultiValueUserAttribute(string $uid, string $attribute): array { + if (!$this->userBackend->userExists($uid)) { + throw new \Exception('User id not found in LDAP'); + } + + $access = $this->userBackend->getLDAPAccess($uid); + $connection = $access->getConnection(); + $key = $uid . '-' . $attribute; + + $cached = $connection->getFromCache($key); + if (is_array($cached)) { + return $cached; + } + + $values = $access->readAttribute($access->username2dn($uid), $attribute); + if ($values === false) { + $values = []; + } + + $connection->writeToCache($key, $values); + return $values; + } +} diff --git a/apps/user_ldap/lib/LDAPProviderFactory.php b/apps/user_ldap/lib/LDAPProviderFactory.php new file mode 100644 index 00000000000..8fad9d52206 --- /dev/null +++ b/apps/user_ldap/lib/LDAPProviderFactory.php @@ -0,0 +1,28 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OCP\IServerContainer; +use OCP\LDAP\ILDAPProvider; +use OCP\LDAP\ILDAPProviderFactory; + +class LDAPProviderFactory implements ILDAPProviderFactory { + public function __construct( + /** * @var IServerContainer */ + private IServerContainer $serverContainer, + ) { + } + + public function getLDAPProvider(): ILDAPProvider { + return $this->serverContainer->get(LDAPProvider::class); + } + + public function isAvailable(): bool { + return true; + } +} diff --git a/apps/user_ldap/lib/LDAPUtility.php b/apps/user_ldap/lib/LDAPUtility.php new file mode 100644 index 00000000000..39b517528e2 --- /dev/null +++ b/apps/user_ldap/lib/LDAPUtility.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +abstract class LDAPUtility { + /** + * constructor, make sure the subclasses call this one! + * @param ILDAPWrapper $ldap an instance of an ILDAPWrapper + */ + public function __construct( + protected ILDAPWrapper $ldap, + ) { + } +} diff --git a/apps/user_ldap/lib/LoginListener.php b/apps/user_ldap/lib/LoginListener.php new file mode 100644 index 00000000000..f397f4694d2 --- /dev/null +++ b/apps/user_ldap/lib/LoginListener.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OCA\User_LDAP\Db\GroupMembership; +use OCA\User_LDAP\Db\GroupMembershipMapper; +use OCP\DB\Exception; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use OCP\Group\Events\UserAddedEvent; +use OCP\Group\Events\UserRemovedEvent; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\User\Events\PostLoginEvent; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener<PostLoginEvent> + */ +class LoginListener implements IEventListener { + public function __construct( + private IEventDispatcher $dispatcher, + private Group_Proxy $groupBackend, + private IGroupManager $groupManager, + private LoggerInterface $logger, + private GroupMembershipMapper $groupMembershipMapper, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof PostLoginEvent) { + $this->onPostLogin($event->getUser()); + } + } + + public function onPostLogin(IUser $user): void { + $this->logger->info( + self::class . ' - {user} postLogin', + [ + 'app' => 'user_ldap', + 'user' => $user->getUID(), + ] + ); + $this->updateGroups($user); + } + + private function updateGroups(IUser $userObject): void { + $userId = $userObject->getUID(); + $groupMemberships = $this->groupMembershipMapper->findGroupMembershipsForUser($userId); + $knownGroups = array_map( + static fn (GroupMembership $groupMembership): string => $groupMembership->getGroupid(), + $groupMemberships + ); + $groupMemberships = array_combine($knownGroups, $groupMemberships); + $actualGroups = $this->groupBackend->getUserGroups($userId); + + $newGroups = array_diff($actualGroups, $knownGroups); + $oldGroups = array_diff($knownGroups, $actualGroups); + foreach ($newGroups as $groupId) { + $groupObject = $this->groupManager->get($groupId); + if ($groupObject === null) { + $this->logger->error( + self::class . ' - group {group} could not be found (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $userId, + 'group' => $groupId + ] + ); + continue; + } + try { + $this->groupMembershipMapper->insert(GroupMembership::fromParams(['groupid' => $groupId,'userid' => $userId])); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $this->logger->error( + self::class . ' - group {group} membership failed to be added (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $userId, + 'group' => $groupId, + 'exception' => $e, + ] + ); + } + /* We failed to insert the groupmembership so we do not want to advertise it */ + continue; + } + $this->groupBackend->addRelationshipToCaches($userId, null, $groupId); + $this->dispatcher->dispatchTyped(new UserAddedEvent($groupObject, $userObject)); + $this->logger->info( + self::class . ' - {user} added to {group}', + [ + 'app' => 'user_ldap', + 'user' => $userId, + 'group' => $groupId + ] + ); + } + foreach ($oldGroups as $groupId) { + try { + $this->groupMembershipMapper->delete($groupMemberships[$groupId]); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_DATABASE_OBJECT_NOT_FOUND) { + $this->logger->error( + self::class . ' - group {group} membership failed to be removed (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $userId, + 'group' => $groupId, + 'exception' => $e, + ] + ); + } + /* We failed to delete the groupmembership so we do not want to advertise it */ + continue; + } + $groupObject = $this->groupManager->get($groupId); + if ($groupObject === null) { + $this->logger->error( + self::class . ' - group {group} could not be found (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $userId, + 'group' => $groupId + ] + ); + continue; + } + $this->dispatcher->dispatchTyped(new UserRemovedEvent($groupObject, $userObject)); + $this->logger->info( + 'service "updateGroups" - {user} removed from {group}', + [ + 'user' => $userId, + 'group' => $groupId + ] + ); + } + } +} diff --git a/apps/user_ldap/lib/Mapping/AbstractMapping.php b/apps/user_ldap/lib/Mapping/AbstractMapping.php new file mode 100644 index 00000000000..fa10312a915 --- /dev/null +++ b/apps/user_ldap/lib/Mapping/AbstractMapping.php @@ -0,0 +1,450 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Mapping; + +use Doctrine\DBAL\Exception; +use OCP\DB\IPreparedStatement; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Server; +use Psr\Log\LoggerInterface; + +/** + * Class AbstractMapping + * + * @package OCA\User_LDAP\Mapping + */ +abstract class AbstractMapping { + /** + * returns the DB table name which holds the mappings + * + * @return string + */ + abstract protected function getTableName(bool $includePrefix = true); + + /** + * @param IDBConnection $dbc + */ + public function __construct( + protected IDBConnection $dbc, + ) { + } + + /** @var array caches Names (value) by DN (key) */ + protected $cache = []; + + /** + * checks whether a provided string represents an existing table col + * + * @param string $col + * @return bool + */ + public function isColNameValid($col) { + switch ($col) { + case 'ldap_dn': + case 'ldap_dn_hash': + case 'owncloud_name': + case 'directory_uuid': + return true; + default: + return false; + } + } + + /** + * Gets the value of one column based on a provided value of another column + * + * @param string $fetchCol + * @param string $compareCol + * @param string $search + * @return string|false + * @throws \Exception + */ + protected function getXbyY($fetchCol, $compareCol, $search) { + if (!$this->isColNameValid($fetchCol)) { + //this is used internally only, but we don't want to risk + //having SQL injection at all. + throw new \Exception('Invalid Column Name'); + } + $query = $this->dbc->prepare(' + SELECT `' . $fetchCol . '` + FROM `' . $this->getTableName() . '` + WHERE `' . $compareCol . '` = ? + '); + + try { + $res = $query->execute([$search]); + $data = $res->fetchOne(); + $res->closeCursor(); + return $data; + } catch (Exception $e) { + return false; + } + } + + /** + * Performs a DELETE or UPDATE query to the database. + * + * @param IPreparedStatement $statement + * @param array $parameters + * @return bool true if at least one row was modified, false otherwise + */ + protected function modify(IPreparedStatement $statement, $parameters) { + try { + $result = $statement->execute($parameters); + $updated = $result->rowCount() > 0; + $result->closeCursor(); + return $updated; + } catch (Exception $e) { + return false; + } + } + + /** + * Gets the LDAP DN based on the provided name. + * Replaces Access::ocname2dn + * + * @param string $name + * @return string|false + */ + public function getDNByName($name) { + $dn = array_search($name, $this->cache); + if ($dn === false && ($dn = $this->getXbyY('ldap_dn', 'owncloud_name', $name)) !== false) { + $this->cache[$dn] = $name; + } + return $dn; + } + + /** + * Updates the DN based on the given UUID + * + * @param string $fdn + * @param string $uuid + * @return bool + */ + public function setDNbyUUID($fdn, $uuid) { + $oldDn = $this->getDnByUUID($uuid); + $statement = $this->dbc->prepare(' + UPDATE `' . $this->getTableName() . '` + SET `ldap_dn_hash` = ?, `ldap_dn` = ? + WHERE `directory_uuid` = ? + '); + + $r = $this->modify($statement, [$this->getDNHash($fdn), $fdn, $uuid]); + + if ($r && is_string($oldDn) && isset($this->cache[$oldDn])) { + $this->cache[$fdn] = $this->cache[$oldDn]; + unset($this->cache[$oldDn]); + } + + return $r; + } + + /** + * Updates the UUID based on the given DN + * + * required by Migration/UUIDFix + * + * @param $uuid + * @param $fdn + * @return bool + */ + public function setUUIDbyDN($uuid, $fdn): bool { + $statement = $this->dbc->prepare(' + UPDATE `' . $this->getTableName() . '` + SET `directory_uuid` = ? + WHERE `ldap_dn_hash` = ? + '); + + unset($this->cache[$fdn]); + + return $this->modify($statement, [$uuid, $this->getDNHash($fdn)]); + } + + /** + * Get the hash to store in database column ldap_dn_hash for a given dn + */ + protected function getDNHash(string $fdn): string { + return hash('sha256', $fdn, false); + } + + /** + * Gets the name based on the provided LDAP DN. + * + * @param string $fdn + * @return string|false + */ + public function getNameByDN($fdn) { + if (!isset($this->cache[$fdn])) { + $this->cache[$fdn] = $this->getXbyY('owncloud_name', 'ldap_dn_hash', $this->getDNHash($fdn)); + } + return $this->cache[$fdn]; + } + + /** + * @param array<string> $hashList + */ + protected function prepareListOfIdsQuery(array $hashList): IQueryBuilder { + $qb = $this->dbc->getQueryBuilder(); + $qb->select('owncloud_name', 'ldap_dn_hash', 'ldap_dn') + ->from($this->getTableName(false)) + ->where($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($hashList, IQueryBuilder::PARAM_STR_ARRAY))); + return $qb; + } + + protected function collectResultsFromListOfIdsQuery(IQueryBuilder $qb, array &$results): void { + $stmt = $qb->executeQuery(); + while ($entry = $stmt->fetch(\Doctrine\DBAL\FetchMode::ASSOCIATIVE)) { + $results[$entry['ldap_dn']] = $entry['owncloud_name']; + $this->cache[$entry['ldap_dn']] = $entry['owncloud_name']; + } + $stmt->closeCursor(); + } + + /** + * @param array<string> $fdns + * @return array<string,string> + */ + public function getListOfIdsByDn(array $fdns): array { + $totalDBParamLimit = 65000; + $sliceSize = 1000; + $maxSlices = $this->dbc->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE ? 9 : $totalDBParamLimit / $sliceSize; + $results = []; + + $slice = 1; + $fdns = array_map([$this, 'getDNHash'], $fdns); + $fdnsSlice = count($fdns) > $sliceSize ? array_slice($fdns, 0, $sliceSize) : $fdns; + $qb = $this->prepareListOfIdsQuery($fdnsSlice); + + while (isset($fdnsSlice[999])) { + // Oracle does not allow more than 1000 values in the IN list, + // but allows slicing + $slice++; + $fdnsSlice = array_slice($fdns, $sliceSize * ($slice - 1), $sliceSize); + + /** @see https://github.com/vimeo/psalm/issues/4995 */ + /** @psalm-suppress TypeDoesNotContainType */ + if (!isset($qb)) { + $qb = $this->prepareListOfIdsQuery($fdnsSlice); + continue; + } + + if (!empty($fdnsSlice)) { + $qb->orWhere($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($fdnsSlice, IQueryBuilder::PARAM_STR_ARRAY))); + } + + if ($slice % $maxSlices === 0) { + $this->collectResultsFromListOfIdsQuery($qb, $results); + unset($qb); + } + } + + if (isset($qb)) { + $this->collectResultsFromListOfIdsQuery($qb, $results); + } + + return $results; + } + + /** + * Searches mapped names by the giving string in the name column + * + * @return string[] + */ + public function getNamesBySearch(string $search, string $prefixMatch = '', string $postfixMatch = ''): array { + $statement = $this->dbc->prepare(' + SELECT `owncloud_name` + FROM `' . $this->getTableName() . '` + WHERE `owncloud_name` LIKE ? + '); + + try { + $res = $statement->execute([$prefixMatch . $this->dbc->escapeLikeParameter($search) . $postfixMatch]); + } catch (Exception $e) { + return []; + } + $names = []; + while ($row = $res->fetch()) { + $names[] = $row['owncloud_name']; + } + return $names; + } + + /** + * Gets the name based on the provided LDAP UUID. + * + * @param string $uuid + * @return string|false + */ + public function getNameByUUID($uuid) { + return $this->getXbyY('owncloud_name', 'directory_uuid', $uuid); + } + + public function getDnByUUID($uuid) { + return $this->getXbyY('ldap_dn', 'directory_uuid', $uuid); + } + + /** + * Gets the UUID based on the provided LDAP DN + * + * @param string $dn + * @return false|string + * @throws \Exception + */ + public function getUUIDByDN($dn) { + return $this->getXbyY('directory_uuid', 'ldap_dn_hash', $this->getDNHash($dn)); + } + + public function getList(int $offset = 0, ?int $limit = null, bool $invalidatedOnly = false): array { + $select = $this->dbc->getQueryBuilder(); + $select->selectAlias('ldap_dn', 'dn') + ->selectAlias('owncloud_name', 'name') + ->selectAlias('directory_uuid', 'uuid') + ->from($this->getTableName()) + ->setMaxResults($limit) + ->setFirstResult($offset); + + if ($invalidatedOnly) { + $select->where($select->expr()->like('directory_uuid', $select->createNamedParameter('invalidated_%'))); + } + + $result = $select->executeQuery(); + $entries = $result->fetchAll(); + $result->closeCursor(); + + return $entries; + } + + /** + * attempts to map the given entry + * + * @param string $fdn fully distinguished name (from LDAP) + * @param string $name + * @param string $uuid a unique identifier as used in LDAP + * @return bool + */ + public function map($fdn, $name, $uuid) { + if (mb_strlen($fdn) > 4000) { + Server::get(LoggerInterface::class)->error( + 'Cannot map, because the DN exceeds 4000 characters: {dn}', + [ + 'app' => 'user_ldap', + 'dn' => $fdn, + ] + ); + return false; + } + + $row = [ + 'ldap_dn_hash' => $this->getDNHash($fdn), + 'ldap_dn' => $fdn, + 'owncloud_name' => $name, + 'directory_uuid' => $uuid + ]; + + try { + $result = $this->dbc->insertIfNotExist($this->getTableName(), $row); + if ((bool)$result === true) { + $this->cache[$fdn] = $name; + } + // insertIfNotExist returns values as int + return (bool)$result; + } catch (\Exception $e) { + return false; + } + } + + /** + * removes a mapping based on the owncloud_name of the entry + * + * @param string $name + * @return bool + */ + public function unmap($name) { + $statement = $this->dbc->prepare(' + DELETE FROM `' . $this->getTableName() . '` + WHERE `owncloud_name` = ?'); + + $dn = array_search($name, $this->cache); + if ($dn !== false) { + unset($this->cache[$dn]); + } + + return $this->modify($statement, [$name]); + } + + /** + * Truncates the mapping table + * + * @return bool + */ + public function clear() { + $sql = $this->dbc + ->getDatabasePlatform() + ->getTruncateTableSQL('`' . $this->getTableName() . '`'); + try { + $this->dbc->executeQuery($sql); + + return true; + } catch (Exception $e) { + return false; + } + } + + /** + * clears the mapping table one by one and executing a callback with + * each row's id (=owncloud_name col) + * + * @param callable $preCallback + * @param callable $postCallback + * @return bool true on success, false when at least one row was not + * deleted + */ + public function clearCb(callable $preCallback, callable $postCallback): bool { + $picker = $this->dbc->getQueryBuilder(); + $picker->select('owncloud_name') + ->from($this->getTableName()); + $cursor = $picker->executeQuery(); + $result = true; + while (($id = $cursor->fetchOne()) !== false) { + $preCallback($id); + if ($isUnmapped = $this->unmap($id)) { + $postCallback($id); + } + $result = $result && $isUnmapped; + } + $cursor->closeCursor(); + return $result; + } + + /** + * returns the number of entries in the mappings table + * + * @return int + */ + public function count(): int { + $query = $this->dbc->getQueryBuilder(); + $query->select($query->func()->count('ldap_dn_hash')) + ->from($this->getTableName()); + $res = $query->executeQuery(); + $count = $res->fetchOne(); + $res->closeCursor(); + return (int)$count; + } + + public function countInvalidated(): int { + $query = $this->dbc->getQueryBuilder(); + $query->select($query->func()->count('ldap_dn_hash')) + ->from($this->getTableName()) + ->where($query->expr()->like('directory_uuid', $query->createNamedParameter('invalidated_%'))); + $res = $query->executeQuery(); + $count = $res->fetchOne(); + $res->closeCursor(); + return (int)$count; + } +} diff --git a/apps/user_ldap/lib/Mapping/GroupMapping.php b/apps/user_ldap/lib/Mapping/GroupMapping.php new file mode 100644 index 00000000000..d9ae5e749fc --- /dev/null +++ b/apps/user_ldap/lib/Mapping/GroupMapping.php @@ -0,0 +1,24 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Mapping; + +/** + * Class UserMapping + * @package OCA\User_LDAP\Mapping + */ +class GroupMapping extends AbstractMapping { + + /** + * returns the DB table name which holds the mappings + * @return string + */ + protected function getTableName(bool $includePrefix = true) { + $p = $includePrefix ? '*PREFIX*' : ''; + return $p . 'ldap_group_mapping'; + } +} diff --git a/apps/user_ldap/lib/Mapping/UserMapping.php b/apps/user_ldap/lib/Mapping/UserMapping.php new file mode 100644 index 00000000000..a030cd0ab52 --- /dev/null +++ b/apps/user_ldap/lib/Mapping/UserMapping.php @@ -0,0 +1,64 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\Mapping; + +use OCP\HintException; +use OCP\IDBConnection; +use OCP\IRequest; +use OCP\Server; +use OCP\Support\Subscription\IAssertion; + +/** + * Class UserMapping + * + * @package OCA\User_LDAP\Mapping + */ +class UserMapping extends AbstractMapping { + + protected const PROV_API_REGEX = '/\/ocs\/v[1-9].php\/cloud\/(groups|users)/'; + + public function __construct( + IDBConnection $dbc, + private IAssertion $assertion, + ) { + parent::__construct($dbc); + } + + /** + * @throws HintException + */ + public function map($fdn, $name, $uuid): bool { + try { + $this->assertion->createUserIsLegit(); + } catch (HintException $e) { + static $isProvisioningApi = null; + + if ($isProvisioningApi === null) { + $request = Server::get(IRequest::class); + $isProvisioningApi = \preg_match(self::PROV_API_REGEX, $request->getRequestUri()) === 1; + } + if ($isProvisioningApi) { + // only throw when prov API is being used, since functionality + // should not break for end users (e.g. when sharing). + // On direct API usage, e.g. on users page, this is desired. + throw $e; + } + return false; + } + return parent::map($fdn, $name, $uuid); + } + + /** + * returns the DB table name which holds the mappings + * @return string + */ + protected function getTableName(bool $includePrefix = true) { + $p = $includePrefix ? '*PREFIX*' : ''; + return $p . 'ldap_user_mapping'; + } +} diff --git a/apps/user_ldap/lib/Migration/GroupMappingMigration.php b/apps/user_ldap/lib/Migration/GroupMappingMigration.php new file mode 100644 index 00000000000..7dfb8705770 --- /dev/null +++ b/apps/user_ldap/lib/Migration/GroupMappingMigration.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use OCP\IDBConnection; +use OCP\Migration\SimpleMigrationStep; + +abstract class GroupMappingMigration extends SimpleMigrationStep { + + public function __construct( + private IDBConnection $dbc, + ) { + } + + protected function copyGroupMappingData(string $sourceTable, string $destinationTable): void { + $insert = $this->dbc->getQueryBuilder(); + $insert->insert($destinationTable) + ->values([ + 'ldap_dn' => $insert->createParameter('ldap_dn'), + 'owncloud_name' => $insert->createParameter('owncloud_name'), + 'directory_uuid' => $insert->createParameter('directory_uuid'), + 'ldap_dn_hash' => $insert->createParameter('ldap_dn_hash'), + ]); + + $query = $this->dbc->getQueryBuilder(); + $query->select('*') + ->from($sourceTable); + + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $insert + ->setParameter('ldap_dn', $row['ldap_dn']) + ->setParameter('owncloud_name', $row['owncloud_name']) + ->setParameter('directory_uuid', $row['directory_uuid']) + ->setParameter('ldap_dn_hash', $row['ldap_dn_hash']) + ; + + $insert->executeStatement(); + } + $result->closeCursor(); + } +} diff --git a/apps/user_ldap/lib/Migration/RemoveRefreshTime.php b/apps/user_ldap/lib/Migration/RemoveRefreshTime.php new file mode 100644 index 00000000000..88ac56ccb84 --- /dev/null +++ b/apps/user_ldap/lib/Migration/RemoveRefreshTime.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +/** + * Class RmRefreshTime + * + * this can be removed with Nextcloud 21 + * + * @package OCA\User_LDAP\Migration + */ +class RemoveRefreshTime implements IRepairStep { + + public function __construct( + private IDBConnection $dbc, + private IConfig $config, + ) { + } + + public function getName() { + return 'Remove deprecated refresh time markers for LDAP user records'; + } + + public function run(IOutput $output) { + $this->config->deleteAppValue('user_ldap', 'updateAttributesInterval'); + + $qb = $this->dbc->getQueryBuilder(); + $qb->delete('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter('user_ldap'))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('lastFeatureRefresh'))) + ->executeStatement(); + } +} diff --git a/apps/user_ldap/lib/Migration/SetDefaultProvider.php b/apps/user_ldap/lib/Migration/SetDefaultProvider.php new file mode 100644 index 00000000000..0bb04438a1d --- /dev/null +++ b/apps/user_ldap/lib/Migration/SetDefaultProvider.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\Helper; +use OCA\User_LDAP\LDAPProviderFactory; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class SetDefaultProvider implements IRepairStep { + + public function __construct( + private IConfig $config, + private Helper $helper, + ) { + } + + public function getName(): string { + return 'Set default LDAP provider'; + } + + public function run(IOutput $output): void { + $current = $this->config->getSystemValue('ldapProviderFactory', null); + if ($current === null) { + $this->config->setSystemValue('ldapProviderFactory', LDAPProviderFactory::class); + } + } +} diff --git a/apps/user_ldap/lib/Migration/UUIDFix.php b/apps/user_ldap/lib/Migration/UUIDFix.php new file mode 100644 index 00000000000..e853f3bba66 --- /dev/null +++ b/apps/user_ldap/lib/Migration/UUIDFix.php @@ -0,0 +1,32 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\Mapping\AbstractMapping; +use OCA\User_LDAP\Proxy; +use OCA\User_LDAP\User_Proxy; +use OCP\BackgroundJob\QueuedJob; + +abstract class UUIDFix extends QueuedJob { + protected AbstractMapping $mapper; + protected Proxy $proxy; + + public function run($argument) { + $isUser = $this->proxy instanceof User_Proxy; + foreach ($argument['records'] as $record) { + $access = $this->proxy->getLDAPAccess($record['name']); + $uuid = $access->getUUID($record['dn'], $isUser); + if ($uuid === false) { + // record not found, no prob, continue with the next + continue; + } + if ($uuid !== $record['uuid']) { + $this->mapper->setUUIDbyDN($uuid, $record['dn']); + } + } + } +} diff --git a/apps/user_ldap/lib/Migration/UUIDFixGroup.php b/apps/user_ldap/lib/Migration/UUIDFixGroup.php new file mode 100644 index 00000000000..3924c91e7ba --- /dev/null +++ b/apps/user_ldap/lib/Migration/UUIDFixGroup.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Mapping\GroupMapping; +use OCP\AppFramework\Utility\ITimeFactory; + +class UUIDFixGroup extends UUIDFix { + public function __construct(ITimeFactory $time, GroupMapping $mapper, Group_Proxy $proxy) { + parent::__construct($time); + $this->mapper = $mapper; + $this->proxy = $proxy; + } +} diff --git a/apps/user_ldap/lib/Migration/UUIDFixInsert.php b/apps/user_ldap/lib/Migration/UUIDFixInsert.php new file mode 100644 index 00000000000..bb92314d93a --- /dev/null +++ b/apps/user_ldap/lib/Migration/UUIDFixInsert.php @@ -0,0 +1,72 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class UUIDFixInsert implements IRepairStep { + + public function __construct( + protected IConfig $config, + protected UserMapping $userMapper, + protected GroupMapping $groupMapper, + protected IJobList $jobList, + ) { + } + + /** + * Returns the step's name + * + * @return string + * @since 9.1.0 + */ + public function getName() { + return 'Insert UUIDFix background job for user and group in batches'; + } + + /** + * Run repair step. + * Must throw exception on error. + * + * @param IOutput $output + * @throws \Exception in case of failure + * @since 9.1.0 + */ + public function run(IOutput $output) { + $installedVersion = $this->config->getAppValue('user_ldap', 'installed_version', '1.2.1'); + if (version_compare($installedVersion, '1.2.1') !== -1) { + return; + } + + foreach ([$this->userMapper, $this->groupMapper] as $mapper) { + $offset = 0; + $batchSize = 50; + $jobClass = $mapper instanceof UserMapping ? UUIDFixUser::class : UUIDFixGroup::class; + do { + $retry = false; + $records = $mapper->getList($offset, $batchSize); + if (count($records) === 0) { + continue; + } + try { + $this->jobList->add($jobClass, ['records' => $records]); + $offset += $batchSize; + } catch (\InvalidArgumentException $e) { + if (str_contains($e->getMessage(), 'Background job arguments can\'t exceed 4000')) { + $batchSize = (int)floor(count($records) * 0.8); + $retry = true; + } + } + } while (count($records) === $batchSize || $retry); + } + } +} diff --git a/apps/user_ldap/lib/Migration/UUIDFixUser.php b/apps/user_ldap/lib/Migration/UUIDFixUser.php new file mode 100644 index 00000000000..71c3f638095 --- /dev/null +++ b/apps/user_ldap/lib/Migration/UUIDFixUser.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User_Proxy; +use OCP\AppFramework\Utility\ITimeFactory; + +class UUIDFixUser extends UUIDFix { + public function __construct(ITimeFactory $time, UserMapping $mapper, User_Proxy $proxy) { + parent::__construct($time); + $this->mapper = $mapper; + $this->proxy = $proxy; + } +} diff --git a/apps/user_ldap/lib/Migration/UnsetDefaultProvider.php b/apps/user_ldap/lib/Migration/UnsetDefaultProvider.php new file mode 100644 index 00000000000..025415cf712 --- /dev/null +++ b/apps/user_ldap/lib/Migration/UnsetDefaultProvider.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use OCA\User_LDAP\LDAPProviderFactory; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class UnsetDefaultProvider implements IRepairStep { + + public function __construct( + private IConfig $config, + ) { + } + + public function getName(): string { + return 'Unset default LDAP provider'; + } + + public function run(IOutput $output): void { + $current = $this->config->getSystemValue('ldapProviderFactory', null); + if ($current === LDAPProviderFactory::class) { + $this->config->deleteSystemValue('ldapProviderFactory'); + } + } +} diff --git a/apps/user_ldap/lib/Migration/Version1010Date20200630192842.php b/apps/user_ldap/lib/Migration/Version1010Date20200630192842.php new file mode 100644 index 00000000000..1464e50e359 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1010Date20200630192842.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1010Date20200630192842 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_user_mapping')) { + $table = $schema->createTable('ldap_user_mapping'); + $table->addColumn('ldap_dn', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table->addColumn('owncloud_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table->addColumn('directory_uuid', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('ldap_dn_hash', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->setPrimaryKey(['owncloud_name']); + $table->addUniqueIndex(['ldap_dn_hash'], 'ldap_user_dn_hashes'); + $table->addUniqueIndex(['directory_uuid'], 'ldap_user_directory_uuid'); + } + + if (!$schema->hasTable('ldap_group_mapping')) { + $table = $schema->createTable('ldap_group_mapping'); + $table->addColumn('ldap_dn', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table->addColumn('owncloud_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table->addColumn('directory_uuid', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('ldap_dn_hash', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->setPrimaryKey(['owncloud_name']); + $table->addUniqueIndex(['ldap_dn_hash'], 'ldap_group_dn_hashes'); + $table->addUniqueIndex(['directory_uuid'], 'ldap_group_directory_uuid'); + } + + if (!$schema->hasTable('ldap_group_members')) { + $table = $schema->createTable('ldap_group_members'); + $table->addColumn('owncloudname', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('owncloudusers', Types::TEXT, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['owncloudname']); + } + return $schema; + } +} diff --git a/apps/user_ldap/lib/Migration/Version1120Date20210917155206.php b/apps/user_ldap/lib/Migration/Version1120Date20210917155206.php new file mode 100644 index 00000000000..dc3823bf771 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1120Date20210917155206.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OC\Hooks\PublicEmitter; +use OCP\DB\Exception; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IUserManager; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Psr\Log\LoggerInterface; + +class Version1120Date20210917155206 extends SimpleMigrationStep { + + public function __construct( + private IDBConnection $dbc, + private IUserManager $userManager, + private LoggerInterface $logger, + ) { + } + + public function getName() { + return 'Adjust LDAP user and group id column lengths to match server lengths'; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // ensure that there is no user or group id longer than 64char in LDAP table + $this->handleIDs('ldap_group_mapping', false); + $this->handleIDs('ldap_user_mapping', true); + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $changeSchema = false; + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $table = $schema->getTable($tableName); + $column = $table->getColumn('owncloud_name'); + if ($column->getLength() > 64) { + $column->setLength(64); + $changeSchema = true; + } + } + + return $changeSchema ? $schema : null; + } + + protected function handleIDs(string $table, bool $emitHooks) { + $select = $this->getSelectQuery($table); + $update = $this->getUpdateQuery($table); + + $result = $select->executeQuery(); + while ($row = $result->fetch()) { + $newId = hash('sha256', $row['owncloud_name'], false); + if ($emitHooks) { + $this->emitUnassign($row['owncloud_name'], true); + } + $update->setParameter('uuid', $row['directory_uuid']); + $update->setParameter('newId', $newId); + try { + $update->executeStatement(); + if ($emitHooks) { + $this->emitUnassign($row['owncloud_name'], false); + $this->emitAssign($newId); + } + } catch (Exception $e) { + $this->logger->error('Failed to shorten owncloud_name "{oldId}" to "{newId}" (UUID: "{uuid}" of {table})', + [ + 'app' => 'user_ldap', + 'oldId' => $row['owncloud_name'], + 'newId' => $newId, + 'uuid' => $row['directory_uuid'], + 'table' => $table, + 'exception' => $e, + ] + ); + } + } + $result->closeCursor(); + } + + protected function getSelectQuery(string $table): IQueryBuilder { + $qb = $this->dbc->getQueryBuilder(); + $qb->select('owncloud_name', 'directory_uuid') + ->from($table) + ->where($qb->expr()->gt($qb->func()->octetLength('owncloud_name'), $qb->createNamedParameter('64'), IQueryBuilder::PARAM_INT)); + return $qb; + } + + protected function getUpdateQuery(string $table): IQueryBuilder { + $qb = $this->dbc->getQueryBuilder(); + $qb->update($table) + ->set('owncloud_name', $qb->createParameter('newId')) + ->where($qb->expr()->eq('directory_uuid', $qb->createParameter('uuid'))); + return $qb; + } + + protected function emitUnassign(string $oldId, bool $pre): void { + if ($this->userManager instanceof PublicEmitter) { + $this->userManager->emit('\OC\User', $pre ? 'pre' : 'post' . 'UnassignedUserId', [$oldId]); + } + } + + protected function emitAssign(string $newId): void { + if ($this->userManager instanceof PublicEmitter) { + $this->userManager->emit('\OC\User', 'assignedUserId', [$newId]); + } + } +} diff --git a/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php b/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php new file mode 100644 index 00000000000..2457acd840d --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php @@ -0,0 +1,266 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use Generator; +use OCP\DB\Exception; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Psr\Log\LoggerInterface; + +class Version1130Date20211102154716 extends SimpleMigrationStep { + + /** @var string[] */ + private $hashColumnAddedToTables = []; + + public function __construct( + private IDBConnection $dbc, + private LoggerInterface $logger, + ) { + } + + public function getName() { + return 'Adjust LDAP user and group ldap_dn column lengths and add ldap_dn_hash columns'; + } + + public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $this->processDuplicateUUIDs($tableName); + } + + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + if ($schema->hasTable('ldap_group_mapping_backup')) { + // Previous upgrades of a broken release might have left an incomplete + // ldap_group_mapping_backup table. No need to recreate, but it + // should be empty. + // TRUNCATE is not available from Query Builder, but faster than DELETE FROM. + $sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*ldap_group_mapping_backup`', false); + $this->dbc->executeStatement($sql); + } + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $changeSchema = false; + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $table = $schema->getTable($tableName); + if (!$table->hasColumn('ldap_dn_hash')) { + $table->addColumn('ldap_dn_hash', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $changeSchema = true; + $this->hashColumnAddedToTables[] = $tableName; + } + $column = $table->getColumn('ldap_dn'); + if ($tableName === 'ldap_user_mapping') { + if ($column->getLength() < 4000) { + $column->setLength(4000); + $changeSchema = true; + } + + if ($table->hasIndex('ldap_dn_users')) { + $table->dropIndex('ldap_dn_users'); + $changeSchema = true; + } + if (!$table->hasIndex('ldap_user_dn_hashes')) { + $table->addUniqueIndex(['ldap_dn_hash'], 'ldap_user_dn_hashes'); + $changeSchema = true; + } + if (!$table->hasIndex('ldap_user_directory_uuid')) { + $table->addUniqueIndex(['directory_uuid'], 'ldap_user_directory_uuid'); + $changeSchema = true; + } + } elseif (!$schema->hasTable('ldap_group_mapping_backup')) { + // We need to copy the table twice to be able to change primary key, prepare the backup table + $table2 = $schema->createTable('ldap_group_mapping_backup'); + $table2->addColumn('ldap_dn', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table2->addColumn('owncloud_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table2->addColumn('directory_uuid', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table2->addColumn('ldap_dn_hash', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table2->setPrimaryKey(['owncloud_name'], 'lgm_backup_primary'); + $changeSchema = true; + } + } + + return $changeSchema ? $schema : null; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) { + $this->handleDNHashes('ldap_group_mapping'); + $this->handleDNHashes('ldap_user_mapping'); + } + + protected function handleDNHashes(string $table): void { + $select = $this->getSelectQuery($table); + $update = $this->getUpdateQuery($table); + + $result = $select->executeQuery(); + while ($row = $result->fetch()) { + $dnHash = hash('sha256', $row['ldap_dn'], false); + $update->setParameter('name', $row['owncloud_name']); + $update->setParameter('dn_hash', $dnHash); + try { + $update->executeStatement(); + } catch (Exception $e) { + $this->logger->error('Failed to add hash "{dnHash}" ("{name}" of {table})', + [ + 'app' => 'user_ldap', + 'name' => $row['owncloud_name'], + 'dnHash' => $dnHash, + 'table' => $table, + 'exception' => $e, + ] + ); + } + } + $result->closeCursor(); + } + + protected function getSelectQuery(string $table): IQueryBuilder { + $qb = $this->dbc->getQueryBuilder(); + $qb->select('owncloud_name', 'ldap_dn') + ->from($table); + + // when added we may run into risk that it's read from a DB node + // where the column is not present. Then the where clause is also + // not necessary since all rows qualify. + if (!in_array($table, $this->hashColumnAddedToTables, true)) { + $qb->where($qb->expr()->isNull('ldap_dn_hash')); + } + + return $qb; + } + + protected function getUpdateQuery(string $table): IQueryBuilder { + $qb = $this->dbc->getQueryBuilder(); + $qb->update($table) + ->set('ldap_dn_hash', $qb->createParameter('dn_hash')) + ->where($qb->expr()->eq('owncloud_name', $qb->createParameter('name'))); + return $qb; + } + + /** + * @throws Exception + */ + protected function processDuplicateUUIDs(string $table): void { + $uuids = $this->getDuplicatedUuids($table); + $idsWithUuidToInvalidate = []; + foreach ($uuids as $uuid) { + array_push($idsWithUuidToInvalidate, ...$this->getNextcloudIdsByUuid($table, $uuid)); + } + $this->invalidateUuids($table, $idsWithUuidToInvalidate); + } + + /** + * @throws Exception + */ + protected function invalidateUuids(string $table, array $idList): void { + $update = $this->dbc->getQueryBuilder(); + $update->update($table) + ->set('directory_uuid', $update->createParameter('invalidatedUuid')) + ->where($update->expr()->eq('owncloud_name', $update->createParameter('nextcloudId'))); + + while ($nextcloudId = array_shift($idList)) { + $update->setParameter('nextcloudId', $nextcloudId); + $update->setParameter('invalidatedUuid', 'invalidated_' . \bin2hex(\random_bytes(6))); + try { + $update->executeStatement(); + $this->logger->warning( + 'LDAP user or group with ID {nid} has a duplicated UUID value which therefore was invalidated. You may double-check your LDAP configuration and trigger an update of the UUID.', + [ + 'app' => 'user_ldap', + 'nid' => $nextcloudId, + ] + ); + } catch (Exception $e) { + // Catch possible, but unlikely duplications if new invalidated errors. + // There is the theoretical chance of an infinity loop is, when + // the constraint violation has a different background. I cannot + // think of one at the moment. + if ($e->getReason() !== Exception::REASON_CONSTRAINT_VIOLATION) { + throw $e; + } + $idList[] = $nextcloudId; + } + } + } + + /** + * @throws \OCP\DB\Exception + * @return array<string> + */ + protected function getNextcloudIdsByUuid(string $table, string $uuid): array { + $select = $this->dbc->getQueryBuilder(); + $select->select('owncloud_name') + ->from($table) + ->where($select->expr()->eq('directory_uuid', $select->createNamedParameter($uuid))); + + $result = $select->executeQuery(); + $idList = []; + while (($id = $result->fetchOne()) !== false) { + $idList[] = $id; + } + $result->closeCursor(); + return $idList; + } + + /** + * @return Generator<string> + * @throws \OCP\DB\Exception + */ + protected function getDuplicatedUuids(string $table): Generator { + $select = $this->dbc->getQueryBuilder(); + $select->select('directory_uuid') + ->from($table) + ->groupBy('directory_uuid') + ->having($select->expr()->gt($select->func()->count('owncloud_name'), $select->createNamedParameter(1))); + + $result = $select->executeQuery(); + while (($uuid = $result->fetchOne()) !== false) { + yield $uuid; + } + $result->closeCursor(); + } +} diff --git a/apps/user_ldap/lib/Migration/Version1130Date20220110154717.php b/apps/user_ldap/lib/Migration/Version1130Date20220110154717.php new file mode 100644 index 00000000000..80960373edf --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1130Date20220110154717.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; + +class Version1130Date20220110154717 extends GroupMappingMigration { + public function getName() { + return 'Copy ldap_group_mapping data to backup table if needed'; + } + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @since 13.0.0 + */ + public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_mapping_backup')) { + // Backup table does not exist + return; + } + + $output->startProgress(); + $this->copyGroupMappingData('ldap_group_mapping', 'ldap_group_mapping_backup'); + $output->finishProgress(); + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_mapping_backup')) { + // Backup table does not exist + return null; + } + + $schema->dropTable('ldap_group_mapping'); + + return $schema; + } +} diff --git a/apps/user_ldap/lib/Migration/Version1130Date20220110154718.php b/apps/user_ldap/lib/Migration/Version1130Date20220110154718.php new file mode 100644 index 00000000000..f67b791daad --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1130Date20220110154718.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; + +class Version1130Date20220110154718 extends GroupMappingMigration { + public function getName() { + return 'Copy ldap_group_mapping data from backup table and if needed'; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_mapping_backup')) { + // Backup table does not exist + return null; + } + + $table = $schema->createTable('ldap_group_mapping'); + $table->addColumn('ldap_dn', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + 'default' => '', + ]); + $table->addColumn('owncloud_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table->addColumn('directory_uuid', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('ldap_dn_hash', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->setPrimaryKey(['owncloud_name']); + $table->addUniqueIndex(['ldap_dn_hash'], 'ldap_group_dn_hashes'); + $table->addUniqueIndex(['directory_uuid'], 'ldap_group_directory_uuid'); + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_mapping_backup')) { + // Backup table does not exist + return; + } + + $output->startProgress(); + $this->copyGroupMappingData('ldap_group_mapping_backup', 'ldap_group_mapping'); + $output->finishProgress(); + } +} diff --git a/apps/user_ldap/lib/Migration/Version1130Date20220110154719.php b/apps/user_ldap/lib/Migration/Version1130Date20220110154719.php new file mode 100644 index 00000000000..c34ee5357f5 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1130Date20220110154719.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1130Date20220110154719 extends SimpleMigrationStep { + public function getName() { + return 'Drop ldap_group_mapping_backup'; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('ldap_group_mapping_backup')) { + $schema->dropTable('ldap_group_mapping_backup'); + return $schema; + } + + return null; + } +} diff --git a/apps/user_ldap/lib/Migration/Version1141Date20220323143801.php b/apps/user_ldap/lib/Migration/Version1141Date20220323143801.php new file mode 100644 index 00000000000..ecedbf1de20 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1141Date20220323143801.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1141Date20220323143801 extends SimpleMigrationStep { + + public function __construct( + private IDBConnection $dbc, + ) { + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $qb = $this->dbc->getQueryBuilder(); + $qb->select('ldap_dn') + ->from($tableName) + ->where($qb->expr()->gt($qb->func()->octetLength('ldap_dn'), $qb->createNamedParameter('4000'), IQueryBuilder::PARAM_INT)); + + $dnsTooLong = []; + $result = $qb->executeQuery(); + while (($dn = $result->fetchOne()) !== false) { + $dnsTooLong[] = $dn; + } + $result->closeCursor(); + $this->shortenDNs($dnsTooLong, $tableName); + } + } + + protected function shortenDNs(array $dns, string $table): void { + $qb = $this->dbc->getQueryBuilder(); + $qb->update($table) + ->set('ldap_dn', $qb->createParameter('shortenedDn')) + ->where($qb->expr()->eq('ldap_dn', $qb->createParameter('originalDn'))); + + $pageSize = 1000; + $page = 0; + do { + $subset = array_slice($dns, $page * $pageSize, $pageSize); + try { + $this->dbc->beginTransaction(); + foreach ($subset as $dn) { + $shortenedDN = mb_substr($dn, 0, 4000); + $qb->setParameter('shortenedDn', $shortenedDN); + $qb->setParameter('originalDn', $dn); + $qb->executeStatement(); + } + $this->dbc->commit(); + } catch (\Throwable $t) { + $this->dbc->rollBack(); + throw $t; + } + $page++; + } while (count($subset) === $pageSize); + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $table = $schema->getTable($tableName); + $column = $table->getColumn('ldap_dn'); + if ($column->getLength() > 4000) { + $column->setLength(4000); + } + } + + return $schema; + } +} diff --git a/apps/user_ldap/lib/Migration/Version1190Date20230706134108.php b/apps/user_ldap/lib/Migration/Version1190Date20230706134108.php new file mode 100644 index 00000000000..85b046ab7c9 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1190Date20230706134108.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1190Date20230706134108 extends SimpleMigrationStep { + public function __construct( + private IDBConnection $dbc, + ) { + } + + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_membership')) { + $table = $schema->createTable('ldap_group_membership'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('groupid', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('userid', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['groupid', 'userid'], 'user_ldap_membership_unique'); + return $schema; + } else { + return null; + } + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('ldap_group_members')) { + // Old table does not exist + return; + } + + $output->startProgress(); + $this->copyGroupMembershipData(); + $output->finishProgress(); + } + + protected function copyGroupMembershipData(): void { + $insert = $this->dbc->getQueryBuilder(); + $insert->insert('ldap_group_membership') + ->values([ + 'userid' => $insert->createParameter('userid'), + 'groupid' => $insert->createParameter('groupid'), + ]); + + $query = $this->dbc->getQueryBuilder(); + $query->select('*') + ->from('ldap_group_members'); + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $knownUsers = unserialize($row['owncloudusers']); + if (!is_array($knownUsers)) { + /* Unserialize failed or data was incorrect in database, ignore */ + continue; + } + $knownUsers = array_unique($knownUsers); + foreach ($knownUsers as $knownUser) { + try { + $insert + ->setParameter('groupid', $row['owncloudname']) + ->setParameter('userid', $knownUser) + ; + + $insert->executeStatement(); + } catch (\OCP\DB\Exception $e) { + /* + * If it fails on unique constaint violation it may just be left over value from previous half-migration + * If it fails on something else, ignore as well, data will be filled by background job later anyway + */ + } + } + } + $result->closeCursor(); + } +} diff --git a/apps/user_ldap/lib/Migration/Version1190Date20230706134109.php b/apps/user_ldap/lib/Migration/Version1190Date20230706134109.php new file mode 100644 index 00000000000..2d3c26f0d49 --- /dev/null +++ b/apps/user_ldap/lib/Migration/Version1190Date20230706134109.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1190Date20230706134109 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('ldap_group_members')) { + $schema->dropTable('ldap_group_members'); + return $schema; + } + + return null; + } +} diff --git a/apps/user_ldap/lib/Notification/Notifier.php b/apps/user_ldap/lib/Notification/Notifier.php new file mode 100644 index 00000000000..0195cb9e65b --- /dev/null +++ b/apps/user_ldap/lib/Notification/Notifier.php @@ -0,0 +1,82 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Notification; + +use OCP\L10N\IFactory; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +class Notifier implements INotifier { + + /** + * @param IFactory $l10nFactory + */ + public function __construct( + protected IFactory $l10nFactory, + ) { + } + + /** + * Identifier of the notifier, only use [a-z0-9_] + * + * @return string + * @since 17.0.0 + */ + public function getID(): string { + return 'user_ldap'; + } + + /** + * Human readable name describing the notifier + * + * @return string + * @since 17.0.0 + */ + public function getName(): string { + return $this->l10nFactory->get('user_ldap')->t('LDAP User backend'); + } + + /** + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + * @throws UnknownNotificationException When the notification was not prepared by a notifier + */ + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== 'user_ldap') { + // Not my app => throw + throw new UnknownNotificationException(); + } + + // Read the language from the notification + $l = $this->l10nFactory->get('user_ldap', $languageCode); + + switch ($notification->getSubject()) { + // Deal with known subjects + case 'pwd_exp_warn_days': + $params = $notification->getSubjectParameters(); + $days = (int)$params[0]; + if ($days === 2) { + $notification->setParsedSubject($l->t('Your password will expire tomorrow.')); + } elseif ($days === 1) { + $notification->setParsedSubject($l->t('Your password will expire today.')); + } else { + $notification->setParsedSubject($l->n( + 'Your password will expire within %n day.', + 'Your password will expire within %n days.', + $days + )); + } + return $notification; + + default: + // Unknown subject => Unknown notification => throw + throw new UnknownNotificationException(); + } + } +} diff --git a/apps/user_ldap/lib/PagedResults/TLinkId.php b/apps/user_ldap/lib/PagedResults/TLinkId.php new file mode 100644 index 00000000000..46d392995e0 --- /dev/null +++ b/apps/user_ldap/lib/PagedResults/TLinkId.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\PagedResults; + +trait TLinkId { + public function getLinkId($link) { + if (is_object($link)) { + return spl_object_id($link); + } elseif (is_resource($link)) { + return (int)$link; + } elseif (is_array($link) && isset($link[0])) { + if (is_object($link[0])) { + return spl_object_id($link[0]); + } elseif (is_resource($link[0])) { + return (int)$link[0]; + } + } + throw new \RuntimeException('No resource provided'); + } +} diff --git a/apps/user_ldap/lib/Proxy.php b/apps/user_ldap/lib/Proxy.php new file mode 100644 index 00000000000..22b2c6617af --- /dev/null +++ b/apps/user_ldap/lib/Proxy.php @@ -0,0 +1,207 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\Server; + +/** + * @template T + */ +abstract class Proxy { + /** @var array<string,Access> */ + private static array $accesses = []; + private ?bool $isSingleBackend = null; + private ?ICache $cache = null; + + /** @var T[] */ + protected array $backends = []; + /** @var ?T */ + protected $refBackend = null; + + protected bool $isSetUp = false; + + public function __construct( + private Helper $helper, + private ILDAPWrapper $ldap, + private AccessFactory $accessFactory, + ) { + $memcache = Server::get(ICacheFactory::class); + if ($memcache->isAvailable()) { + $this->cache = $memcache->createDistributed(); + } + } + + protected function setup(): void { + if ($this->isSetUp) { + return; + } + + $serverConfigPrefixes = $this->helper->getServerConfigurationPrefixes(true); + foreach ($serverConfigPrefixes as $configPrefix) { + $this->backends[$configPrefix] = $this->newInstance($configPrefix); + + if (is_null($this->refBackend)) { + $this->refBackend = $this->backends[$configPrefix]; + } + } + + $this->isSetUp = true; + } + + /** + * @return T + */ + abstract protected function newInstance(string $configPrefix): object; + + /** + * @return T + */ + public function getBackend(string $configPrefix): object { + $this->setup(); + return $this->backends[$configPrefix]; + } + + private function addAccess(string $configPrefix): void { + $userMap = Server::get(UserMapping::class); + $groupMap = Server::get(GroupMapping::class); + + $connector = new Connection($this->ldap, $configPrefix); + $access = $this->accessFactory->get($connector); + $access->setUserMapper($userMap); + $access->setGroupMapper($groupMap); + self::$accesses[$configPrefix] = $access; + } + + protected function getAccess(string $configPrefix): Access { + if (!isset(self::$accesses[$configPrefix])) { + $this->addAccess($configPrefix); + } + return self::$accesses[$configPrefix]; + } + + /** + * @param string $uid + * @return string + */ + protected function getUserCacheKey($uid) { + return 'user-' . $uid . '-lastSeenOn'; + } + + /** + * @param string $gid + * @return string + */ + protected function getGroupCacheKey($gid) { + return 'group-' . $gid . '-lastSeenOn'; + } + + /** + * @param string $id + * @param string $method + * @param array $parameters + * @param bool $passOnWhen + * @return mixed + */ + abstract protected function callOnLastSeenOn($id, $method, $parameters, $passOnWhen); + + /** + * @param string $id + * @param string $method + * @param array $parameters + * @return mixed + */ + abstract protected function walkBackends($id, $method, $parameters); + + /** + * @param string $id + * @return Access + */ + abstract public function getLDAPAccess($id); + + abstract protected function activeBackends(): int; + + protected function isSingleBackend(): bool { + if ($this->isSingleBackend === null) { + $this->isSingleBackend = $this->activeBackends() === 1; + } + return $this->isSingleBackend; + } + + /** + * Takes care of the request to the User backend + * + * @param string $id + * @param string $method string, the method of the user backend that shall be called + * @param array $parameters an array of parameters to be passed + * @param bool $passOnWhen + * @return mixed the result of the specified method + */ + protected function handleRequest($id, $method, $parameters, $passOnWhen = false) { + if (!$this->isSingleBackend()) { + $result = $this->callOnLastSeenOn($id, $method, $parameters, $passOnWhen); + } + if (!isset($result) || $result === $passOnWhen) { + $result = $this->walkBackends($id, $method, $parameters); + } + return $result; + } + + /** + * @param string|null $key + * @return string + */ + private function getCacheKey($key) { + $prefix = 'LDAP-Proxy-'; + if ($key === null) { + return $prefix; + } + return $prefix . hash('sha256', $key); + } + + /** + * @param string $key + * @return mixed|null + */ + public function getFromCache($key) { + if ($this->cache === null) { + return null; + } + + $key = $this->getCacheKey($key); + $value = $this->cache->get($key); + if ($value === null) { + return null; + } + + return json_decode(base64_decode($value)); + } + + /** + * @param string $key + * @param mixed $value + */ + public function writeToCache($key, $value) { + if ($this->cache === null) { + return; + } + $key = $this->getCacheKey($key); + $value = base64_encode(json_encode($value)); + $this->cache->set($key, $value, 2592000); + } + + public function clearCache() { + if ($this->cache === null) { + return; + } + $this->cache->clear($this->getCacheKey(null)); + } +} diff --git a/apps/user_ldap/lib/Service/BirthdateParserService.php b/apps/user_ldap/lib/Service/BirthdateParserService.php new file mode 100644 index 00000000000..8234161b3d8 --- /dev/null +++ b/apps/user_ldap/lib/Service/BirthdateParserService.php @@ -0,0 +1,44 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Service; + +use DateTimeImmutable; +use Exception; +use InvalidArgumentException; + +class BirthdateParserService { + /** + * Try to parse the birthdate from LDAP. + * Supports LDAP's generalized time syntax, YYYYMMDD and YYYY-MM-DD. + * + * @throws InvalidArgumentException If the format of then given date is unknown + */ + public function parseBirthdate(string $value): DateTimeImmutable { + // Minimum LDAP generalized date is "1994121610Z" with 11 chars + // While maximum other format is "1994-12-16" with 10 chars + if (strlen($value) > strlen('YYYY-MM-DD')) { + // Probably LDAP generalized time syntax + $value = substr($value, 0, 8); + } + + // Should be either YYYYMMDD or YYYY-MM-DD + if (!preg_match('/^(\d{8}|\d{4}-\d{2}-\d{2})$/', $value)) { + throw new InvalidArgumentException("Unknown date format: $value"); + } + + try { + return new DateTimeImmutable($value); + } catch (Exception $e) { + throw new InvalidArgumentException( + "Unknown date format: $value", + 0, + $e, + ); + } + } +} diff --git a/apps/user_ldap/lib/Service/UpdateGroupsService.php b/apps/user_ldap/lib/Service/UpdateGroupsService.php new file mode 100644 index 00000000000..94f2a7fd4a1 --- /dev/null +++ b/apps/user_ldap/lib/Service/UpdateGroupsService.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\User_LDAP\Service; + +use OCA\User_LDAP\Db\GroupMembership; +use OCA\User_LDAP\Db\GroupMembershipMapper; +use OCA\User_LDAP\Group_Proxy; +use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Group\Events\UserAddedEvent; +use OCP\Group\Events\UserRemovedEvent; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class UpdateGroupsService { + public function __construct( + private Group_Proxy $groupBackend, + private IEventDispatcher $dispatcher, + private IGroupManager $groupManager, + private IUserManager $userManager, + private LoggerInterface $logger, + private GroupMembershipMapper $groupMembershipMapper, + ) { + } + + /** + * @throws Exception + */ + public function updateGroups(): void { + $knownGroups = $this->groupMembershipMapper->getKnownGroups(); + $actualGroups = $this->groupBackend->getGroups(); + + if (empty($actualGroups) && empty($knownGroups)) { + $this->logger->info( + 'service "updateGroups" - groups do not seem to be configured properly, aborting.', + ); + return; + } + + $this->handleKnownGroups(array_intersect($actualGroups, $knownGroups)); + $this->handleCreatedGroups(array_diff($actualGroups, $knownGroups)); + $this->handleRemovedGroups(array_diff($knownGroups, $actualGroups)); + + $this->logger->debug('service "updateGroups" - Finished.'); + } + + /** + * @param string[] $groups + * @throws Exception + */ + public function handleKnownGroups(array $groups): void { + $this->logger->debug('service "updateGroups" - Dealing with known Groups.'); + + foreach ($groups as $group) { + $this->logger->debug('service "updateGroups" - Dealing with {group}.', ['group' => $group]); + $groupMemberships = $this->groupMembershipMapper->findGroupMemberships($group); + $knownUsers = array_map( + static fn (GroupMembership $groupMembership): string => $groupMembership->getUserid(), + $groupMemberships + ); + $groupMemberships = array_combine($knownUsers, $groupMemberships); + $actualUsers = $this->groupBackend->usersInGroup($group); + + $groupObject = $this->groupManager->get($group); + if ($groupObject === null) { + /* We are not expecting the group to not be found since it was returned by $this->groupBackend->getGroups() */ + $this->logger->error( + 'service "updateGroups" - Failed to get group {group} for update', + [ + 'group' => $group + ] + ); + continue; + } + foreach (array_diff($knownUsers, $actualUsers) as $removedUser) { + try { + $this->groupMembershipMapper->delete($groupMemberships[$removedUser]); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_DATABASE_OBJECT_NOT_FOUND) { + /* If reason is not found something else removed the membership, that’s fine */ + $this->logger->error( + self::class . ' - group {group} membership failed to be removed (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $removedUser, + 'group' => $group, + 'exception' => $e, + ] + ); + } + /* We failed to delete the groupmembership so we do not want to advertise it */ + continue; + } + $userObject = $this->userManager->get($removedUser); + if ($userObject instanceof IUser) { + $this->dispatcher->dispatchTyped(new UserRemovedEvent($groupObject, $userObject)); + } + $this->logger->info( + 'service "updateGroups" - {user} removed from {group}', + [ + 'user' => $removedUser, + 'group' => $group + ] + ); + } + foreach (array_diff($actualUsers, $knownUsers) as $addedUser) { + try { + $this->groupMembershipMapper->insert(GroupMembership::fromParams(['groupid' => $group,'userid' => $addedUser])); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + /* If reason is unique constraint something else added the membership, that’s fine */ + $this->logger->error( + self::class . ' - group {group} membership failed to be added (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $addedUser, + 'group' => $group, + 'exception' => $e, + ] + ); + } + /* We failed to insert the groupmembership so we do not want to advertise it */ + continue; + } + $userObject = $this->userManager->get($addedUser); + if ($userObject instanceof IUser) { + $this->dispatcher->dispatchTyped(new UserAddedEvent($groupObject, $userObject)); + } + $this->logger->info( + 'service "updateGroups" - {user} added to {group}', + [ + 'user' => $addedUser, + 'group' => $group + ] + ); + } + } + $this->logger->debug('service "updateGroups" - FINISHED dealing with known Groups.'); + } + + /** + * @param string[] $createdGroups + * @throws Exception + */ + public function handleCreatedGroups(array $createdGroups): void { + $this->logger->debug('service "updateGroups" - dealing with created Groups.'); + + foreach ($createdGroups as $createdGroup) { + $this->logger->info('service "updateGroups" - new group "' . $createdGroup . '" found.'); + + $users = $this->groupBackend->usersInGroup($createdGroup); + $groupObject = $this->groupManager->get($createdGroup); + foreach ($users as $user) { + try { + $this->groupMembershipMapper->insert(GroupMembership::fromParams(['groupid' => $createdGroup,'userid' => $user])); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $this->logger->error( + self::class . ' - group {group} membership failed to be added (user {user})', + [ + 'app' => 'user_ldap', + 'user' => $user, + 'group' => $createdGroup, + 'exception' => $e, + ] + ); + } + /* We failed to insert the groupmembership so we do not want to advertise it */ + continue; + } + if ($groupObject instanceof IGroup) { + $userObject = $this->userManager->get($user); + if ($userObject instanceof IUser) { + $this->dispatcher->dispatchTyped(new UserAddedEvent($groupObject, $userObject)); + } + } + } + } + $this->logger->debug('service "updateGroups" - FINISHED dealing with created Groups.'); + } + + /** + * @param string[] $removedGroups + * @throws Exception + */ + public function handleRemovedGroups(array $removedGroups): void { + $this->logger->debug('service "updateGroups" - dealing with removed groups.'); + + $this->groupMembershipMapper->deleteGroups($removedGroups); + foreach ($removedGroups as $group) { + $groupObject = $this->groupManager->get($group); + if ($groupObject instanceof IGroup) { + $groupMemberships = $this->groupMembershipMapper->findGroupMemberships($group); + foreach ($groupMemberships as $groupMembership) { + $userObject = $this->userManager->get($groupMembership->getUserid()); + if ($userObject instanceof IUser) { + $this->dispatcher->dispatchTyped(new UserRemovedEvent($groupObject, $userObject)); + } + } + } + } + + $this->logger->info( + 'service "updateGroups" - groups {removedGroups} were removed.', + [ + 'removedGroups' => $removedGroups + ] + ); + } +} diff --git a/apps/user_ldap/lib/Settings/Admin.php b/apps/user_ldap/lib/Settings/Admin.php new file mode 100644 index 00000000000..89fb063265b --- /dev/null +++ b/apps/user_ldap/lib/Settings/Admin.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Settings; + +use OCA\User_LDAP\Configuration; +use OCA\User_LDAP\Helper; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IL10N; +use OCP\Server; +use OCP\Settings\IDelegatedSettings; +use OCP\Template\ITemplateManager; + +class Admin implements IDelegatedSettings { + public function __construct( + private IL10N $l, + private ITemplateManager $templateManager, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $helper = Server::get(Helper::class); + $prefixes = $helper->getServerConfigurationPrefixes(); + if (count($prefixes) === 0) { + $newPrefix = $helper->getNextServerConfigurationPrefix(); + $config = new Configuration($newPrefix, false); + $config->setConfiguration($config->getDefaults()); + $config->saveConfiguration(); + $prefixes[] = $newPrefix; + } + + $hosts = $helper->getServerConfigurationHosts(); + + $wControls = $this->templateManager->getTemplate('user_ldap', 'part.wizardcontrols'); + $wControls = $wControls->fetchPage(); + $sControls = $this->templateManager->getTemplate('user_ldap', 'part.settingcontrols'); + $sControls = $sControls->fetchPage(); + + $parameters = []; + $parameters['serverConfigurationPrefixes'] = $prefixes; + $parameters['serverConfigurationHosts'] = $hosts; + $parameters['settingControls'] = $sControls; + $parameters['wizardControls'] = $wControls; + + // assign default values + if (!isset($config)) { + $config = new Configuration('', false); + } + $defaults = $config->getDefaults(); + foreach ($defaults as $key => $default) { + $parameters[$key . '_default'] = $default; + } + + return new TemplateResponse('user_ldap', 'settings', $parameters); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'ldap'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 5; + } + + public function getName(): ?string { + return null; // Only one setting in this section + } + + public function getAuthorizedAppConfig(): array { + return []; // Custom controller + } +} diff --git a/apps/user_ldap/lib/Settings/Section.php b/apps/user_ldap/lib/Settings/Section.php new file mode 100644 index 00000000000..3b95e25513d --- /dev/null +++ b/apps/user_ldap/lib/Settings/Section.php @@ -0,0 +1,61 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP\Settings; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class Section implements IIconSection { + /** + * @param IURLGenerator $url + * @param IL10N $l + */ + public function __construct( + private IURLGenerator $url, + private IL10N $l, + ) { + } + + /** + * returns the ID of the section. It is supposed to be a lower case string, + * e.g. 'ldap' + * + * @returns string + */ + public function getID() { + return 'ldap'; + } + + /** + * returns the translated name as it should be displayed, e.g. 'LDAP/AD + * integration'. Use the L10N service to translate it. + * + * @return string + */ + public function getName() { + return $this->l->t('LDAP/AD integration'); + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the settings navigation. The sections are arranged in ascending order of + * the priority values. It is required to return a value between 0 and 99. + * + * E.g.: 70 + */ + public function getPriority() { + return 25; + } + + /** + * {@inheritdoc} + */ + public function getIcon() { + return $this->url->imagePath('user_ldap', 'app-dark.svg'); + } +} diff --git a/apps/user_ldap/lib/SetupChecks/LdapConnection.php b/apps/user_ldap/lib/SetupChecks/LdapConnection.php new file mode 100644 index 00000000000..ee8c4ddd595 --- /dev/null +++ b/apps/user_ldap/lib/SetupChecks/LdapConnection.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\SetupChecks; + +use OCA\User_LDAP\AccessFactory; +use OCA\User_LDAP\ConnectionFactory; +use OCA\User_LDAP\Helper; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class LdapConnection implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private Helper $helper, + private ConnectionFactory $connectionFactory, + private AccessFactory $accessFactory, + ) { + } + + public function getCategory(): string { + return 'ldap'; + } + + public function getName(): string { + return $this->l10n->t('LDAP Connection'); + } + + public function run(): SetupResult { + $availableConfigs = $this->helper->getServerConfigurationPrefixes(); + $inactiveConfigurations = []; + $bindFailedConfigurations = []; + $searchFailedConfigurations = []; + foreach ($availableConfigs as $configID) { + $connection = $this->connectionFactory->get($configID); + if (!$connection->ldapConfigurationActive) { + $inactiveConfigurations[] = $configID; + continue; + } + if (!$connection->bind()) { + $bindFailedConfigurations[] = $configID; + continue; + } + $access = $this->accessFactory->get($connection); + $result = $access->countObjects(1); + if (!is_int($result) || ($result <= 0)) { + $searchFailedConfigurations[] = $configID; + } + } + $output = ''; + if (!empty($bindFailedConfigurations)) { + $output .= $this->l10n->n( + 'Binding failed for this LDAP configuration: %s', + 'Binding failed for %n LDAP configurations: %s', + count($bindFailedConfigurations), + [implode(',', $bindFailedConfigurations)] + ) . "\n"; + } + if (!empty($searchFailedConfigurations)) { + $output .= $this->l10n->n( + 'Searching failed for this LDAP configuration: %s', + 'Searching failed for %n LDAP configurations: %s', + count($searchFailedConfigurations), + [implode(',', $searchFailedConfigurations)] + ) . "\n"; + } + if (!empty($inactiveConfigurations)) { + $output .= $this->l10n->n( + 'There is an inactive LDAP configuration: %s', + 'There are %n inactive LDAP configurations: %s', + count($inactiveConfigurations), + [implode(',', $inactiveConfigurations)] + ) . "\n"; + } + if (!empty($bindFailedConfigurations) || !empty($searchFailedConfigurations)) { + return SetupResult::error($output); + } elseif (!empty($inactiveConfigurations)) { + return SetupResult::warning($output); + } + return SetupResult::success($this->l10n->n( + 'Binding and searching works on the configured LDAP connection (%s)', + 'Binding and searching works on all of the %n configured LDAP connections (%s)', + count($availableConfigs), + [implode(',', $availableConfigs)] + )); + } +} diff --git a/apps/user_ldap/lib/SetupChecks/LdapInvalidUuids.php b/apps/user_ldap/lib/SetupChecks/LdapInvalidUuids.php new file mode 100644 index 00000000000..ac502b6b59e --- /dev/null +++ b/apps/user_ldap/lib/SetupChecks/LdapInvalidUuids.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\SetupChecks; + +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class LdapInvalidUuids implements ISetupCheck { + public function __construct( + private IL10N $l10n, + private UserMapping $userMapping, + private GroupMapping $groupMapping, + ) { + } + + public function getCategory(): string { + return 'ldap'; + } + + public function getName(): string { + return $this->l10n->t('Invalid LDAP UUIDs'); + } + + public function run(): SetupResult { + if (count($this->userMapping->getList(0, 1, true)) === 0 + && count($this->groupMapping->getList(0, 1, true)) === 0) { + return SetupResult::success($this->l10n->t('None found')); + } else { + return SetupResult::warning($this->l10n->t('Invalid UUIDs of LDAP accounts or groups have been found. Please review your "Override UUID detection" settings in the Expert part of the LDAP configuration and use "occ ldap:update-uuid" to update them.')); + } + } +} diff --git a/apps/user_ldap/lib/User/DeletedUsersIndex.php b/apps/user_ldap/lib/User/DeletedUsersIndex.php new file mode 100644 index 00000000000..f57f71a9d47 --- /dev/null +++ b/apps/user_ldap/lib/User/DeletedUsersIndex.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\User; + +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\IConfig; +use OCP\PreConditionNotMetException; +use OCP\Share\IManager; + +/** + * Class DeletedUsersIndex + * @package OCA\User_LDAP + */ +class DeletedUsersIndex { + protected ?array $deletedUsers = null; + + public function __construct( + protected IConfig $config, + protected UserMapping $mapping, + private IManager $shareManager, + ) { + } + + /** + * reads LDAP users marked as deleted from the database + * @return OfflineUser[] + */ + private function fetchDeletedUsers(): array { + $deletedUsers = $this->config->getUsersForUserValue('user_ldap', 'isDeleted', '1'); + + $userObjects = []; + foreach ($deletedUsers as $user) { + $userObject = new OfflineUser($user, $this->config, $this->mapping, $this->shareManager); + if ($userObject->getLastLogin() > $userObject->getDetectedOn()) { + $userObject->unmark(); + } else { + $userObjects[] = $userObject; + } + } + $this->deletedUsers = $userObjects; + + return $this->deletedUsers; + } + + /** + * returns all LDAP users that are marked as deleted + * @return OfflineUser[] + */ + public function getUsers(): array { + if (is_array($this->deletedUsers)) { + return $this->deletedUsers; + } + return $this->fetchDeletedUsers(); + } + + /** + * whether at least one user was detected as deleted + */ + public function hasUsers(): bool { + if (!is_array($this->deletedUsers)) { + $this->fetchDeletedUsers(); + } + return is_array($this->deletedUsers) && (count($this->deletedUsers) > 0); + } + + /** + * marks a user as deleted + * + * @throws PreConditionNotMetException + */ + public function markUser(string $ocName): void { + if ($this->isUserMarked($ocName)) { + // the user is already marked, do not write to DB again + return; + } + $this->config->setUserValue($ocName, 'user_ldap', 'isDeleted', '1'); + $this->config->setUserValue($ocName, 'user_ldap', 'foundDeleted', (string)time()); + $this->deletedUsers = null; + } + + public function isUserMarked(string $ocName): bool { + return ($this->config->getUserValue($ocName, 'user_ldap', 'isDeleted', '0') === '1'); + } +} diff --git a/apps/user_ldap/lib/User/Manager.php b/apps/user_ldap/lib/User/Manager.php new file mode 100644 index 00000000000..88a001dd965 --- /dev/null +++ b/apps/user_ldap/lib/User/Manager.php @@ -0,0 +1,258 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\User; + +use OCA\User_LDAP\Access; +use OCP\Cache\CappedMemoryCache; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Image; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\Share\IManager; +use Psr\Log\LoggerInterface; + +/** + * Manager + * + * upon request, returns an LDAP user object either by creating or from run-time + * cache + */ +class Manager { + protected ?Access $access = null; + protected IDBConnection $db; + /** @var CappedMemoryCache<User> $usersByDN */ + protected CappedMemoryCache $usersByDN; + /** @var CappedMemoryCache<User> $usersByUid */ + protected CappedMemoryCache $usersByUid; + + public function __construct( + protected IConfig $ocConfig, + protected LoggerInterface $logger, + protected IAvatarManager $avatarManager, + protected Image $image, + protected IUserManager $userManager, + protected INotificationManager $notificationManager, + private IManager $shareManager, + ) { + $this->usersByDN = new CappedMemoryCache(); + $this->usersByUid = new CappedMemoryCache(); + } + + /** + * Binds manager to an instance of Access. + * It needs to be assigned first before the manager can be used. + * @param Access + */ + public function setLdapAccess(Access $access) { + $this->access = $access; + } + + /** + * @brief creates an instance of User and caches (just runtime) it in the + * property array + * @param string $dn the DN of the user + * @param string $uid the internal (owncloud) username + * @return User + */ + private function createAndCache($dn, $uid) { + $this->checkAccess(); + $user = new User($uid, $dn, $this->access, $this->ocConfig, + clone $this->image, $this->logger, + $this->avatarManager, $this->userManager, + $this->notificationManager); + $this->usersByDN[$dn] = $user; + $this->usersByUid[$uid] = $user; + return $user; + } + + /** + * removes a user entry from the cache + * @param $uid + */ + public function invalidate($uid) { + if (!isset($this->usersByUid[$uid])) { + return; + } + $dn = $this->usersByUid[$uid]->getDN(); + unset($this->usersByUid[$uid]); + unset($this->usersByDN[$dn]); + } + + /** + * @brief checks whether the Access instance has been set + * @throws \Exception if Access has not been set + * @psalm-assert !null $this->access + * @return null + */ + private function checkAccess() { + if (is_null($this->access)) { + throw new \Exception('LDAP Access instance must be set first'); + } + } + + /** + * returns a list of attributes that will be processed further, e.g. quota, + * email, displayname, or others. + * + * @param bool $minimal - optional, set to true to skip attributes with big + * payload + * @return string[] + */ + public function getAttributes($minimal = false) { + $baseAttributes = array_merge(Access::UUID_ATTRIBUTES, ['dn', 'uid', 'samaccountname', 'memberof']); + $attributes = [ + $this->access->getConnection()->ldapExpertUUIDUserAttr, + $this->access->getConnection()->ldapExpertUsernameAttr, + $this->access->getConnection()->ldapQuotaAttribute, + $this->access->getConnection()->ldapEmailAttribute, + $this->access->getConnection()->ldapUserDisplayName, + $this->access->getConnection()->ldapUserDisplayName2, + $this->access->getConnection()->ldapExtStorageHomeAttribute, + $this->access->getConnection()->ldapAttributePhone, + $this->access->getConnection()->ldapAttributeWebsite, + $this->access->getConnection()->ldapAttributeAddress, + $this->access->getConnection()->ldapAttributeTwitter, + $this->access->getConnection()->ldapAttributeFediverse, + $this->access->getConnection()->ldapAttributeOrganisation, + $this->access->getConnection()->ldapAttributeRole, + $this->access->getConnection()->ldapAttributeHeadline, + $this->access->getConnection()->ldapAttributeBiography, + $this->access->getConnection()->ldapAttributeBirthDate, + $this->access->getConnection()->ldapAttributePronouns, + ]; + + $homeRule = (string)$this->access->getConnection()->homeFolderNamingRule; + if (str_starts_with($homeRule, 'attr:')) { + $attributes[] = substr($homeRule, strlen('attr:')); + } + + if (!$minimal) { + // attributes that are not really important but may come with big + // payload. + $attributes = array_merge( + $attributes, + $this->access->getConnection()->resolveRule('avatar') + ); + } + + $attributes = array_reduce($attributes, + function ($list, $attribute) { + $attribute = strtolower(trim((string)$attribute)); + if (!empty($attribute) && !in_array($attribute, $list)) { + $list[] = $attribute; + } + + return $list; + }, + $baseAttributes // hard-coded, lower-case, non-empty attributes + ); + + return $attributes; + } + + /** + * Checks whether the specified user is marked as deleted + * @param string $id the Nextcloud user name + * @return bool + */ + public function isDeletedUser($id) { + $isDeleted = $this->ocConfig->getUserValue( + $id, 'user_ldap', 'isDeleted', 0); + return (int)$isDeleted === 1; + } + + /** + * creates and returns an instance of OfflineUser for the specified user + * @param string $id + * @return OfflineUser + */ + public function getDeletedUser($id) { + return new OfflineUser( + $id, + $this->ocConfig, + $this->access->getUserMapper(), + $this->shareManager + ); + } + + /** + * @brief returns a User object by its Nextcloud username + * @param string $id the DN or username of the user + * @return User|OfflineUser|null + */ + protected function createInstancyByUserName($id) { + //most likely a uid. Check whether it is a deleted user + if ($this->isDeletedUser($id)) { + return $this->getDeletedUser($id); + } + $dn = $this->access->username2dn($id); + if ($dn !== false) { + return $this->createAndCache($dn, $id); + } + return null; + } + + /** + * @brief returns a User object by its DN or Nextcloud username + * @param string $id the DN or username of the user + * @return User|OfflineUser|null + * @throws \Exception when connection could not be established + */ + public function get($id) { + $this->checkAccess(); + if (isset($this->usersByDN[$id])) { + return $this->usersByDN[$id]; + } elseif (isset($this->usersByUid[$id])) { + return $this->usersByUid[$id]; + } + + if ($this->access->stringResemblesDN($id)) { + $uid = $this->access->dn2username($id); + if ($uid !== false) { + return $this->createAndCache($id, $uid); + } + } + + return $this->createInstancyByUserName($id); + } + + /** + * @brief Checks whether a User object by its DN or Nextcloud username exists + * @param string $id the DN or username of the user + * @throws \Exception when connection could not be established + */ + public function exists($id): bool { + $this->checkAccess(); + $this->logger->debug('Checking if {id} exists', ['id' => $id]); + if (isset($this->usersByDN[$id])) { + return true; + } elseif (isset($this->usersByUid[$id])) { + return true; + } + + if ($this->access->stringResemblesDN($id)) { + $this->logger->debug('{id} looks like a dn', ['id' => $id]); + $uid = $this->access->dn2username($id); + if ($uid !== false) { + return true; + } + } + + // Most likely a uid. Check whether it is a deleted user + if ($this->isDeletedUser($id)) { + return true; + } + $dn = $this->access->username2dn($id); + if ($dn !== false) { + return true; + } + return false; + } +} diff --git a/apps/user_ldap/lib/user/offlineuser.php b/apps/user_ldap/lib/User/OfflineUser.php index aee1a137a96..ecaab7188ba 100644 --- a/apps/user_ldap/lib/user/offlineuser.php +++ b/apps/user_ldap/lib/User/OfflineUser.php @@ -1,36 +1,20 @@ <?php + /** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - -namespace OCA\user_ldap\lib\user; +namespace OCA\User_LDAP\User; use OCA\User_LDAP\Mapping\UserMapping; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Share\IManager; +use OCP\Share\IShare; class OfflineUser { /** - * @var string $ocName - */ - protected $ocName; - /** * @var string $dn */ protected $dn; @@ -51,6 +35,11 @@ class OfflineUser { */ protected $lastLogin; /** + * @var string $foundDeleted the timestamp when the user was detected as unavailable + */ + protected $foundDeleted; + protected ?string $extStorageHome = null; + /** * @var string $email */ protected $email; @@ -59,37 +48,27 @@ class OfflineUser { */ protected $hasActiveShares; /** - * @var \OCP\IConfig $config - */ - protected $config; - /** - * @var \OCP\IDBConnection $db + * @var IDBConnection $db */ protected $db; - /** - * @var \OCA\User_LDAP\Mapping\UserMapping - */ - protected $mapping; /** * @param string $ocName - * @param \OCP\IConfig $config - * @param \OCP\IDBConnection $db - * @param \OCA\User_LDAP\Mapping\UserMapping $mapping - */ - public function __construct($ocName, \OCP\IConfig $config, \OCP\IDBConnection $db, UserMapping $mapping) { - $this->ocName = $ocName; - $this->config = $config; - $this->db = $db; - $this->mapping = $mapping; - $this->fetchDetails(); + */ + public function __construct( + protected $ocName, + protected IConfig $config, + protected UserMapping $mapping, + private IManager $shareManager, + ) { } /** * remove the Delete-flag from the user. */ public function unmark() { - $this->config->setUserValue($this->ocName, 'user_ldap', 'isDeleted', '0'); + $this->config->deleteUserValue($this->ocName, 'user_ldap', 'isDeleted'); + $this->config->deleteUserValue($this->ocName, 'user_ldap', 'foundDeleted'); } /** @@ -97,7 +76,7 @@ class OfflineUser { * @return array */ public function export() { - $data = array(); + $data = []; $data['ocName'] = $this->getOCName(); $data['dn'] = $this->getDN(); $data['uid'] = $this->getUID(); @@ -111,7 +90,7 @@ class OfflineUser { } /** - * getter for ownCloud internal name + * getter for Nextcloud internal name * @return string */ public function getOCName() { @@ -123,6 +102,9 @@ class OfflineUser { * @return string */ public function getUID() { + if ($this->uid === null) { + $this->fetchDetails(); + } return $this->uid; } @@ -131,6 +113,10 @@ class OfflineUser { * @return string */ public function getDN() { + if ($this->dn === null) { + $dn = $this->mapping->getDNByName($this->ocName); + $this->dn = ($dn !== false) ? $dn : ''; + } return $this->dn; } @@ -139,6 +125,9 @@ class OfflineUser { * @return string */ public function getDisplayName() { + if ($this->displayName === null) { + $this->fetchDetails(); + } return $this->displayName; } @@ -147,6 +136,9 @@ class OfflineUser { * @return string */ public function getEmail() { + if ($this->email === null) { + $this->fetchDetails(); + } return $this->email; } @@ -155,6 +147,9 @@ class OfflineUser { * @return string */ public function getHomePath() { + if ($this->homePath === null) { + $this->fetchDetails(); + } return $this->homePath; } @@ -163,7 +158,28 @@ class OfflineUser { * @return int */ public function getLastLogin() { - return intval($this->lastLogin); + if ($this->lastLogin === null) { + $this->fetchDetails(); + } + return (int)$this->lastLogin; + } + + /** + * getter for the detection timestamp + * @return int + */ + public function getDetectedOn() { + if ($this->foundDeleted === null) { + $this->fetchDetails(); + } + return (int)$this->foundDeleted; + } + + public function getExtStorageHome(): string { + if ($this->extStorageHome === null) { + $this->fetchDetails(); + } + return (string)$this->extStorageHome; } /** @@ -171,6 +187,9 @@ class OfflineUser { * @return bool */ public function getHasActiveShares() { + if ($this->hasActiveShares === null) { + $this->determineShares(); + } return $this->hasActiveShares; } @@ -178,51 +197,45 @@ class OfflineUser { * reads the user details */ protected function fetchDetails() { - $properties = array ( + $properties = [ 'displayName' => 'user_ldap', - 'uid' => 'user_ldap', - 'homePath' => 'user_ldap', - 'email' => 'settings', - 'lastLogin' => 'login' - ); - foreach($properties as $property => $app) { + 'uid' => 'user_ldap', + 'homePath' => 'user_ldap', + 'foundDeleted' => 'user_ldap', + 'extStorageHome' => 'user_ldap', + 'email' => 'settings', + 'lastLogin' => 'login', + ]; + foreach ($properties as $property => $app) { $this->$property = $this->config->getUserValue($this->ocName, $app, $property, ''); } - - $dn = $this->mapping->getDNByName($this->ocName); - $this->dn = ($dn !== false) ? $dn : ''; - - $this->determineShares(); } - /** * finds out whether the user has active shares. The result is stored in * $this->hasActiveShares */ protected function determineShares() { - $query = $this->db->prepare(' - SELECT COUNT(`uid_owner`) - FROM `*PREFIX*share` - WHERE `uid_owner` = ? - ', 1); - $query->execute(array($this->ocName)); - $sResult = $query->fetchColumn(0); - if(intval($sResult) === 1) { - $this->hasActiveShares = true; - return; - } - - $query = $this->db->prepare(' - SELECT COUNT(`owner`) - FROM `*PREFIX*share_external` - WHERE `owner` = ? - ', 1); - $query->execute(array($this->ocName)); - $sResult = $query->fetchColumn(0); - if(intval($sResult) === 1) { - $this->hasActiveShares = true; - return; + $shareInterface = new \ReflectionClass(IShare::class); + $shareConstants = $shareInterface->getConstants(); + + foreach ($shareConstants as $constantName => $constantValue) { + if (!str_starts_with($constantName, 'TYPE_') + || $constantValue === IShare::TYPE_USERGROUP + ) { + continue; + } + $shares = $this->shareManager->getSharesBy( + $this->ocName, + $constantValue, + null, + false, + 1 + ); + if (!empty($shares)) { + $this->hasActiveShares = true; + return; + } } $this->hasActiveShares = false; diff --git a/apps/user_ldap/lib/User/User.php b/apps/user_ldap/lib/User/User.php new file mode 100644 index 00000000000..8f97ec1701f --- /dev/null +++ b/apps/user_ldap/lib/User/User.php @@ -0,0 +1,824 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP\User; + +use InvalidArgumentException; +use OC\Accounts\AccountManager; +use OCA\User_LDAP\Access; +use OCA\User_LDAP\Connection; +use OCA\User_LDAP\Exceptions\AttributeNotSet; +use OCA\User_LDAP\Service\BirthdateParserService; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\Image; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\PreConditionNotMetException; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** + * User + * + * represents an LDAP user, gets and holds user-specific information from LDAP + */ +class User { + protected Connection $connection; + /** + * @var array<string,1> + */ + protected array $refreshedFeatures = []; + protected string|false|null $avatarImage = null; + + protected BirthdateParserService $birthdateParser; + + /** + * DB config keys for user preferences + * @var string + */ + public const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished'; + + /** + * @brief constructor, make sure the subclasses call this one! + */ + public function __construct( + protected string $uid, + protected string $dn, + protected Access $access, + protected IConfig $config, + protected Image $image, + protected LoggerInterface $logger, + protected IAvatarManager $avatarManager, + protected IUserManager $userManager, + protected INotificationManager $notificationManager, + ) { + if ($uid === '') { + $logger->error("uid for '$dn' must not be an empty string", ['app' => 'user_ldap']); + throw new \InvalidArgumentException('uid must not be an empty string!'); + } + $this->connection = $this->access->getConnection(); + $this->birthdateParser = new BirthdateParserService(); + + Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry'); + } + + /** + * marks a user as deleted + * + * @throws PreConditionNotMetException + */ + public function markUser(): void { + $curValue = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '0'); + if ($curValue === '1') { + // the user is already marked, do not write to DB again + return; + } + $this->config->setUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '1'); + $this->config->setUserValue($this->getUsername(), 'user_ldap', 'foundDeleted', (string)time()); + } + + /** + * processes results from LDAP for attributes as returned by getAttributesToRead() + * @param array $ldapEntry the user entry as retrieved from LDAP + */ + public function processAttributes(array $ldapEntry): void { + //Quota + $attr = strtolower($this->connection->ldapQuotaAttribute); + if (isset($ldapEntry[$attr])) { + $this->updateQuota($ldapEntry[$attr][0]); + } else { + if ($this->connection->ldapQuotaDefault !== '') { + $this->updateQuota(); + } + } + unset($attr); + + //displayName + $displayName = $displayName2 = ''; + $attr = strtolower($this->connection->ldapUserDisplayName); + if (isset($ldapEntry[$attr])) { + $displayName = (string)$ldapEntry[$attr][0]; + } + $attr = strtolower($this->connection->ldapUserDisplayName2); + if (isset($ldapEntry[$attr])) { + $displayName2 = (string)$ldapEntry[$attr][0]; + } + if ($displayName !== '') { + $this->composeAndStoreDisplayName($displayName, $displayName2); + $this->access->cacheUserDisplayName( + $this->getUsername(), + $displayName, + $displayName2 + ); + } + unset($attr); + + //Email + //email must be stored after displayname, because it would cause a user + //change event that will trigger fetching the display name again + $attr = strtolower($this->connection->ldapEmailAttribute); + if (isset($ldapEntry[$attr])) { + $mailValue = 0; + for ($x = 0; $x < count($ldapEntry[$attr]); $x++) { + if (filter_var($ldapEntry[$attr][$x], FILTER_VALIDATE_EMAIL)) { + $mailValue = $x; + break; + } + } + $this->updateEmail($ldapEntry[$attr][$mailValue]); + } + unset($attr); + + // LDAP Username, needed for s2s sharing + if (isset($ldapEntry['uid'])) { + $this->storeLDAPUserName($ldapEntry['uid'][0]); + } elseif (isset($ldapEntry['samaccountname'])) { + $this->storeLDAPUserName($ldapEntry['samaccountname'][0]); + } + + //homePath + if (str_starts_with($this->connection->homeFolderNamingRule, 'attr:')) { + $attr = strtolower(substr($this->connection->homeFolderNamingRule, strlen('attr:'))); + if (isset($ldapEntry[$attr])) { + $this->access->cacheUserHome( + $this->getUsername(), $this->getHomePath($ldapEntry[$attr][0])); + } + } + + //memberOf groups + $cacheKey = 'getMemberOf' . $this->getUsername(); + $groups = false; + if (isset($ldapEntry['memberof'])) { + $groups = $ldapEntry['memberof']; + } + $this->connection->writeToCache($cacheKey, $groups); + + //external storage var + $attr = strtolower($this->connection->ldapExtStorageHomeAttribute); + if (isset($ldapEntry[$attr])) { + $this->updateExtStorageHome($ldapEntry[$attr][0]); + } + unset($attr); + + // check for cached profile data + $username = $this->getUsername(); // buffer variable, to save resource + $cacheKey = 'getUserProfile-' . $username; + $profileCached = $this->connection->getFromCache($cacheKey); + // honoring profile disabled in config.php and check if user profile was refreshed + if ($this->config->getSystemValueBool('profile.enabled', true) + && ($profileCached === null) // no cache or TTL not expired + && !$this->wasRefreshed('profile')) { + // check current data + $profileValues = []; + //User Profile Field - Phone number + $attr = strtolower($this->connection->ldapAttributePhone); + if (!empty($attr)) { // attribute configured + $profileValues[IAccountManager::PROPERTY_PHONE] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - website + $attr = strtolower($this->connection->ldapAttributeWebsite); + if (isset($ldapEntry[$attr])) { + $cutPosition = strpos($ldapEntry[$attr][0], ' '); + if ($cutPosition) { + // drop appended label + $profileValues[IAccountManager::PROPERTY_WEBSITE] + = substr($ldapEntry[$attr][0], 0, $cutPosition); + } else { + $profileValues[IAccountManager::PROPERTY_WEBSITE] + = $ldapEntry[$attr][0]; + } + } elseif (!empty($attr)) { // configured, but not defined + $profileValues[IAccountManager::PROPERTY_WEBSITE] = ''; + } + //User Profile Field - Address + $attr = strtolower($this->connection->ldapAttributeAddress); + if (isset($ldapEntry[$attr])) { + if (str_contains($ldapEntry[$attr][0], '$')) { + // basic format conversion from postalAddress syntax to commata delimited + $profileValues[IAccountManager::PROPERTY_ADDRESS] + = str_replace('$', ', ', $ldapEntry[$attr][0]); + } else { + $profileValues[IAccountManager::PROPERTY_ADDRESS] + = $ldapEntry[$attr][0]; + } + } elseif (!empty($attr)) { // configured, but not defined + $profileValues[IAccountManager::PROPERTY_ADDRESS] = ''; + } + //User Profile Field - Twitter + $attr = strtolower($this->connection->ldapAttributeTwitter); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_TWITTER] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - fediverse + $attr = strtolower($this->connection->ldapAttributeFediverse); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_FEDIVERSE] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - organisation + $attr = strtolower($this->connection->ldapAttributeOrganisation); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_ORGANISATION] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - role + $attr = strtolower($this->connection->ldapAttributeRole); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_ROLE] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - headline + $attr = strtolower($this->connection->ldapAttributeHeadline); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_HEADLINE] + = $ldapEntry[$attr][0] ?? ''; + } + //User Profile Field - biography + $attr = strtolower($this->connection->ldapAttributeBiography); + if (isset($ldapEntry[$attr])) { + if (str_contains($ldapEntry[$attr][0], '\r')) { + // convert line endings + $profileValues[IAccountManager::PROPERTY_BIOGRAPHY] + = str_replace(["\r\n","\r"], "\n", $ldapEntry[$attr][0]); + } else { + $profileValues[IAccountManager::PROPERTY_BIOGRAPHY] + = $ldapEntry[$attr][0]; + } + } elseif (!empty($attr)) { // configured, but not defined + $profileValues[IAccountManager::PROPERTY_BIOGRAPHY] = ''; + } + //User Profile Field - birthday + $attr = strtolower($this->connection->ldapAttributeBirthDate); + if (!empty($attr) && !empty($ldapEntry[$attr][0])) { + $value = $ldapEntry[$attr][0]; + try { + $birthdate = $this->birthdateParser->parseBirthdate($value); + $profileValues[IAccountManager::PROPERTY_BIRTHDATE] + = $birthdate->format('Y-m-d'); + } catch (InvalidArgumentException $e) { + // Invalid date -> just skip the property + $this->logger->info("Failed to parse user's birthdate from LDAP: $value", [ + 'exception' => $e, + 'userId' => $username, + ]); + } + } + //User Profile Field - pronouns + $attr = strtolower($this->connection->ldapAttributePronouns); + if (!empty($attr)) { + $profileValues[IAccountManager::PROPERTY_PRONOUNS] + = $ldapEntry[$attr][0] ?? ''; + } + // check for changed data and cache just for TTL checking + $checksum = hash('sha256', json_encode($profileValues)); + $this->connection->writeToCache($cacheKey, $checksum // write array to cache. is waste of cache space + , null); // use ldapCacheTTL from configuration + // Update user profile + if ($this->config->getUserValue($username, 'user_ldap', 'lastProfileChecksum', null) !== $checksum) { + $this->config->setUserValue($username, 'user_ldap', 'lastProfileChecksum', $checksum); + $this->updateProfile($profileValues); + $this->logger->info("updated profile uid=$username", ['app' => 'user_ldap']); + } else { + $this->logger->debug('profile data from LDAP unchanged', ['app' => 'user_ldap', 'uid' => $username]); + } + unset($attr); + } elseif ($profileCached !== null) { // message delayed, to declutter log + $this->logger->debug('skipping profile check, while cached data exist', ['app' => 'user_ldap', 'uid' => $username]); + } + + //Avatar + /** @var Connection $connection */ + $connection = $this->access->getConnection(); + $attributes = $connection->resolveRule('avatar'); + foreach ($attributes as $attribute) { + if (isset($ldapEntry[$attribute])) { + $this->avatarImage = $ldapEntry[$attribute][0]; + $this->updateAvatar(); + break; + } + } + } + + /** + * @brief returns the LDAP DN of the user + * @return string + */ + public function getDN() { + return $this->dn; + } + + /** + * @brief returns the Nextcloud internal username of the user + * @return string + */ + public function getUsername() { + return $this->uid; + } + + /** + * returns the home directory of the user if specified by LDAP settings + * @throws \Exception + */ + public function getHomePath(?string $valueFromLDAP = null): string|false { + $path = (string)$valueFromLDAP; + $attr = null; + + if (is_null($valueFromLDAP) + && str_starts_with($this->access->connection->homeFolderNamingRule, 'attr:') + && $this->access->connection->homeFolderNamingRule !== 'attr:') { + $attr = substr($this->access->connection->homeFolderNamingRule, strlen('attr:')); + $dn = $this->access->username2dn($this->getUsername()); + if ($dn === false) { + return false; + } + $homedir = $this->access->readAttribute($dn, $attr); + if ($homedir !== false && isset($homedir[0])) { + $path = $homedir[0]; + } + } + + if ($path !== '') { + //if attribute's value is an absolute path take this, otherwise append it to data dir + //check for / at the beginning or pattern c:\ resp. c:/ + if ($path[0] !== '/' + && !(strlen($path) > 3 && ctype_alpha($path[0]) + && $path[1] === ':' && ($path[2] === '\\' || $path[2] === '/')) + ) { + $path = $this->config->getSystemValue('datadirectory', + \OC::$SERVERROOT . '/data') . '/' . $path; + } + //we need it to store it in the DB as well in case a user gets + //deleted so we can clean up afterwards + $this->config->setUserValue( + $this->getUsername(), 'user_ldap', 'homePath', $path + ); + return $path; + } + + if (!is_null($attr) + && $this->config->getAppValue('user_ldap', 'enforce_home_folder_naming_rule', 'true') + ) { + // a naming rule attribute is defined, but it doesn't exist for that LDAP user + throw new \Exception('Home dir attribute can\'t be read from LDAP for uid: ' . $this->getUsername()); + } + + //false will apply default behaviour as defined and done by OC_User + $this->config->setUserValue($this->getUsername(), 'user_ldap', 'homePath', ''); + return false; + } + + public function getMemberOfGroups(): array|false { + $cacheKey = 'getMemberOf' . $this->getUsername(); + $memberOfGroups = $this->connection->getFromCache($cacheKey); + if (!is_null($memberOfGroups)) { + return $memberOfGroups; + } + $groupDNs = $this->access->readAttribute($this->getDN(), 'memberOf'); + $this->connection->writeToCache($cacheKey, $groupDNs); + return $groupDNs; + } + + /** + * @brief reads the image from LDAP that shall be used as Avatar + * @return string|false data (provided by LDAP) + */ + public function getAvatarImage(): string|false { + if (!is_null($this->avatarImage)) { + return $this->avatarImage; + } + + $this->avatarImage = false; + /** @var Connection $connection */ + $connection = $this->access->getConnection(); + $attributes = $connection->resolveRule('avatar'); + foreach ($attributes as $attribute) { + $result = $this->access->readAttribute($this->dn, $attribute); + if ($result !== false && isset($result[0])) { + $this->avatarImage = $result[0]; + break; + } + } + + return $this->avatarImage; + } + + /** + * @brief marks the user as having logged in at least once + */ + public function markLogin(): void { + $this->config->setUserValue( + $this->uid, 'user_ldap', self::USER_PREFKEY_FIRSTLOGIN, '1'); + } + + /** + * Stores a key-value pair in relation to this user + */ + private function store(string $key, string $value): void { + $this->config->setUserValue($this->uid, 'user_ldap', $key, $value); + } + + /** + * Composes the display name and stores it in the database. The final + * display name is returned. + * + * @return string the effective display name + */ + public function composeAndStoreDisplayName(string $displayName, string $displayName2 = ''): string { + if ($displayName2 !== '') { + $displayName .= ' (' . $displayName2 . ')'; + } + $oldName = $this->config->getUserValue($this->uid, 'user_ldap', 'displayName', null); + if ($oldName !== $displayName) { + $this->store('displayName', $displayName); + $user = $this->userManager->get($this->getUsername()); + if (!empty($oldName) && $user instanceof \OC\User\User) { + // if it was empty, it would be a new record, not a change emitting the trigger could + // potentially cause a UniqueConstraintViolationException, depending on some factors. + $user->triggerChange('displayName', $displayName, $oldName); + } + } + return $displayName; + } + + /** + * Stores the LDAP Username in the Database + */ + public function storeLDAPUserName(string $userName): void { + $this->store('uid', $userName); + } + + /** + * @brief checks whether an update method specified by feature was run + * already. If not, it will marked like this, because it is expected that + * the method will be run, when false is returned. + * @param string $feature email | quota | avatar | profile (can be extended) + */ + private function wasRefreshed(string $feature): bool { + if (isset($this->refreshedFeatures[$feature])) { + return true; + } + $this->refreshedFeatures[$feature] = 1; + return false; + } + + /** + * fetches the email from LDAP and stores it as Nextcloud user value + * @param ?string $valueFromLDAP if known, to save an LDAP read request + */ + public function updateEmail(?string $valueFromLDAP = null): void { + if ($this->wasRefreshed('email')) { + return; + } + $email = (string)$valueFromLDAP; + if (is_null($valueFromLDAP)) { + $emailAttribute = $this->connection->ldapEmailAttribute; + if ($emailAttribute !== '') { + $aEmail = $this->access->readAttribute($this->dn, $emailAttribute); + if (is_array($aEmail) && (count($aEmail) > 0)) { + $email = (string)$aEmail[0]; + } + } + } + if ($email !== '') { + $user = $this->userManager->get($this->uid); + if (!is_null($user)) { + $currentEmail = (string)$user->getSystemEMailAddress(); + if ($currentEmail !== $email) { + $user->setSystemEMailAddress($email); + } + } + } + } + + /** + * Overall process goes as follow: + * 1. fetch the quota from LDAP and check if it's parseable with the "verifyQuotaValue" function + * 2. if the value can't be fetched, is empty or not parseable, use the default LDAP quota + * 3. if the default LDAP quota can't be parsed, use the Nextcloud's default quota (use 'default') + * 4. check if the target user exists and set the quota for the user. + * + * In order to improve performance and prevent an unwanted extra LDAP call, the $valueFromLDAP + * parameter can be passed with the value of the attribute. This value will be considered as the + * quota for the user coming from the LDAP server (step 1 of the process) It can be useful to + * fetch all the user's attributes in one call and use the fetched values in this function. + * The expected value for that parameter is a string describing the quota for the user. Valid + * values are 'none' (unlimited), 'default' (the Nextcloud's default quota), '1234' (quota in + * bytes), '1234 MB' (quota in MB - check the \OCP\Util::computerFileSize method for more info) + * + * fetches the quota from LDAP and stores it as Nextcloud user value + * @param ?string $valueFromLDAP the quota attribute's value can be passed, + * to save the readAttribute request + */ + public function updateQuota(?string $valueFromLDAP = null): void { + if ($this->wasRefreshed('quota')) { + return; + } + + $quotaAttribute = $this->connection->ldapQuotaAttribute; + $defaultQuota = $this->connection->ldapQuotaDefault; + if ($quotaAttribute === '' && $defaultQuota === '') { + return; + } + + $quota = false; + if (is_null($valueFromLDAP) && $quotaAttribute !== '') { + $aQuota = $this->access->readAttribute($this->dn, $quotaAttribute); + if ($aQuota !== false && isset($aQuota[0]) && $this->verifyQuotaValue($aQuota[0])) { + $quota = $aQuota[0]; + } elseif (is_array($aQuota) && isset($aQuota[0])) { + $this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . $aQuota[0] . ']', ['app' => 'user_ldap']); + } + } elseif (!is_null($valueFromLDAP) && $this->verifyQuotaValue($valueFromLDAP)) { + $quota = $valueFromLDAP; + } else { + $this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . ($valueFromLDAP ?? '') . ']', ['app' => 'user_ldap']); + } + + if ($quota === false && $this->verifyQuotaValue($defaultQuota)) { + // quota not found using the LDAP attribute (or not parseable). Try the default quota + $quota = $defaultQuota; + } elseif ($quota === false) { + $this->logger->debug('no suitable default quota found for user ' . $this->uid . ': [' . $defaultQuota . ']', ['app' => 'user_ldap']); + return; + } + + $targetUser = $this->userManager->get($this->uid); + if ($targetUser instanceof IUser) { + $targetUser->setQuota($quota); + } else { + $this->logger->info('trying to set a quota for user ' . $this->uid . ' but the user is missing', ['app' => 'user_ldap']); + } + } + + private function verifyQuotaValue(string $quotaValue): bool { + return $quotaValue === 'none' || $quotaValue === 'default' || Util::computerFileSize($quotaValue) !== false; + } + + /** + * takes values from LDAP and stores it as Nextcloud user profile value + * + * @param array $profileValues associative array of property keys and values from LDAP + */ + private function updateProfile(array $profileValues): void { + // check if given array is empty + if (empty($profileValues)) { + return; // okay, nothing to do + } + // fetch/prepare user + $user = $this->userManager->get($this->uid); + if (is_null($user)) { + $this->logger->error('could not get user for uid=' . $this->uid . '', ['app' => 'user_ldap']); + return; + } + // prepare AccountManager and Account + $accountManager = Server::get(IAccountManager::class); + $account = $accountManager->getAccount($user); // get Account + $defaultScopes = array_merge(AccountManager::DEFAULT_SCOPES, + $this->config->getSystemValue('account_manager.default_property_scope', [])); + // loop through the properties and handle them + foreach ($profileValues as $property => $valueFromLDAP) { + // check and update profile properties + $value = (is_array($valueFromLDAP) ? $valueFromLDAP[0] : $valueFromLDAP); // take ONLY the first value, if multiple values specified + try { + $accountProperty = $account->getProperty($property); + $currentValue = $accountProperty->getValue(); + $scope = ($accountProperty->getScope() ?: $defaultScopes[$property]); + } catch (PropertyDoesNotExistException $e) { // thrown at getProperty + $this->logger->error('property does not exist: ' . $property + . ' for uid=' . $this->uid . '', ['app' => 'user_ldap', 'exception' => $e]); + $currentValue = ''; + $scope = $defaultScopes[$property]; + } + $verified = IAccountManager::VERIFIED; // trust the LDAP admin knew what they put there + if ($currentValue !== $value) { + $account->setProperty($property, $value, $scope, $verified); + $this->logger->debug('update user profile: ' . $property . '=' . $value + . ' for uid=' . $this->uid . '', ['app' => 'user_ldap']); + } + } + try { + $accountManager->updateAccount($account); // may throw InvalidArgumentException + } catch (\InvalidArgumentException $e) { + $this->logger->error('invalid data from LDAP: for uid=' . $this->uid . '', ['app' => 'user_ldap', 'func' => 'updateProfile' + , 'exception' => $e]); + } + } + + /** + * @brief attempts to get an image from LDAP and sets it as Nextcloud avatar + * @return bool true when the avatar was set successfully or is up to date + */ + public function updateAvatar(bool $force = false): bool { + if (!$force && $this->wasRefreshed('avatar')) { + return false; + } + $avatarImage = $this->getAvatarImage(); + if ($avatarImage === false) { + //not set, nothing left to do; + return false; + } + + if (!$this->image->loadFromBase64(base64_encode($avatarImage))) { + return false; + } + + // use the checksum before modifications + $checksum = md5($this->image->data()); + + if ($checksum === $this->config->getUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', '') && $this->avatarExists()) { + return true; + } + + $isSet = $this->setNextcloudAvatar(); + + if ($isSet) { + // save checksum only after successful setting + $this->config->setUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', $checksum); + } + + return $isSet; + } + + private function avatarExists(): bool { + try { + $currentAvatar = $this->avatarManager->getAvatar($this->uid); + return $currentAvatar->exists() && $currentAvatar->isCustomAvatar(); + } catch (\Exception $e) { + return false; + } + } + + /** + * @brief sets an image as Nextcloud avatar + */ + private function setNextcloudAvatar(): bool { + if (!$this->image->valid()) { + $this->logger->error('avatar image data from LDAP invalid for ' . $this->dn, ['app' => 'user_ldap']); + return false; + } + + + //make sure it is a square and not bigger than 512x512 + $size = min([$this->image->width(), $this->image->height(), 512]); + if (!$this->image->centerCrop($size)) { + $this->logger->error('croping image for avatar failed for ' . $this->dn, ['app' => 'user_ldap']); + return false; + } + + try { + $avatar = $this->avatarManager->getAvatar($this->uid); + $avatar->set($this->image); + return true; + } catch (\Exception $e) { + $this->logger->info('Could not set avatar for ' . $this->dn, ['exception' => $e]); + } + return false; + } + + /** + * @throws AttributeNotSet + * @throws \OC\ServerNotAvailableException + * @throws PreConditionNotMetException + */ + public function getExtStorageHome():string { + $value = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', ''); + if ($value !== '') { + return $value; + } + + $value = $this->updateExtStorageHome(); + if ($value !== '') { + return $value; + } + + throw new AttributeNotSet(sprintf( + 'external home storage attribute yield no value for %s', $this->getUsername() + )); + } + + /** + * @throws PreConditionNotMetException + * @throws \OC\ServerNotAvailableException + */ + public function updateExtStorageHome(?string $valueFromLDAP = null):string { + if ($valueFromLDAP === null) { + $extHomeValues = $this->access->readAttribute($this->getDN(), $this->connection->ldapExtStorageHomeAttribute); + } else { + $extHomeValues = [$valueFromLDAP]; + } + if ($extHomeValues !== false && isset($extHomeValues[0])) { + $extHome = $extHomeValues[0]; + $this->config->setUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', $extHome); + return $extHome; + } else { + $this->config->deleteUserValue($this->getUsername(), 'user_ldap', 'extStorageHome'); + return ''; + } + } + + /** + * called by a post_login hook to handle password expiry + */ + public function handlePasswordExpiry(array $params): void { + $ppolicyDN = $this->connection->ldapDefaultPPolicyDN; + if (empty($ppolicyDN) || ((int)$this->connection->turnOnPasswordChange !== 1)) { + //password expiry handling disabled + return; + } + $uid = $params['uid']; + if (isset($uid) && $uid === $this->getUsername()) { + //retrieve relevant user attributes + $result = $this->access->search('objectclass=*', $this->dn, ['pwdpolicysubentry', 'pwdgraceusetime', 'pwdreset', 'pwdchangedtime']); + + if (!empty($result)) { + if (array_key_exists('pwdpolicysubentry', $result[0])) { + $pwdPolicySubentry = $result[0]['pwdpolicysubentry']; + if ($pwdPolicySubentry && (count($pwdPolicySubentry) > 0)) { + $ppolicyDN = $pwdPolicySubentry[0];//custom ppolicy DN + } + } + + $pwdGraceUseTime = array_key_exists('pwdgraceusetime', $result[0]) ? $result[0]['pwdgraceusetime'] : []; + $pwdReset = array_key_exists('pwdreset', $result[0]) ? $result[0]['pwdreset'] : []; + $pwdChangedTime = array_key_exists('pwdchangedtime', $result[0]) ? $result[0]['pwdchangedtime'] : []; + } + + //retrieve relevant password policy attributes + $cacheKey = 'ppolicyAttributes' . $ppolicyDN; + $result = $this->connection->getFromCache($cacheKey); + if (is_null($result)) { + $result = $this->access->search('objectclass=*', $ppolicyDN, ['pwdgraceauthnlimit', 'pwdmaxage', 'pwdexpirewarning']); + $this->connection->writeToCache($cacheKey, $result); + } + + $pwdGraceAuthNLimit = array_key_exists('pwdgraceauthnlimit', $result[0]) ? $result[0]['pwdgraceauthnlimit'] : []; + $pwdMaxAge = array_key_exists('pwdmaxage', $result[0]) ? $result[0]['pwdmaxage'] : []; + $pwdExpireWarning = array_key_exists('pwdexpirewarning', $result[0]) ? $result[0]['pwdexpirewarning'] : []; + + //handle grace login + if (!empty($pwdGraceUseTime)) { //was this a grace login? + if (!empty($pwdGraceAuthNLimit) + && count($pwdGraceUseTime) < (int)$pwdGraceAuthNLimit[0]) { //at least one more grace login available? + $this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true'); + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid])); + } else { //no more grace login available + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showLoginFormInvalidPassword', ['user' => $uid])); + } + exit(); + } + //handle pwdReset attribute + if (!empty($pwdReset) && $pwdReset[0] === 'TRUE') { //user must change their password + $this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true'); + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid])); + exit(); + } + //handle password expiry warning + if (!empty($pwdChangedTime)) { + if (!empty($pwdMaxAge) + && !empty($pwdExpireWarning)) { + $pwdMaxAgeInt = (int)$pwdMaxAge[0]; + $pwdExpireWarningInt = (int)$pwdExpireWarning[0]; + if ($pwdMaxAgeInt > 0 && $pwdExpireWarningInt > 0) { + $pwdChangedTimeDt = \DateTime::createFromFormat('YmdHisZ', $pwdChangedTime[0]); + $pwdChangedTimeDt->add(new \DateInterval('PT' . $pwdMaxAgeInt . 'S')); + $currentDateTime = new \DateTime(); + $secondsToExpiry = $pwdChangedTimeDt->getTimestamp() - $currentDateTime->getTimestamp(); + if ($secondsToExpiry <= $pwdExpireWarningInt) { + //remove last password expiry warning if any + $notification = $this->notificationManager->createNotification(); + $notification->setApp('user_ldap') + ->setUser($uid) + ->setObject('pwd_exp_warn', $uid) + ; + $this->notificationManager->markProcessed($notification); + //create new password expiry warning + $notification = $this->notificationManager->createNotification(); + $notification->setApp('user_ldap') + ->setUser($uid) + ->setDateTime($currentDateTime) + ->setObject('pwd_exp_warn', $uid) + ->setSubject('pwd_exp_warn_days', [(int)ceil($secondsToExpiry / 60 / 60 / 24)]) + ; + $this->notificationManager->notify($notification); + } + } + } + } + } + } +} diff --git a/apps/user_ldap/lib/UserPluginManager.php b/apps/user_ldap/lib/UserPluginManager.php new file mode 100644 index 00000000000..ed87fea6fde --- /dev/null +++ b/apps/user_ldap/lib/UserPluginManager.php @@ -0,0 +1,206 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\User_LDAP; + +use OC\User\Backend; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class UserPluginManager { + private int $respondToActions = 0; + + private array $which = [ + Backend::CREATE_USER => null, + Backend::SET_PASSWORD => null, + Backend::GET_HOME => null, + Backend::GET_DISPLAYNAME => null, + Backend::SET_DISPLAYNAME => null, + Backend::PROVIDE_AVATAR => null, + Backend::COUNT_USERS => null, + 'deleteUser' => null + ]; + + private bool $suppressDeletion = false; + + /** + * @return int All implemented actions, except for 'deleteUser' + */ + public function getImplementedActions() { + return $this->respondToActions; + } + + /** + * Registers a user plugin that may implement some actions, overriding User_LDAP's user actions. + * + * @param ILDAPUserPlugin $plugin + */ + public function register(ILDAPUserPlugin $plugin) { + $respondToActions = $plugin->respondToActions(); + $this->respondToActions |= $respondToActions; + + foreach ($this->which as $action => $v) { + if (is_int($action) && (bool)($respondToActions & $action)) { + $this->which[$action] = $plugin; + Server::get(LoggerInterface::class)->debug('Registered action ' . $action . ' to plugin ' . get_class($plugin), ['app' => 'user_ldap']); + } + } + if (method_exists($plugin, 'deleteUser')) { + $this->which['deleteUser'] = $plugin; + Server::get(LoggerInterface::class)->debug('Registered action deleteUser to plugin ' . get_class($plugin), ['app' => 'user_ldap']); + } + } + + /** + * Signal if there is a registered plugin that implements some given actions + * @param int $actions Actions defined in \OC\User\Backend, like Backend::CREATE_USER + * @return bool + */ + public function implementsActions($actions) { + return ($actions & $this->respondToActions) == $actions; + } + + /** + * Create a new user in LDAP Backend + * + * @param string $username The username of the user to create + * @param string $password The password of the new user + * @return string | false The user DN if user creation was successful. + * @throws \Exception + */ + public function createUser($username, $password) { + $plugin = $this->which[Backend::CREATE_USER]; + + if ($plugin) { + return $plugin->createUser($username, $password); + } + throw new \Exception('No plugin implements createUser in this LDAP Backend.'); + } + + /** + * Change the password of a user* + * @param string $uid The username + * @param string $password The new password + * @return bool + * @throws \Exception + */ + public function setPassword($uid, $password) { + $plugin = $this->which[Backend::SET_PASSWORD]; + + if ($plugin) { + return $plugin->setPassword($uid, $password); + } + throw new \Exception('No plugin implements setPassword in this LDAP Backend.'); + } + + /** + * checks whether the user is allowed to change their avatar in Nextcloud + * @param string $uid the Nextcloud user name + * @return boolean either the user can or cannot + * @throws \Exception + */ + public function canChangeAvatar($uid) { + $plugin = $this->which[Backend::PROVIDE_AVATAR]; + + if ($plugin) { + return $plugin->canChangeAvatar($uid); + } + throw new \Exception('No plugin implements canChangeAvatar in this LDAP Backend.'); + } + + /** + * Get the user's home directory + * @param string $uid the username + * @return boolean + * @throws \Exception + */ + public function getHome($uid) { + $plugin = $this->which[Backend::GET_HOME]; + + if ($plugin) { + return $plugin->getHome($uid); + } + throw new \Exception('No plugin implements getHome in this LDAP Backend.'); + } + + /** + * Get display name of the user + * @param string $uid user ID of the user + * @return string display name + * @throws \Exception + */ + public function getDisplayName($uid) { + $plugin = $this->which[Backend::GET_DISPLAYNAME]; + + if ($plugin) { + return $plugin->getDisplayName($uid); + } + throw new \Exception('No plugin implements getDisplayName in this LDAP Backend.'); + } + + /** + * Set display name of the user + * @param string $uid user ID of the user + * @param string $displayName new user's display name + * @return string display name + * @throws \Exception + */ + public function setDisplayName($uid, $displayName) { + $plugin = $this->which[Backend::SET_DISPLAYNAME]; + + if ($plugin) { + return $plugin->setDisplayName($uid, $displayName); + } + throw new \Exception('No plugin implements setDisplayName in this LDAP Backend.'); + } + + /** + * Count the number of users + * @return int|false + * @throws \Exception + */ + public function countUsers() { + $plugin = $this->which[Backend::COUNT_USERS]; + + if ($plugin) { + return $plugin->countUsers(); + } + throw new \Exception('No plugin implements countUsers in this LDAP Backend.'); + } + + /** + * @return bool + */ + public function canDeleteUser() { + return !$this->suppressDeletion && $this->which['deleteUser'] !== null; + } + + /** + * @param $uid + * @return bool + * @throws \Exception + */ + public function deleteUser($uid) { + $plugin = $this->which['deleteUser']; + if ($plugin) { + if ($this->suppressDeletion) { + return false; + } + return $plugin->deleteUser($uid); + } + throw new \Exception('No plugin implements deleteUser in this LDAP Backend.'); + } + + /** + * @param bool $value + * @return bool – the value before the change + */ + public function setSuppressDeletion(bool $value): bool { + $old = $this->suppressDeletion; + $this->suppressDeletion = $value; + return $old; + } +} diff --git a/apps/user_ldap/lib/User_LDAP.php b/apps/user_ldap/lib/User_LDAP.php new file mode 100644 index 00000000000..c3f56f5ff9b --- /dev/null +++ b/apps/user_ldap/lib/User_LDAP.php @@ -0,0 +1,635 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OC\ServerNotAvailableException; +use OC\User\Backend; +use OC\User\NoUserException; +use OCA\User_LDAP\Exceptions\NotOnLDAP; +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User\OfflineUser; +use OCA\User_LDAP\User\User; +use OCP\IUserBackend; +use OCP\Notification\IManager as INotificationManager; +use OCP\User\Backend\ICountMappedUsersBackend; +use OCP\User\Backend\ILimitAwareCountUsersBackend; +use OCP\User\Backend\IProvideEnabledStateBackend; +use OCP\UserInterface; +use Psr\Log\LoggerInterface; + +class User_LDAP extends BackendUtility implements IUserBackend, UserInterface, IUserLDAP, ILimitAwareCountUsersBackend, ICountMappedUsersBackend, IProvideEnabledStateBackend { + public function __construct( + Access $access, + protected INotificationManager $notificationManager, + protected UserPluginManager $userPluginManager, + protected LoggerInterface $logger, + protected DeletedUsersIndex $deletedUsersIndex, + ) { + parent::__construct($access); + } + + /** + * checks whether the user is allowed to change their avatar in Nextcloud + * + * @param string $uid the Nextcloud user name + * @return boolean either the user can or cannot + * @throws \Exception + */ + public function canChangeAvatar($uid) { + if ($this->userPluginManager->implementsActions(Backend::PROVIDE_AVATAR)) { + return $this->userPluginManager->canChangeAvatar($uid); + } + + if (!$this->implementsActions(Backend::PROVIDE_AVATAR)) { + return true; + } + + $user = $this->access->userManager->get($uid); + if (!$user instanceof User) { + return false; + } + $imageData = $user->getAvatarImage(); + if ($imageData === false) { + return true; + } + return !$user->updateAvatar(true); + } + + /** + * Return the username for the given login name, if available + * + * @param string $loginName + * @return string|false + * @throws \Exception + */ + public function loginName2UserName($loginName, bool $forceLdapRefetch = false) { + $cacheKey = 'loginName2UserName-' . $loginName; + $username = $this->access->connection->getFromCache($cacheKey); + + $ignoreCache = ($username === false && $forceLdapRefetch); + if ($username !== null && !$ignoreCache) { + return $username; + } + + try { + $ldapRecord = $this->getLDAPUserByLoginName($loginName); + $user = $this->access->userManager->get($ldapRecord['dn'][0]); + if ($user === null || $user instanceof OfflineUser) { + // this path is not really possible, however get() is documented + // to return User, OfflineUser or null so we are very defensive here. + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + $username = $user->getUsername(); + $this->access->connection->writeToCache($cacheKey, $username); + if ($forceLdapRefetch) { + $user->processAttributes($ldapRecord); + } + return $username; + } catch (NotOnLDAP $e) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + } + + /** + * returns the username for the given LDAP DN, if available + * + * @param string $dn + * @return string|false with the username + */ + public function dn2UserName($dn) { + return $this->access->dn2username($dn); + } + + /** + * returns an LDAP record based on a given login name + * + * @param string $loginName + * @return array + * @throws NotOnLDAP + */ + public function getLDAPUserByLoginName($loginName) { + //find out dn of the user name + $attrs = $this->access->userManager->getAttributes(); + $users = $this->access->fetchUsersByLoginName($loginName, $attrs); + if (count($users) < 1) { + throw new NotOnLDAP('No user available for the given login name on ' + . $this->access->connection->ldapHost . ':' . $this->access->connection->ldapPort); + } + return $users[0]; + } + + /** + * Check if the password is correct without logging in the user + * + * @param string $uid The username + * @param string $password The password + * @return false|string + */ + public function checkPassword($uid, $password) { + $username = $this->loginName2UserName($uid, true); + if ($username === false) { + return false; + } + $dn = $this->access->username2dn($username); + $user = $this->access->userManager->get($dn); + + if (!$user instanceof User) { + $this->logger->warning( + 'LDAP Login: Could not get user object for DN ' . $dn + . '. Maybe the LDAP entry has no set display name attribute?', + ['app' => 'user_ldap'] + ); + return false; + } + if ($user->getUsername() !== false) { + //are the credentials OK? + if (!$this->access->areCredentialsValid($dn, $password)) { + return false; + } + + $this->access->cacheUserExists($user->getUsername()); + $user->markLogin(); + + return $user->getUsername(); + } + + return false; + } + + /** + * Set password + * @param string $uid The username + * @param string $password The new password + * @return bool + */ + public function setPassword($uid, $password) { + if ($this->userPluginManager->implementsActions(Backend::SET_PASSWORD)) { + return $this->userPluginManager->setPassword($uid, $password); + } + + $user = $this->access->userManager->get($uid); + + if (!$user instanceof User) { + throw new \Exception('LDAP setPassword: Could not get user object for uid ' . $uid + . '. Maybe the LDAP entry has no set display name attribute?'); + } + if ($user->getUsername() !== false && $this->access->setPassword($user->getDN(), $password)) { + $ldapDefaultPPolicyDN = $this->access->connection->ldapDefaultPPolicyDN; + $turnOnPasswordChange = $this->access->connection->turnOnPasswordChange; + if (!empty($ldapDefaultPPolicyDN) && ((int)$turnOnPasswordChange === 1)) { + //remove last password expiry warning if any + $notification = $this->notificationManager->createNotification(); + $notification->setApp('user_ldap') + ->setUser($uid) + ->setObject('pwd_exp_warn', $uid) + ; + $this->notificationManager->markProcessed($notification); + } + return true; + } + + return false; + } + + /** + * Get a list of all users + * + * @param string $search + * @param integer $limit + * @param integer $offset + * @return string[] an array of all uids + */ + public function getUsers($search = '', $limit = 10, $offset = 0) { + $search = $this->access->escapeFilterPart($search, true); + $cachekey = 'getUsers-' . $search . '-' . $limit . '-' . $offset; + + //check if users are cached, if so return + $ldap_users = $this->access->connection->getFromCache($cachekey); + if (!is_null($ldap_users)) { + return $ldap_users; + } + + // if we'd pass -1 to LDAP search, we'd end up in a Protocol + // error. With a limit of 0, we get 0 results. So we pass null. + if ($limit <= 0) { + $limit = null; + } + $filter = $this->access->combineFilterWithAnd([ + $this->access->connection->ldapUserFilter, + $this->access->connection->ldapUserDisplayName . '=*', + $this->access->getFilterPartForUserSearch($search) + ]); + + $this->logger->debug( + 'getUsers: Options: search ' . $search . ' limit ' . $limit . ' offset ' . $offset . ' Filter: ' . $filter, + ['app' => 'user_ldap'] + ); + //do the search and translate results to Nextcloud names + $ldap_users = $this->access->fetchListOfUsers( + $filter, + $this->access->userManager->getAttributes(true), + $limit, $offset); + $ldap_users = $this->access->nextcloudUserNames($ldap_users); + $this->logger->debug( + 'getUsers: ' . count($ldap_users) . ' Users found', + ['app' => 'user_ldap'] + ); + + $this->access->connection->writeToCache($cachekey, $ldap_users); + return $ldap_users; + } + + /** + * checks whether a user is still available on LDAP + * + * @param string|User $user either the Nextcloud user + * name or an instance of that user + * @throws \Exception + * @throws \OC\ServerNotAvailableException + */ + public function userExistsOnLDAP($user, bool $ignoreCache = false): bool { + if (is_string($user)) { + $user = $this->access->userManager->get($user); + } + if (is_null($user)) { + return false; + } + $uid = $user instanceof User ? $user->getUsername() : $user->getOCName(); + $cacheKey = 'userExistsOnLDAP' . $uid; + if (!$ignoreCache) { + $userExists = $this->access->connection->getFromCache($cacheKey); + if (!is_null($userExists)) { + return (bool)$userExists; + } + } + + $dn = $user->getDN(); + //check if user really still exists by reading its entry + if (!is_array($this->access->readAttribute($dn, '', $this->access->connection->ldapUserFilter))) { + try { + $uuid = $this->access->getUserMapper()->getUUIDByDN($dn); + if (!$uuid) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + $newDn = $this->access->getUserDnByUuid($uuid); + //check if renamed user is still valid by reapplying the ldap filter + if ($newDn === $dn || !is_array($this->access->readAttribute($newDn, '', $this->access->connection->ldapUserFilter))) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + $this->access->getUserMapper()->setDNbyUUID($newDn, $uuid); + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (\Exception $e) { + $this->access->connection->writeToCache($cacheKey, false); + return false; + } + } + + if ($user instanceof OfflineUser) { + $user->unmark(); + } + + $this->access->connection->writeToCache($cacheKey, true); + return true; + } + + /** + * check if a user exists + * @param string $uid the username + * @return boolean + * @throws \Exception when connection could not be established + */ + public function userExists($uid) { + $userExists = $this->access->connection->getFromCache('userExists' . $uid); + if (!is_null($userExists)) { + return (bool)$userExists; + } + $userExists = $this->access->userManager->exists($uid); + + if (!$userExists) { + $this->logger->debug( + 'No DN found for ' . $uid . ' on ' . $this->access->connection->ldapHost, + ['app' => 'user_ldap'] + ); + $this->access->connection->writeToCache('userExists' . $uid, false); + return false; + } + + $this->access->connection->writeToCache('userExists' . $uid, true); + return true; + } + + /** + * returns whether a user was deleted in LDAP + * + * @param string $uid The username of the user to delete + * @return bool + */ + public function deleteUser($uid) { + if ($this->userPluginManager->canDeleteUser()) { + $status = $this->userPluginManager->deleteUser($uid); + if ($status === false) { + return false; + } + } + + $marked = $this->deletedUsersIndex->isUserMarked($uid); + if (!$marked) { + try { + $user = $this->access->userManager->get($uid); + if (($user instanceof User) && !$this->userExistsOnLDAP($uid, true)) { + $user->markUser(); + $marked = true; + } + } catch (\Exception $e) { + $this->logger->debug( + $e->getMessage(), + ['app' => 'user_ldap', 'exception' => $e] + ); + } + if (!$marked) { + $this->logger->notice( + 'User ' . $uid . ' is not marked as deleted, not cleaning up.', + ['app' => 'user_ldap'] + ); + return false; + } + } + $this->logger->info('Cleaning up after user ' . $uid, + ['app' => 'user_ldap']); + + $this->access->getUserMapper()->unmap($uid); // we don't emit unassign signals here, since it is implicit to delete signals fired from core + $this->access->userManager->invalidate($uid); + $this->access->connection->clearCache(); + return true; + } + + /** + * get the user's home directory + * + * @param string $uid the username + * @return bool|string + * @throws NoUserException + * @throws \Exception + */ + public function getHome($uid) { + // user Exists check required as it is not done in user proxy! + if (!$this->userExists($uid)) { + return false; + } + + if ($this->userPluginManager->implementsActions(Backend::GET_HOME)) { + return $this->userPluginManager->getHome($uid); + } + + $cacheKey = 'getHome' . $uid; + $path = $this->access->connection->getFromCache($cacheKey); + if (!is_null($path)) { + return $path; + } + + // early return path if it is a deleted user + $user = $this->access->userManager->get($uid); + if ($user instanceof User || $user instanceof OfflineUser) { + $path = $user->getHomePath() ?: false; + } else { + throw new NoUserException($uid . ' is not a valid user anymore'); + } + + $this->access->cacheUserHome($uid, $path); + return $path; + } + + /** + * get display name of the user + * @param string $uid user ID of the user + * @return string|false display name + */ + public function getDisplayName($uid) { + if ($this->userPluginManager->implementsActions(Backend::GET_DISPLAYNAME)) { + return $this->userPluginManager->getDisplayName($uid); + } + + if (!$this->userExists($uid)) { + return false; + } + + $cacheKey = 'getDisplayName' . $uid; + if (!is_null($displayName = $this->access->connection->getFromCache($cacheKey))) { + return $displayName; + } + + //Check whether the display name is configured to have a 2nd feature + $additionalAttribute = $this->access->connection->ldapUserDisplayName2; + $displayName2 = ''; + if ($additionalAttribute !== '') { + $displayName2 = $this->access->readAttribute( + $this->access->username2dn($uid), + $additionalAttribute); + } + + $displayName = $this->access->readAttribute( + $this->access->username2dn($uid), + $this->access->connection->ldapUserDisplayName); + + if ($displayName && (count($displayName) > 0)) { + $displayName = $displayName[0]; + + if (is_array($displayName2)) { + $displayName2 = count($displayName2) > 0 ? $displayName2[0] : ''; + } + + $user = $this->access->userManager->get($uid); + if ($user instanceof User) { + $displayName = $user->composeAndStoreDisplayName($displayName, (string)$displayName2); + $this->access->connection->writeToCache($cacheKey, $displayName); + } + if ($user instanceof OfflineUser) { + $displayName = $user->getDisplayName(); + } + return $displayName; + } + + return null; + } + + /** + * set display name of the user + * @param string $uid user ID of the user + * @param string $displayName new display name of the user + * @return string|false display name + */ + public function setDisplayName($uid, $displayName) { + if ($this->userPluginManager->implementsActions(Backend::SET_DISPLAYNAME)) { + $this->userPluginManager->setDisplayName($uid, $displayName); + $this->access->cacheUserDisplayName($uid, $displayName); + return $displayName; + } + return false; + } + + /** + * Get a list of all display names + * + * @param string $search + * @param int|null $limit + * @param int|null $offset + * @return array an array of all displayNames (value) and the corresponding uids (key) + */ + public function getDisplayNames($search = '', $limit = null, $offset = null) { + $cacheKey = 'getDisplayNames-' . $search . '-' . $limit . '-' . $offset; + if (!is_null($displayNames = $this->access->connection->getFromCache($cacheKey))) { + return $displayNames; + } + + $displayNames = []; + $users = $this->getUsers($search, $limit, $offset); + foreach ($users as $user) { + $displayNames[$user] = $this->getDisplayName($user); + } + $this->access->connection->writeToCache($cacheKey, $displayNames); + return $displayNames; + } + + /** + * Check if backend implements actions + * @param int $actions bitwise-or'ed actions + * @return boolean + * + * Returns the supported actions as int to be + * compared with \OC\User\Backend::CREATE_USER etc. + */ + public function implementsActions($actions) { + return (bool)((Backend::CHECK_PASSWORD + | Backend::GET_HOME + | Backend::GET_DISPLAYNAME + | (($this->access->connection->ldapUserAvatarRule !== 'none') ? Backend::PROVIDE_AVATAR : 0) + | Backend::COUNT_USERS + | (((int)$this->access->connection->turnOnPasswordChange === 1)? Backend::SET_PASSWORD :0) + | $this->userPluginManager->getImplementedActions()) + & $actions); + } + + /** + * @return bool + */ + public function hasUserListings() { + return true; + } + + /** + * counts the users in LDAP + */ + public function countUsers(int $limit = 0): int|false { + if ($this->userPluginManager->implementsActions(Backend::COUNT_USERS)) { + return $this->userPluginManager->countUsers(); + } + + $filter = $this->access->getFilterForUserCount(); + $cacheKey = 'countUsers-' . $filter . '-' . $limit; + if (!is_null($entries = $this->access->connection->getFromCache($cacheKey))) { + return $entries; + } + $entries = $this->access->countUsers($filter, limit:$limit); + $this->access->connection->writeToCache($cacheKey, $entries); + return $entries; + } + + public function countMappedUsers(): int { + return $this->access->getUserMapper()->count(); + } + + /** + * Backend name to be shown in user management + * @return string the name of the backend to be shown + */ + public function getBackendName() { + return 'LDAP'; + } + + /** + * Return access for LDAP interaction. + * @param string $uid + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($uid) { + return $this->access; + } + + /** + * Return LDAP connection resource from a cloned connection. + * The cloned connection needs to be closed manually. + * of the current access. + * @param string $uid + * @return \LDAP\Connection The LDAP connection + */ + public function getNewLDAPConnection($uid) { + $connection = clone $this->access->getConnection(); + return $connection->getConnectionResource(); + } + + /** + * create new user + * @param string $username username of the new user + * @param string $password password of the new user + * @throws \UnexpectedValueException + * @return bool + */ + public function createUser($username, $password) { + if ($this->userPluginManager->implementsActions(Backend::CREATE_USER)) { + if ($dn = $this->userPluginManager->createUser($username, $password)) { + if (is_string($dn)) { + // the NC user creation work flow requires a know user id up front + $uuid = $this->access->getUUID($dn, true); + if (is_string($uuid)) { + $this->access->mapAndAnnounceIfApplicable( + $this->access->getUserMapper(), + $dn, + $username, + $uuid, + true + ); + } else { + $this->logger->warning( + 'Failed to map created LDAP user with userid {userid}, because UUID could not be determined', + [ + 'app' => 'user_ldap', + 'userid' => $username, + ] + ); + } + } else { + throw new \UnexpectedValueException('LDAP Plugin: Method createUser changed to return the user DN instead of boolean.'); + } + } + return (bool)$dn; + } + return false; + } + + public function isUserEnabled(string $uid, callable $queryDatabaseValue): bool { + if ($this->deletedUsersIndex->isUserMarked($uid) && ((int)$this->access->connection->markRemnantsAsDisabled === 1)) { + return false; + } else { + return $queryDatabaseValue(); + } + } + + public function setUserEnabled(string $uid, bool $enabled, callable $queryDatabaseValue, callable $setDatabaseValue): bool { + $setDatabaseValue($enabled); + return $enabled; + } + + public function getDisabledUserList(?int $limit = null, int $offset = 0, string $search = ''): array { + throw new \Exception('This is implemented directly in User_Proxy'); + } +} diff --git a/apps/user_ldap/lib/User_Proxy.php b/apps/user_ldap/lib/User_Proxy.php new file mode 100644 index 00000000000..0d41f495ce9 --- /dev/null +++ b/apps/user_ldap/lib/User_Proxy.php @@ -0,0 +1,434 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User\OfflineUser; +use OCA\User_LDAP\User\User; +use OCP\IUserBackend; +use OCP\Notification\IManager as INotificationManager; +use OCP\User\Backend\ICountMappedUsersBackend; +use OCP\User\Backend\ILimitAwareCountUsersBackend; +use OCP\User\Backend\IProvideEnabledStateBackend; +use OCP\UserInterface; +use Psr\Log\LoggerInterface; + +/** + * @template-extends Proxy<User_LDAP> + */ +class User_Proxy extends Proxy implements IUserBackend, UserInterface, IUserLDAP, ILimitAwareCountUsersBackend, ICountMappedUsersBackend, IProvideEnabledStateBackend { + public function __construct( + private Helper $helper, + ILDAPWrapper $ldap, + AccessFactory $accessFactory, + private INotificationManager $notificationManager, + private UserPluginManager $userPluginManager, + private LoggerInterface $logger, + private DeletedUsersIndex $deletedUsersIndex, + ) { + parent::__construct($helper, $ldap, $accessFactory); + } + + protected function newInstance(string $configPrefix): User_LDAP { + return new User_LDAP( + $this->getAccess($configPrefix), + $this->notificationManager, + $this->userPluginManager, + $this->logger, + $this->deletedUsersIndex, + ); + } + + /** + * Tries the backends one after the other until a positive result is returned from the specified method + * + * @param string $id the uid connected to the request + * @param string $method the method of the user backend that shall be called + * @param array $parameters an array of parameters to be passed + * @return mixed the result of the method or false + */ + protected function walkBackends($id, $method, $parameters) { + $this->setup(); + + $uid = $id; + $cacheKey = $this->getUserCacheKey($uid); + foreach ($this->backends as $configPrefix => $backend) { + $instance = $backend; + if (!method_exists($instance, $method) + && method_exists($this->getAccess($configPrefix), $method)) { + $instance = $this->getAccess($configPrefix); + } + if ($result = call_user_func_array([$instance, $method], $parameters)) { + if (!$this->isSingleBackend()) { + $this->writeToCache($cacheKey, $configPrefix); + } + return $result; + } + } + return false; + } + + /** + * Asks the backend connected to the server that supposely takes care of the uid from the request. + * + * @param string $id the uid connected to the request + * @param string $method the method of the user backend that shall be called + * @param array $parameters an array of parameters to be passed + * @param mixed $passOnWhen the result matches this variable + * @return mixed the result of the method or false + */ + protected function callOnLastSeenOn($id, $method, $parameters, $passOnWhen) { + $this->setup(); + + $uid = $id; + $cacheKey = $this->getUserCacheKey($uid); + $prefix = $this->getFromCache($cacheKey); + //in case the uid has been found in the past, try this stored connection first + if (!is_null($prefix)) { + if (isset($this->backends[$prefix])) { + $instance = $this->backends[$prefix]; + if (!method_exists($instance, $method) + && method_exists($this->getAccess($prefix), $method)) { + $instance = $this->getAccess($prefix); + } + $result = call_user_func_array([$instance, $method], $parameters); + if ($result === $passOnWhen) { + //not found here, reset cache to null if user vanished + //because sometimes methods return false with a reason + $userExists = call_user_func_array( + [$this->backends[$prefix], 'userExistsOnLDAP'], + [$uid] + ); + if (!$userExists) { + $this->writeToCache($cacheKey, null); + } + } + return $result; + } + } + return false; + } + + protected function activeBackends(): int { + $this->setup(); + return count($this->backends); + } + + /** + * Check if backend implements actions + * + * @param int $actions bitwise-or'ed actions + * @return boolean + * + * Returns the supported actions as int to be + * compared with \OC\User\Backend::CREATE_USER etc. + */ + public function implementsActions($actions) { + $this->setup(); + //it's the same across all our user backends obviously + return $this->refBackend->implementsActions($actions); + } + + /** + * Backend name to be shown in user management + * + * @return string the name of the backend to be shown + */ + public function getBackendName() { + $this->setup(); + return $this->refBackend->getBackendName(); + } + + /** + * Get a list of all users + * + * @param string $search + * @param null|int $limit + * @param null|int $offset + * @return string[] an array of all uids + */ + public function getUsers($search = '', $limit = 10, $offset = 0) { + $this->setup(); + + //we do it just as the /OC_User implementation: do not play around with limit and offset but ask all backends + $users = []; + foreach ($this->backends as $backend) { + $backendUsers = $backend->getUsers($search, $limit, $offset); + if (is_array($backendUsers)) { + $users = array_merge($users, $backendUsers); + } + } + return $users; + } + + /** + * check if a user exists + * + * @param string $uid the username + * @return boolean + */ + public function userExists($uid) { + $existsOnLDAP = false; + $existsLocally = $this->handleRequest($uid, 'userExists', [$uid]); + if ($existsLocally) { + $existsOnLDAP = $this->userExistsOnLDAP($uid); + } + if ($existsLocally && !$existsOnLDAP) { + try { + $user = $this->getLDAPAccess($uid)->userManager->get($uid); + if ($user instanceof User) { + $user->markUser(); + } + } catch (\Exception $e) { + // ignore + } + } + return $existsLocally; + } + + /** + * check if a user exists on LDAP + * + * @param string|User $user either the Nextcloud user + * name or an instance of that user + */ + public function userExistsOnLDAP($user, bool $ignoreCache = false): bool { + $id = ($user instanceof User) ? $user->getUsername() : $user; + return $this->handleRequest($id, 'userExistsOnLDAP', [$user, $ignoreCache]); + } + + /** + * Check if the password is correct + * + * @param string $uid The username + * @param string $password The password + * @return bool + * + * Check if the password is correct without logging in the user + */ + public function checkPassword($uid, $password) { + return $this->handleRequest($uid, 'checkPassword', [$uid, $password]); + } + + /** + * returns the username for the given login name, if available + * + * @param string $loginName + * @return string|false + */ + public function loginName2UserName($loginName) { + $id = 'LOGINNAME,' . $loginName; + return $this->handleRequest($id, 'loginName2UserName', [$loginName]); + } + + /** + * returns the username for the given LDAP DN, if available + * + * @param string $dn + * @return string|false with the username + */ + public function dn2UserName($dn) { + $id = 'DN,' . $dn; + return $this->handleRequest($id, 'dn2UserName', [$dn]); + } + + /** + * get the user's home directory + * + * @param string $uid the username + * @return boolean + */ + public function getHome($uid) { + return $this->handleRequest($uid, 'getHome', [$uid]); + } + + /** + * get display name of the user + * + * @param string $uid user ID of the user + * @return string display name + */ + public function getDisplayName($uid) { + return $this->handleRequest($uid, 'getDisplayName', [$uid]); + } + + /** + * set display name of the user + * + * @param string $uid user ID of the user + * @param string $displayName new display name + * @return string display name + */ + public function setDisplayName($uid, $displayName) { + return $this->handleRequest($uid, 'setDisplayName', [$uid, $displayName]); + } + + /** + * checks whether the user is allowed to change their avatar in Nextcloud + * + * @param string $uid the Nextcloud user name + * @return boolean either the user can or cannot + */ + public function canChangeAvatar($uid) { + return $this->handleRequest($uid, 'canChangeAvatar', [$uid], true); + } + + /** + * Get a list of all display names and user ids. + * + * @param string $search + * @param int|null $limit + * @param int|null $offset + * @return array an array of all displayNames (value) and the corresponding uids (key) + */ + public function getDisplayNames($search = '', $limit = null, $offset = null) { + $this->setup(); + + //we do it just as the /OC_User implementation: do not play around with limit and offset but ask all backends + $users = []; + foreach ($this->backends as $backend) { + $backendUsers = $backend->getDisplayNames($search, $limit, $offset); + if (is_array($backendUsers)) { + $users = $users + $backendUsers; + } + } + return $users; + } + + /** + * delete a user + * + * @param string $uid The username of the user to delete + * @return bool + * + * Deletes a user + */ + public function deleteUser($uid) { + return $this->handleRequest($uid, 'deleteUser', [$uid]); + } + + /** + * Set password + * + * @param string $uid The username + * @param string $password The new password + * @return bool + * + */ + public function setPassword($uid, $password) { + return $this->handleRequest($uid, 'setPassword', [$uid, $password]); + } + + /** + * @return bool + */ + public function hasUserListings() { + $this->setup(); + return $this->refBackend->hasUserListings(); + } + + /** + * Count the number of users + */ + public function countUsers(int $limit = 0): int|false { + $this->setup(); + + $users = false; + foreach ($this->backends as $backend) { + $backendUsers = $backend->countUsers($limit); + if ($backendUsers !== false) { + $users = (int)$users + $backendUsers; + if ($limit > 0) { + if ($users >= $limit) { + break; + } + $limit -= $users; + } + } + } + return $users; + } + + /** + * Count the number of mapped users + */ + public function countMappedUsers(): int { + $this->setup(); + + $users = 0; + foreach ($this->backends as $backend) { + $users += $backend->countMappedUsers(); + } + return $users; + } + + /** + * Return access for LDAP interaction. + * + * @param string $uid + * @return Access instance of Access for LDAP interaction + */ + public function getLDAPAccess($uid) { + return $this->handleRequest($uid, 'getLDAPAccess', [$uid]); + } + + /** + * Return a new LDAP connection for the specified user. + * The connection needs to be closed manually. + * + * @param string $uid + * @return \LDAP\Connection The LDAP connection + */ + public function getNewLDAPConnection($uid) { + return $this->handleRequest($uid, 'getNewLDAPConnection', [$uid]); + } + + /** + * Creates a new user in LDAP + * + * @param $username + * @param $password + * @return bool + */ + public function createUser($username, $password) { + return $this->handleRequest($username, 'createUser', [$username, $password]); + } + + public function isUserEnabled(string $uid, callable $queryDatabaseValue): bool { + return $this->handleRequest($uid, 'isUserEnabled', [$uid, $queryDatabaseValue]); + } + + public function setUserEnabled(string $uid, bool $enabled, callable $queryDatabaseValue, callable $setDatabaseValue): bool { + return $this->handleRequest($uid, 'setUserEnabled', [$uid, $enabled, $queryDatabaseValue, $setDatabaseValue]); + } + + public function getDisabledUserList(?int $limit = null, int $offset = 0, string $search = ''): array { + if ((int)$this->getAccess(array_key_first($this->backends) ?? '')->connection->markRemnantsAsDisabled !== 1) { + return []; + } + $disabledUsers = $this->deletedUsersIndex->getUsers(); + if ($search !== '') { + $disabledUsers = array_filter( + $disabledUsers, + fn (OfflineUser $user): bool + => mb_stripos($user->getOCName(), $search) !== false + || mb_stripos($user->getUID(), $search) !== false + || mb_stripos($user->getDisplayName(), $search) !== false + || mb_stripos($user->getEmail(), $search) !== false, + ); + } + return array_map( + fn (OfflineUser $user) => $user->getOCName(), + array_slice( + $disabledUsers, + $offset, + $limit + ) + ); + } +} diff --git a/apps/user_ldap/lib/wizard.php b/apps/user_ldap/lib/Wizard.php index 5235011fb96..15a9f9cb212 100644 --- a/apps/user_ldap/lib/wizard.php +++ b/apps/user_ldap/lib/Wizard.php @@ -1,76 +1,54 @@ <?php + /** - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Jean-Louis Dupond <jean-louis@dupond.be> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nicolas Grekas <nicolas.grekas@gmail.com> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ -namespace OCA\user_ldap\lib; +namespace OCA\User_LDAP; use OC\ServerNotAvailableException; +use OCP\IL10N; +use OCP\L10N\IFactory as IL10NFactory; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; class Wizard extends LDAPUtility { - static protected $l; - protected $access; - protected $cr; - protected $configuration; - protected $result; - protected $resultCache = array(); + protected static ?IL10N $l = null; + protected ?\LDAP\Connection $cr = null; + protected WizardResult $result; + protected LoggerInterface $logger; - const LRESULT_PROCESSED_OK = 2; - const LRESULT_PROCESSED_INVALID = 3; - const LRESULT_PROCESSED_SKIP = 4; + public const LRESULT_PROCESSED_OK = 2; + public const LRESULT_PROCESSED_INVALID = 3; + public const LRESULT_PROCESSED_SKIP = 4; - const LFILTER_LOGIN = 2; - const LFILTER_USER_LIST = 3; - const LFILTER_GROUP_LIST = 4; + public const LFILTER_LOGIN = 2; + public const LFILTER_USER_LIST = 3; + public const LFILTER_GROUP_LIST = 4; - const LFILTER_MODE_ASSISTED = 2; - const LFILTER_MODE_RAW = 1; + public const LFILTER_MODE_ASSISTED = 2; + public const LFILTER_MODE_RAW = 1; - const LDAP_NW_TIMEOUT = 4; + public const LDAP_NW_TIMEOUT = 4; - /** - * Constructor - * @param Configuration $configuration an instance of Configuration - * @param ILDAPWrapper $ldap an instance of ILDAPWrapper - */ - public function __construct(Configuration $configuration, ILDAPWrapper $ldap, Access $access) { + public function __construct( + protected Configuration $configuration, + ILDAPWrapper $ldap, + protected Access $access, + ) { parent::__construct($ldap); - $this->configuration = $configuration; - if(is_null(Wizard::$l)) { - Wizard::$l = \OC::$server->getL10N('user_ldap'); + if (is_null(static::$l)) { + static::$l = Server::get(IL10NFactory::class)->get('user_ldap'); } - $this->access = $access; $this->result = new WizardResult(); + $this->logger = Server::get(LoggerInterface::class); } - public function __destruct() { - if($this->result->hasChanges()) { + public function __destruct() { + if ($this->result->hasChanges()) { $this->configuration->saveConfiguration(); } } @@ -80,80 +58,86 @@ class Wizard extends LDAPUtility { * * @param string $filter the LDAP search filter * @param string $type a string being either 'users' or 'groups'; - * @return bool|int * @throws \Exception */ - public function countEntries($filter, $type) { - $reqs = array('ldapHost', 'ldapPort', 'ldapBase'); - if($type === 'users') { + public function countEntries(string $filter, string $type): int { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if ($type === 'users') { $reqs[] = 'ldapUserFilter'; } - if(!$this->checkRequirements($reqs)) { + if (!$this->checkRequirements($reqs)) { throw new \Exception('Requirements not met', 400); } - $attr = array('dn'); // default + $attr = ['dn']; // default $limit = 1001; - if($type === 'groups') { - $result = $this->access->countGroups($filter, $attr, $limit); - } else if($type === 'users') { + if ($type === 'groups') { + $result = $this->access->countGroups($filter, $attr, $limit); + } elseif ($type === 'users') { $result = $this->access->countUsers($filter, $attr, $limit); - } else if ($type === 'objects') { + } elseif ($type === 'objects') { $result = $this->access->countObjects($limit); } else { - throw new \Exception('internal error: invalid object type', 500); + throw new \Exception('Internal error: Invalid object type', 500); } - return $result; + return (int)$result; } /** - * formats the return value of a count operation to the string to be - * inserted. - * - * @param bool|int $count - * @return int|string + * @return WizardResult|false */ - private function formatCountResult($count) { - $formatted = ($count !== false) ? $count : 0; - if($formatted > 1000) { - $formatted = '> 1000'; - } - return $formatted; - } - public function countGroups() { $filter = $this->configuration->ldapGroupFilter; - if(empty($filter)) { - $output = self::$l->n('%s group found', '%s groups found', 0, array(0)); + if (empty($filter)) { + $output = self::$l->n('%n group found', '%n groups found', 0); $this->result->addChange('ldap_group_count', $output); return $this->result; } try { - $groupsTotal = $this->formatCountResult($this->countEntries($filter, 'groups')); + $groupsTotal = $this->countEntries($filter, 'groups'); } catch (\Exception $e) { //400 can be ignored, 500 is forwarded - if($e->getCode() === 500) { + if ($e->getCode() === 500) { throw $e; } return false; } - $output = self::$l->n('%s group found', '%s groups found', $groupsTotal, array($groupsTotal)); + + if ($groupsTotal > 1000) { + $output = self::$l->t('> 1000 groups found'); + } else { + $output = self::$l->n( + '%n group found', + '%n groups found', + $groupsTotal + ); + } $this->result->addChange('ldap_group_count', $output); return $this->result; } /** - * @return WizardResult * @throws \Exception */ - public function countUsers() { + public function countUsers(): WizardResult { $filter = $this->access->getFilterForUserCount(); - $usersTotal = $this->formatCountResult($this->countEntries($filter, 'users')); - $output = self::$l->n('%s user found', '%s users found', $usersTotal, array($usersTotal)); + $usersTotal = $this->countEntries($filter, 'users'); + if ($usersTotal > 1000) { + $output = self::$l->t('> 1000 users found'); + } else { + $output = self::$l->n( + '%n user found', + '%n users found', + $usersTotal + ); + } $this->result->addChange('ldap_user_count', $output); return $this->result; } @@ -161,84 +145,78 @@ class Wizard extends LDAPUtility { /** * counts any objects in the currently set base dn * - * @return WizardResult * @throws \Exception */ - public function countInBaseDN() { + public function countInBaseDN(): WizardResult { // we don't need to provide a filter in this case - $total = $this->countEntries(null, 'objects'); - if($total === false) { - throw new \Exception('invalid results received'); - } + $total = $this->countEntries('', 'objects'); $this->result->addChange('ldap_test_base', $total); return $this->result; } /** * counts users with a specified attribute - * @param string $attr - * @param bool $existsCheck - * @return int|bool + * @return int|false */ - public function countUsersWithAttribute($attr, $existsCheck = false) { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + public function countUsersWithAttribute(string $attr, bool $existsCheck = false) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } - $filter = $this->access->combineFilterWithAnd(array( + $filter = $this->access->combineFilterWithAnd([ $this->configuration->ldapUserFilter, $attr . '=*' - )); + ]); - $limit = ($existsCheck === false) ? null : 1; + $limit = $existsCheck ? null : 1; - return $this->access->countUsers($filter, array('dn'), $limit); + return $this->access->countUsers($filter, ['dn'], $limit); } /** * detects the display name attribute. If a setting is already present that * returns at least one hit, the detection will be canceled. - * @return WizardResult|bool + * @return WizardResult|false * @throws \Exception */ public function detectUserDisplayNameAttribute() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $attr = $this->configuration->ldapUserDisplayName; - if($attr !== 'displayName' && !empty($attr)) { + if ($attr !== '' && $attr !== 'displayName') { // most likely not the default value with upper case N, // verify it still produces a result - $count = intval($this->countUsersWithAttribute($attr, true)); - if($count > 0) { + $count = (int)$this->countUsersWithAttribute($attr, true); + if ($count > 0) { //no change, but we sent it back to make sure the user interface - //is still correct, even if the ajax call was cancelled inbetween + //is still correct, even if the ajax call was cancelled meanwhile $this->result->addChange('ldap_display_name', $attr); return $this->result; } } // first attribute that has at least one result wins - $displayNameAttrs = array('displayname', 'cn'); + $displayNameAttrs = ['displayname', 'cn']; foreach ($displayNameAttrs as $attr) { - $count = intval($this->countUsersWithAttribute($attr, true)); + $count = (int)$this->countUsersWithAttribute($attr, true); - if($count > 0) { + if ($count > 0) { $this->applyFind('ldap_display_name', $attr); return $this->result; } - }; + } - throw new \Exception(self::$l->t('Could not detect user display name attribute. Please specify it yourself in advanced ldap settings.')); + throw new \Exception(self::$l->t('Could not detect user display name attribute. Please specify it yourself in advanced LDAP settings.')); } /** @@ -248,18 +226,18 @@ class Wizard extends LDAPUtility { * @return WizardResult|bool */ public function detectEmailAttribute() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $attr = $this->configuration->ldapEmailAttribute; - if(!empty($attr)) { - $count = intval($this->countUsersWithAttribute($attr, true)); - if($count > 0) { + if ($attr !== '') { + $count = (int)$this->countUsersWithAttribute($attr, true); + if ($count > 0) { return false; } $writeLog = true; @@ -267,23 +245,25 @@ class Wizard extends LDAPUtility { $writeLog = false; } - $emailAttributes = array('mail', 'mailPrimaryAddress'); + $emailAttributes = ['mail', 'mailPrimaryAddress']; $winner = ''; $maxUsers = 0; - foreach($emailAttributes as $attr) { + foreach ($emailAttributes as $attr) { $count = $this->countUsersWithAttribute($attr); - if($count > $maxUsers) { + if ($count > $maxUsers) { $maxUsers = $count; $winner = $attr; } } - if($winner !== '') { + if ($winner !== '') { $this->applyFind('ldap_email_attr', $winner); - if($writeLog) { - \OCP\Util::writeLog('user_ldap', 'The mail attribute has ' . - 'automatically been reset, because the original value ' . - 'did not return any results.', \OCP\Util::INFO); + if ($writeLog) { + $this->logger->info( + 'The mail attribute has automatically been reset, ' + . 'because the original value did not return any results.', + ['app' => 'user_ldap'] + ); } } @@ -291,27 +271,31 @@ class Wizard extends LDAPUtility { } /** - * @return WizardResult + * @return WizardResult|false * @throws \Exception */ public function determineAttributes() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $attributes = $this->getUserAttributes(); + if (!is_array($attributes)) { + throw new \Exception('Failed to determine user attributes'); + } + natcasesort($attributes); $attributes = array_values($attributes); $this->result->addOptions('ldap_loginfilter_attributes', $attributes); $selected = $this->configuration->ldapLoginFilterAttributes; - if(is_array($selected) && !empty($selected)) { + if (is_array($selected) && !empty($selected)) { $this->result->addChange('ldap_loginfilter_attributes', $selected); } @@ -320,32 +304,36 @@ class Wizard extends LDAPUtility { /** * detects the available LDAP attributes - * @return array|false The instance's WizardResult instance + * @return array|false * @throws \Exception */ private function getUserAttributes() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } $base = $this->configuration->ldapBase[0]; $filter = $this->configuration->ldapUserFilter; - $rr = $this->ldap->search($cr, $base, $filter, array(), 1, 1); - if(!$this->ldap->isResource($rr)) { + $rr = $this->ldap->search($cr, $base, $filter, [], 1, 1); + if (!$this->ldap->isResource($rr)) { return false; } + /** @var \LDAP\Result $rr */ $er = $this->ldap->firstEntry($cr, $rr); $attributes = $this->ldap->getAttributes($cr, $er); - $pureAttributes = array(); - for($i = 0; $i < $attributes['count']; $i++) { + if ($attributes === false) { + return false; + } + $pureAttributes = []; + for ($i = 0; $i < $attributes['count']; $i++) { $pureAttributes[] = $attributes[$i]; } @@ -358,8 +346,8 @@ class Wizard extends LDAPUtility { */ public function determineGroupsForGroups() { return $this->determineGroups('ldap_groupfilter_groups', - 'ldapGroupFilterGroups', - false); + 'ldapGroupFilterGroups', + false); } /** @@ -368,35 +356,33 @@ class Wizard extends LDAPUtility { */ public function determineGroupsForUsers() { return $this->determineGroups('ldap_userfilter_groups', - 'ldapUserFilterGroups'); + 'ldapUserFilterGroups'); } /** * detects the available LDAP groups - * @param string $dbKey - * @param string $confKey - * @param bool $testMemberOf * @return WizardResult|false the instance's WizardResult instance * @throws \Exception */ - private function determineGroups($dbKey, $confKey, $testMemberOf = true) { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - ))) { + private function determineGroups(string $dbKey, string $confKey, bool $testMemberOf = true) { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } $this->fetchGroups($dbKey, $confKey); - if($testMemberOf) { - $this->configuration->hasMemberOfFilterSupport = $this->testMemberOf(); + if ($testMemberOf) { + $this->configuration->hasMemberOfFilterSupport = (string)$this->testMemberOf(); $this->result->markChange(); - if(!$this->configuration->hasMemberOfFilterSupport) { + if (!$this->configuration->hasMemberOfFilterSupport) { throw new \Exception('memberOf is not supported by the server'); } } @@ -407,34 +393,31 @@ class Wizard extends LDAPUtility { /** * fetches all groups from LDAP and adds them to the result object * - * @param string $dbKey - * @param string $confKey - * @return array $groupEntries * @throws \Exception */ - public function fetchGroups($dbKey, $confKey) { - $obclasses = array('posixGroup', 'group', 'zimbraDistributionList', 'groupOfNames'); + public function fetchGroups(string $dbKey, string $confKey): array { + $obclasses = ['posixGroup', 'group', 'zimbraDistributionList', 'groupOfNames', 'groupOfUniqueNames']; - $filterParts = array(); - foreach($obclasses as $obclass) { - $filterParts[] = 'objectclass='.$obclass; + $filterParts = []; + foreach ($obclasses as $obclass) { + $filterParts[] = 'objectclass=' . $obclass; } //we filter for everything //- that looks like a group and //- has the group display name set $filter = $this->access->combineFilterWithOr($filterParts); - $filter = $this->access->combineFilterWithAnd(array($filter, 'cn=*')); + $filter = $this->access->combineFilterWithAnd([$filter, 'cn=*']); - $groupNames = array(); - $groupEntries = array(); + $groupNames = []; + $groupEntries = []; $limit = 400; $offset = 0; do { // we need to request dn additionally here, otherwise memberOf // detection will fail later - $result = $this->access->searchGroups($filter, array('cn', 'dn'), $limit, $offset); - foreach($result as $item) { - if(!isset($item['cn']) && !is_array($item['cn']) && !isset($item['cn'][0])) { + $result = $this->access->searchGroups($filter, ['cn', 'dn'], $limit, $offset); + foreach ($result as $item) { + if (!isset($item['cn']) || !is_array($item['cn']) || !isset($item['cn'][0])) { // just in case - no issue known continue; } @@ -444,7 +427,7 @@ class Wizard extends LDAPUtility { $offset += $limit; } while ($this->access->hasMoreResults()); - if(count($groupNames) > 0) { + if (count($groupNames) > 0) { natsort($groupNames); $this->result->addOptions($dbKey, array_values($groupNames)); } else { @@ -452,25 +435,29 @@ class Wizard extends LDAPUtility { } $setFeatures = $this->configuration->$confKey; - if(is_array($setFeatures) && !empty($setFeatures)) { + if (is_array($setFeatures) && !empty($setFeatures)) { //something is already configured? pre-select it. $this->result->addChange($dbKey, $setFeatures); } return $groupEntries; } + /** + * @return WizardResult|false + */ public function determineGroupMemberAssoc() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapGroupFilter', - ))) { + $reqs = ['ldapHost', 'ldapGroupFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $attribute = $this->detectGroupMemberAssoc(); - if($attribute === false) { + if ($attribute === false) { return false; } - $this->configuration->setConfiguration(array('ldapGroupMemberAssocAttr' => $attribute)); + $this->configuration->setConfiguration(['ldapGroupMemberAssocAttr' => $attribute]); $this->result->addChange('ldap_group_member_assoc_attribute', $attribute); return $this->result; @@ -482,54 +469,56 @@ class Wizard extends LDAPUtility { * @throws \Exception */ public function determineGroupObjectClasses() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - ))) { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } - $obclasses = array('groupOfNames', 'group', 'posixGroup', '*'); + $obclasses = ['groupOfNames', 'groupOfUniqueNames', 'group', 'posixGroup', '*']; $this->determineFeature($obclasses, - 'objectclass', - 'ldap_groupfilter_objectclass', - 'ldapGroupFilterObjectclass', - false); + 'objectclass', + 'ldap_groupfilter_objectclass', + 'ldapGroupFilterObjectclass', + false); return $this->result; } /** * detects the available object classes - * @return WizardResult + * @return WizardResult|false * @throws \Exception */ public function determineUserObjectClasses() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - ))) { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } - $obclasses = array('inetOrgPerson', 'person', 'organizationalPerson', - 'user', 'posixAccount', '*'); + $obclasses = ['inetOrgPerson', 'person', 'organizationalPerson', + 'user', 'posixAccount', '*']; $filter = $this->configuration->ldapUserFilter; //if filter is empty, it is probably the first time the wizard is called //then, apply suggestions. $this->determineFeature($obclasses, - 'objectclass', - 'ldap_userfilter_objectclass', - 'ldapUserFilterObjectclass', - empty($filter)); + 'objectclass', + 'ldap_userfilter_objectclass', + 'ldapUserFilterObjectclass', + empty($filter)); return $this->result; } @@ -539,18 +528,19 @@ class Wizard extends LDAPUtility { * @throws \Exception */ public function getGroupFilter() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - ))) { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } //make sure the use display name is set $displayName = $this->configuration->ldapGroupDisplayName; - if(empty($displayName)) { + if ($displayName === '') { $d = $this->configuration->getDefaults(); $this->applyFind('ldap_group_display_name', - $d['ldap_group_display_name']); + $d['ldap_group_display_name']); } $filter = $this->composeLdapFilter(self::LFILTER_GROUP_LIST); @@ -563,20 +553,21 @@ class Wizard extends LDAPUtility { * @throws \Exception */ public function getUserListFilter() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - ))) { + $reqs = ['ldapHost', 'ldapBase']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } //make sure the use display name is set $displayName = $this->configuration->ldapUserDisplayName; - if(empty($displayName)) { + if ($displayName === '') { $d = $this->configuration->getDefaults(); $this->applyFind('ldap_display_name', $d['ldap_display_name']); } $filter = $this->composeLdapFilter(self::LFILTER_USER_LIST); - if(!$filter) { + if (!$filter) { throw new \Exception('Cannot create filter'); } @@ -585,20 +576,20 @@ class Wizard extends LDAPUtility { } /** - * @return bool|WizardResult + * @return WizardResult|false * @throws \Exception */ public function getUserLoginFilter() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapUserFilter', - ))) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $filter = $this->composeLdapFilter(self::LFILTER_LOGIN); - if(!$filter) { + if (!$filter) { throw new \Exception('Cannot create filter'); } @@ -607,31 +598,27 @@ class Wizard extends LDAPUtility { } /** - * @return bool|WizardResult - * @param string $loginName + * @return WizardResult|false * @throws \Exception */ - public function testLoginName($loginName) { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - 'ldapBase', - 'ldapLoginFilter', - ))) { + public function testLoginName(string $loginName) { + $reqs = ['ldapHost', 'ldapBase', 'ldapUserFilter']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } $cr = $this->access->connection->getConnectionResource(); - if(!$this->ldap->isResource($cr)) { - throw new \Exception('connection error'); - } - if(mb_strpos($this->access->connection->ldapLoginFilter, '%uid', 0, 'UTF-8') + if (mb_strpos($this->access->connection->ldapLoginFilter, '%uid', 0, 'UTF-8') === false) { throw new \Exception('missing placeholder'); } $users = $this->access->countUsersByLoginName($loginName); - if($this->ldap->errno($cr) !== 0) { + if ($this->ldap->errno($cr) !== 0) { throw new \Exception($this->ldap->error($cr)); } $filter = str_replace('%uid', $loginName, $this->access->connection->ldapLoginFilter); @@ -646,23 +633,22 @@ class Wizard extends LDAPUtility { * @throws \Exception */ public function guessPortAndTLS() { - if(!$this->checkRequirements(array('ldapHost', - ))) { + if (!$this->checkRequirements(['ldapHost', + ])) { return false; } $this->checkHost(); $portSettings = $this->getPortSettingsToTry(); - if(!is_array($portSettings)) { - throw new \Exception(print_r($portSettings, true)); - } - //proceed from the best configuration and return on first success - foreach($portSettings as $setting) { + foreach ($portSettings as $setting) { $p = $setting['port']; $t = $setting['tls']; - \OCP\Util::writeLog('user_ldap', 'Wiz: trying port '. $p . ', TLS '. $t, \OCP\Util::DEBUG); - //connectAndBind may throw Exception, it needs to be catched by the + $this->logger->debug( + 'Wiz: trying port ' . $p . ', TLS ' . $t, + ['app' => 'user_ldap'] + ); + //connectAndBind may throw Exception, it needs to be caught by the //callee of this method try { @@ -671,7 +657,7 @@ class Wizard extends LDAPUtility { // any reply other than -1 (= cannot connect) is already okay, // because then we found the server // unavailable startTLS returns -11 - if($e->getCode() > 0) { + if ($e->getCode() > 0) { $settingsFound = true; } else { throw $e; @@ -679,12 +665,15 @@ class Wizard extends LDAPUtility { } if ($settingsFound === true) { - $config = array( - 'ldapPort' => $p, - 'ldapTLS' => intval($t) - ); + $config = [ + 'ldapPort' => (string)$p, + 'ldapTLS' => (string)$t, + ]; $this->configuration->setConfiguration($config); - \OCP\Util::writeLog('user_ldap', 'Wiz: detected Port ' . $p, \OCP\Util::DEBUG); + $this->logger->debug( + 'Wiz: detected Port ' . $p, + ['app' => 'user_ldap'] + ); $this->result->addChange('ldap_port', $p); return $this->result; } @@ -699,18 +688,20 @@ class Wizard extends LDAPUtility { * @return WizardResult|false WizardResult on success, false otherwise */ public function guessBaseDN() { - if(!$this->checkRequirements(array('ldapHost', - 'ldapPort', - ))) { + $reqs = ['ldapHost']; + if (!$this->configuration->usesLdapi()) { + $reqs[] = 'ldapPort'; + } + if (!$this->checkRequirements($reqs)) { return false; } //check whether a DN is given in the agent name (99.9% of all cases) $base = null; $i = stripos($this->configuration->ldapAgentName, 'dc='); - if($i !== false) { + if ($i !== false) { $base = substr($this->configuration->ldapAgentName, $i); - if($this->testBaseDN($base)) { + if ($this->testBaseDN($base)) { $this->applyFind('ldap_base', $base); return $this->result; } @@ -719,14 +710,14 @@ class Wizard extends LDAPUtility { //this did not help :( //Let's see whether we can parse the Host URL and convert the domain to //a base DN - $helper = new Helper(); + $helper = Server::get(Helper::class); $domain = $helper->getDomainFromURL($this->configuration->ldapHost); - if(!$domain) { + if (!$domain) { return false; } $dparts = explode('.', $domain); - while(count($dparts) > 0) { + while (count($dparts) > 0) { $base2 = 'dc=' . implode(',dc=', $dparts); if ($base !== $base2 && $this->testBaseDN($base2)) { $this->applyFind('ldap_base', $base2); @@ -745,9 +736,9 @@ class Wizard extends LDAPUtility { * @param string $value the (detected) value * */ - private function applyFind($key, $value) { + private function applyFind(string $key, string $value): void { $this->result->addChange($key, $value); - $this->configuration->setConfiguration(array($key => $value)); + $this->configuration->setConfiguration([$key => $value]); } /** @@ -755,52 +746,53 @@ class Wizard extends LDAPUtility { * field. In this case the port will be stripped off, but also stored as * setting. */ - private function checkHost() { + private function checkHost(): void { $host = $this->configuration->ldapHost; $hostInfo = parse_url($host); //removes Port from Host - if(is_array($hostInfo) && isset($hostInfo['port'])) { + if (is_array($hostInfo) && isset($hostInfo['port'])) { $port = $hostInfo['port']; - $host = str_replace(':'.$port, '', $host); + $host = str_replace(':' . $port, '', $host); $this->applyFind('ldap_host', $host); - $this->applyFind('ldap_port', $port); + $this->applyFind('ldap_port', (string)$port); } } /** * tries to detect the group member association attribute which is - * one of 'uniqueMember', 'memberUid', 'member' - * @return string|false, string with the attribute name, false on error + * one of 'uniqueMember', 'memberUid', 'member', 'gidNumber' + * @return string|false string with the attribute name, false on error * @throws \Exception */ private function detectGroupMemberAssoc() { - $possibleAttrs = array('uniqueMember', 'memberUid', 'member'); + $possibleAttrs = ['uniqueMember', 'memberUid', 'member', 'gidNumber', 'zimbraMailForwardingAddress']; $filter = $this->configuration->ldapGroupFilter; - if(empty($filter)) { + if (empty($filter)) { return false; } $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } - $base = $this->configuration->ldapBase[0]; + $base = $this->configuration->ldapBaseGroups[0] ?: $this->configuration->ldapBase[0]; $rr = $this->ldap->search($cr, $base, $filter, $possibleAttrs, 0, 1000); - if(!$this->ldap->isResource($rr)) { + if (!$this->ldap->isResource($rr)) { return false; } + /** @var \LDAP\Result $rr */ $er = $this->ldap->firstEntry($cr, $rr); - while(is_resource($er)) { + while ($this->ldap->isResource($er)) { $this->ldap->getDN($cr, $er); $attrs = $this->ldap->getAttributes($cr, $er); - $result = array(); + $result = []; $possibleAttrsCount = count($possibleAttrs); - for($i = 0; $i < $possibleAttrsCount; $i++) { - if(isset($attrs[$possibleAttrs[$i]])) { + for ($i = 0; $i < $possibleAttrsCount; $i++) { + if (isset($attrs[$possibleAttrs[$i]])) { $result[$possibleAttrs[$i]] = $attrs[$possibleAttrs[$i]]['count']; } } - if(!empty($result)) { + if (!empty($result)) { natsort($result); return key($result); } @@ -817,22 +809,25 @@ class Wizard extends LDAPUtility { * @return bool true on success, false otherwise * @throws \Exception */ - private function testBaseDN($base) { + private function testBaseDN(string $base): bool { $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } //base is there, let's validate it. If we search for anything, we should //get a result set > 0 on a proper base - $rr = $this->ldap->search($cr, $base, 'objectClass=*', array('dn'), 0, 1); - if(!$this->ldap->isResource($rr)) { - $errorNo = $this->ldap->errno($cr); + $rr = $this->ldap->search($cr, $base, 'objectClass=*', ['dn'], 0, 1); + if (!$this->ldap->isResource($rr)) { + $errorNo = $this->ldap->errno($cr); $errorMsg = $this->ldap->error($cr); - \OCP\Util::writeLog('user_ldap', 'Wiz: Could not search base '.$base. - ' Error '.$errorNo.': '.$errorMsg, \OCP\Util::INFO); + $this->logger->info( + 'Wiz: Could not search base ' . $base . ' Error ' . $errorNo . ': ' . $errorMsg, + ['app' => 'user_ldap'] + ); return false; } + /** @var \LDAP\Result $rr */ $entries = $this->ldap->countEntries($cr, $rr); return ($entries !== false) && ($entries > 0); } @@ -846,13 +841,13 @@ class Wizard extends LDAPUtility { * @return bool true if it does, false otherwise * @throws \Exception */ - private function testMemberOf() { + private function testMemberOf(): bool { $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } - $result = $this->access->countUsers('memberOf=*', array('memberOf'), 1); - if(is_int($result) && $result > 0) { + $result = $this->access->countUsers('memberOf=*', ['memberOf'], 1); + if (is_int($result) && $result > 0) { return true; } return false; @@ -860,52 +855,52 @@ class Wizard extends LDAPUtility { /** * creates an LDAP Filter from given configuration - * @param integer $filterType int, for which use case the filter shall be created - * can be any of self::LFILTER_USER_LIST, self::LFILTER_LOGIN or - * self::LFILTER_GROUP_LIST - * @return string|false string with the filter on success, false otherwise + * @param int $filterType int, for which use case the filter shall be created + * can be any of self::LFILTER_USER_LIST, self::LFILTER_LOGIN or + * self::LFILTER_GROUP_LIST * @throws \Exception */ - private function composeLdapFilter($filterType) { + private function composeLdapFilter(int $filterType): string { $filter = ''; $parts = 0; switch ($filterType) { case self::LFILTER_USER_LIST: $objcs = $this->configuration->ldapUserFilterObjectclass; //glue objectclasses - if(is_array($objcs) && count($objcs) > 0) { + if (is_array($objcs) && count($objcs) > 0) { $filter .= '(|'; - foreach($objcs as $objc) { - $filter .= '(objectclass=' . $objc . ')'; + foreach ($objcs as $objc) { + $filter .= '(objectclass=' . ldap_escape($objc, '', LDAP_ESCAPE_FILTER) . ')'; } $filter .= ')'; $parts++; } //glue group memberships - if($this->configuration->hasMemberOfFilterSupport) { + if ($this->configuration->hasMemberOfFilterSupport) { $cns = $this->configuration->ldapUserFilterGroups; - if(is_array($cns) && count($cns) > 0) { + if (is_array($cns) && count($cns) > 0) { $filter .= '(|'; $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } $base = $this->configuration->ldapBase[0]; - foreach($cns as $cn) { - $rr = $this->ldap->search($cr, $base, 'cn=' . $cn, array('dn', 'primaryGroupToken')); - if(!$this->ldap->isResource($rr)) { + foreach ($cns as $cn) { + $rr = $this->ldap->search($cr, $base, 'cn=' . ldap_escape($cn, '', LDAP_ESCAPE_FILTER), ['dn', 'primaryGroupToken']); + if (!$this->ldap->isResource($rr)) { continue; } + /** @var \LDAP\Result $rr */ $er = $this->ldap->firstEntry($cr, $rr); $attrs = $this->ldap->getAttributes($cr, $er); $dn = $this->ldap->getDN($cr, $er); - if(empty($dn)) { + if ($dn === false || $dn === '') { continue; } - $filterPart = '(memberof=' . $dn . ')'; - if(isset($attrs['primaryGroupToken'])) { + $filterPart = '(memberof=' . ldap_escape($dn, '', LDAP_ESCAPE_FILTER) . ')'; + if (isset($attrs['primaryGroupToken'])) { $pgt = $attrs['primaryGroupToken'][0]; - $primaryFilterPart = '(primaryGroupID=' . $pgt .')'; + $primaryFilterPart = '(primaryGroupID=' . ldap_escape($pgt, '', LDAP_ESCAPE_FILTER) . ')'; $filterPart = '(|' . $filterPart . $primaryFilterPart . ')'; } $filter .= $filterPart; @@ -915,10 +910,10 @@ class Wizard extends LDAPUtility { $parts++; } //wrap parts in AND condition - if($parts > 1) { + if ($parts > 1) { $filter = '(&' . $filter . ')'; } - if(empty($filter)) { + if ($filter === '') { $filter = '(objectclass=*)'; } break; @@ -926,27 +921,26 @@ class Wizard extends LDAPUtility { case self::LFILTER_GROUP_LIST: $objcs = $this->configuration->ldapGroupFilterObjectclass; //glue objectclasses - if(is_array($objcs) && count($objcs) > 0) { + if (is_array($objcs) && count($objcs) > 0) { $filter .= '(|'; - foreach($objcs as $objc) { - $filter .= '(objectclass=' . $objc . ')'; + foreach ($objcs as $objc) { + $filter .= '(objectclass=' . ldap_escape($objc, '', LDAP_ESCAPE_FILTER) . ')'; } $filter .= ')'; $parts++; } //glue group memberships $cns = $this->configuration->ldapGroupFilterGroups; - if(is_array($cns) && count($cns) > 0) { + if (is_array($cns) && count($cns) > 0) { $filter .= '(|'; - $base = $this->configuration->ldapBase[0]; - foreach($cns as $cn) { - $filter .= '(cn=' . $cn . ')'; + foreach ($cns as $cn) { + $filter .= '(cn=' . ldap_escape($cn, '', LDAP_ESCAPE_FILTER) . ')'; } $filter .= ')'; } $parts++; //wrap parts in AND condition - if($parts > 1) { + if ($parts > 1) { $filter = '(&' . $filter . ')'; } break; @@ -956,36 +950,39 @@ class Wizard extends LDAPUtility { $loginpart = '=%uid'; $filterUsername = ''; $userAttributes = $this->getUserAttributes(); + if ($userAttributes === false) { + throw new \Exception('Failed to get user attributes'); + } $userAttributes = array_change_key_case(array_flip($userAttributes)); $parts = 0; - if($this->configuration->ldapLoginFilterUsername === '1') { + if ($this->configuration->ldapLoginFilterUsername === '1') { $attr = ''; - if(isset($userAttributes['uid'])) { + if (isset($userAttributes['uid'])) { $attr = 'uid'; - } else if(isset($userAttributes['samaccountname'])) { + } elseif (isset($userAttributes['samaccountname'])) { $attr = 'samaccountname'; - } else if(isset($userAttributes['cn'])) { + } elseif (isset($userAttributes['cn'])) { //fallback $attr = 'cn'; } - if(!empty($attr)) { + if ($attr !== '') { $filterUsername = '(' . $attr . $loginpart . ')'; $parts++; } } $filterEmail = ''; - if($this->configuration->ldapLoginFilterEmail === '1') { + if ($this->configuration->ldapLoginFilterEmail === '1') { $filterEmail = '(|(mailPrimaryAddress=%uid)(mail=%uid))'; $parts++; } $filterAttributes = ''; $attrsToFilter = $this->configuration->ldapLoginFilterAttributes; - if(is_array($attrsToFilter) && count($attrsToFilter) > 0) { + if (is_array($attrsToFilter) && count($attrsToFilter) > 0) { $filterAttributes = '(|'; - foreach($attrsToFilter as $attribute) { + foreach ($attrsToFilter as $attribute) { $filterAttributes .= '(' . $attribute . $loginpart . ')'; } $filterAttributes .= ')'; @@ -993,94 +990,92 @@ class Wizard extends LDAPUtility { } $filterLogin = ''; - if($parts > 1) { + if ($parts > 1) { $filterLogin = '(|'; } $filterLogin .= $filterUsername; $filterLogin .= $filterEmail; $filterLogin .= $filterAttributes; - if($parts > 1) { + if ($parts > 1) { $filterLogin .= ')'; } - $filter = '(&'.$ulf.$filterLogin.')'; + $filter = '(&' . $ulf . $filterLogin . ')'; break; } - \OCP\Util::writeLog('user_ldap', 'Wiz: Final filter '.$filter, \OCP\Util::DEBUG); + $this->logger->debug( + 'Wiz: Final filter ' . $filter, + ['app' => 'user_ldap'] + ); return $filter; } /** * Connects and Binds to an LDAP Server + * * @param int $port the port to connect with * @param bool $tls whether startTLS is to be used - * @param bool $ncc - * @return bool * @throws \Exception */ - private function connectAndBind($port = 389, $tls = false, $ncc = false) { - if($ncc) { - //No certificate check - //FIXME: undo afterwards - putenv('LDAPTLS_REQCERT=never'); - } - + private function connectAndBind(int $port, bool $tls): bool { //connect, does not really trigger any server communication - \OCP\Util::writeLog('user_ldap', 'Wiz: Checking Host Info ', \OCP\Util::DEBUG); $host = $this->configuration->ldapHost; - $hostInfo = parse_url($host); - if(!$hostInfo) { - throw new \Exception($this->l->t('Invalid Host')); + $hostInfo = parse_url((string)$host); + if (!is_string($host) || !$hostInfo) { + throw new \Exception(self::$l->t('Invalid Host')); } - \OCP\Util::writeLog('user_ldap', 'Wiz: Attempting to connect ', \OCP\Util::DEBUG); - $cr = $this->ldap->connect($host, $port); - if(!is_resource($cr)) { - throw new \Exception($this->l->t('Invalid Host')); + $this->logger->debug( + 'Wiz: Attempting to connect', + ['app' => 'user_ldap'] + ); + $cr = $this->ldap->connect($host, (string)$port); + if (!$this->ldap->isResource($cr)) { + throw new \Exception(self::$l->t('Invalid Host')); } + /** @var \LDAP\Connection $cr */ - \OCP\Util::writeLog('user_ldap', 'Wiz: Setting LDAP Options ', \OCP\Util::DEBUG); //set LDAP options $this->ldap->setOption($cr, LDAP_OPT_PROTOCOL_VERSION, 3); $this->ldap->setOption($cr, LDAP_OPT_REFERRALS, 0); $this->ldap->setOption($cr, LDAP_OPT_NETWORK_TIMEOUT, self::LDAP_NW_TIMEOUT); try { - if($tls) { + if ($tls) { $isTlsWorking = @$this->ldap->startTls($cr); - if(!$isTlsWorking) { + if (!$isTlsWorking) { return false; } } - \OCP\Util::writeLog('user_ldap', 'Wiz: Attemping to Bind ', \OCP\Util::DEBUG); + $this->logger->debug( + 'Wiz: Attempting to Bind', + ['app' => 'user_ldap'] + ); //interesting part: do the bind! $login = $this->ldap->bind($cr, $this->configuration->ldapAgentName, $this->configuration->ldapAgentPassword ); $errNo = $this->ldap->errno($cr); - $error = ldap_error($cr); + $error = $this->ldap->error($cr); $this->ldap->unbind($cr); - } catch(ServerNotAvailableException $e) { + } catch (ServerNotAvailableException $e) { return false; } - if($login === true) { - $this->ldap->unbind($cr); - if($ncc) { - throw new \Exception('Certificate cannot be validated.'); - } - \OCP\Util::writeLog('user_ldap', 'Wiz: Bind successful to Port '. $port . ' TLS ' . intval($tls), \OCP\Util::DEBUG); + if ($login === true) { + $this->logger->debug( + 'Wiz: Bind successful to Port ' . $port . ' TLS ' . (int)$tls, + ['app' => 'user_ldap'] + ); return true; } - if($errNo === -1 || ($errNo === 2 && $ncc)) { + if ($errNo === -1) { //host, port or TLS wrong return false; - } else if ($errNo === 2) { - return $this->connectAndBind($port, $tls, true); } throw new \Exception($error, $errNo); } @@ -1088,25 +1083,23 @@ class Wizard extends LDAPUtility { /** * checks whether a valid combination of agent and password has been * provided (either two values or nothing for anonymous connect) - * @return bool, true if everything is fine, false otherwise + * @return bool true if everything is fine, false otherwise */ - private function checkAgentRequirements() { + private function checkAgentRequirements(): bool { $agent = $this->configuration->ldapAgentName; $pwd = $this->configuration->ldapAgentPassword; - return ( (!empty($agent) && !empty($pwd)) - || (empty($agent) && empty($pwd))); + return + ($agent !== '' && $pwd !== '') + || ($agent === '' && $pwd === '') + ; } - /** - * @param array $reqs - * @return bool - */ - private function checkRequirements($reqs) { + private function checkRequirements(array $reqs): bool { $this->checkAgentRequirements(); - foreach($reqs as $option) { + foreach ($reqs as $option) { $value = $this->configuration->$option; - if(empty($value)) { + if (empty($value)) { return false; } } @@ -1119,42 +1112,44 @@ class Wizard extends LDAPUtility { * @param string[] $filters array, the filters that shall be used in the search * @param string $attr the attribute of which a list of values shall be returned * @param int $dnReadLimit the amount of how many DNs should be analyzed. - * The lower, the faster + * The lower, the faster * @param string $maxF string. if not null, this variable will have the filter that - * yields most result entries + * yields most result entries * @return array|false an array with the values on success, false otherwise */ - public function cumulativeSearchOnAttribute($filters, $attr, $dnReadLimit = 3, &$maxF = null) { - $dnRead = array(); - $foundItems = array(); + public function cumulativeSearchOnAttribute(array $filters, string $attr, int $dnReadLimit = 3, ?string &$maxF = null) { + $dnRead = []; + $foundItems = []; $maxEntries = 0; - if(!is_array($this->configuration->ldapBase) + if (!is_array($this->configuration->ldapBase) || !isset($this->configuration->ldapBase[0])) { return false; } $base = $this->configuration->ldapBase[0]; $cr = $this->getConnection(); - if(!$this->ldap->isResource($cr)) { + if (!$this->ldap->isResource($cr)) { return false; } + /** @var \LDAP\Connection $cr */ $lastFilter = null; - if(isset($filters[count($filters)-1])) { - $lastFilter = $filters[count($filters)-1]; + if (isset($filters[count($filters) - 1])) { + $lastFilter = $filters[count($filters) - 1]; } - foreach($filters as $filter) { - if($lastFilter === $filter && count($foundItems) > 0) { + foreach ($filters as $filter) { + if ($lastFilter === $filter && count($foundItems) > 0) { //skip when the filter is a wildcard and results were found continue; } // 20k limit for performance and reason - $rr = $this->ldap->search($cr, $base, $filter, array($attr), 0, 20000); - if(!$this->ldap->isResource($rr)) { + $rr = $this->ldap->search($cr, $base, $filter, [$attr], 0, 20000); + if (!$this->ldap->isResource($rr)) { continue; } + /** @var \LDAP\Result $rr */ $entries = $this->ldap->countEntries($cr, $rr); $getEntryFunc = 'firstEntry'; - if(($entries !== false) && ($entries > 0)) { - if(!is_null($maxF) && $entries > $maxEntries) { + if (($entries !== false) && ($entries > 0)) { + if (!is_null($maxF) && $entries > $maxEntries) { $maxEntries = $entries; $maxF = $filter; } @@ -1162,26 +1157,25 @@ class Wizard extends LDAPUtility { do { $entry = $this->ldap->$getEntryFunc($cr, $rr); $getEntryFunc = 'nextEntry'; - if(!$this->ldap->isResource($entry)) { + if (!$this->ldap->isResource($entry)) { continue 2; } $rr = $entry; //will be expected by nextEntry next round $attributes = $this->ldap->getAttributes($cr, $entry); $dn = $this->ldap->getDN($cr, $entry); - if($dn === false || in_array($dn, $dnRead)) { + if ($attributes === false || $dn === false || in_array($dn, $dnRead)) { continue; } - $newItems = array(); - $state = $this->getAttributeValuesFromEntry($attributes, - $attr, - $newItems); + $newItems = []; + $state = $this->getAttributeValuesFromEntry( + $attributes, + $attr, + $newItems + ); $dnReadCount++; $foundItems = array_merge($foundItems, $newItems); - $this->resultCache[$dn][$attr] = $newItems; $dnRead[] = $dn; - } while(($state === self::LRESULT_PROCESSED_SKIP - || $this->ldap->isResource($entry)) - && ($dnReadLimit === 0 || $dnReadCount < $dnReadLimit)); + } while ($dnReadLimit === 0 || $dnReadCount < $dnReadLimit); } } @@ -1194,20 +1188,20 @@ class Wizard extends LDAPUtility { * @param string $attr the attribute to look for * @param string $dbkey the dbkey of the setting the feature is connected to * @param string $confkey the confkey counterpart for the $dbkey as used in the - * Configuration class + * Configuration class * @param bool $po whether the objectClass with most result entries - * shall be pre-selected via the result - * @return array|false list of found items. + * shall be pre-selected via the result + * @return array list of found items. * @throws \Exception */ - private function determineFeature($objectclasses, $attr, $dbkey, $confkey, $po = false) { + private function determineFeature(array $objectclasses, string $attr, string $dbkey, string $confkey, bool $po = false): array { $cr = $this->getConnection(); - if(!$cr) { + if (!$cr) { throw new \Exception('Could not connect to LDAP'); } $p = 'objectclass='; - foreach($objectclasses as $key => $value) { - $objectclasses[$key] = $p.$value; + foreach ($objectclasses as $key => $value) { + $objectclasses[$key] = $p . $value; } $maxEntryObjC = ''; @@ -1215,10 +1209,10 @@ class Wizard extends LDAPUtility { //When looking for objectclasses, testing few entries is sufficient, $dig = 3; - $availableFeatures = - $this->cumulativeSearchOnAttribute($objectclasses, $attr, - $dig, $maxEntryObjC); - if(is_array($availableFeatures) + $availableFeatures + = $this->cumulativeSearchOnAttribute($objectclasses, $attr, + $dig, $maxEntryObjC); + if (is_array($availableFeatures) && count($availableFeatures) > 0) { natcasesort($availableFeatures); //natcasesort keeps indices, but we must get rid of them for proper @@ -1229,10 +1223,10 @@ class Wizard extends LDAPUtility { } $setFeatures = $this->configuration->$confkey; - if(is_array($setFeatures) && !empty($setFeatures)) { + if (is_array($setFeatures) && !empty($setFeatures)) { //something is already configured? pre-select it. $this->result->addChange($dbkey, $setFeatures); - } else if($po && !empty($maxEntryObjC)) { + } elseif ($po && $maxEntryObjC !== '') { //pre-select objectclass with most result entries $maxEntryObjC = str_replace($p, '', $maxEntryObjC); $this->applyFind($dbkey, $maxEntryObjC); @@ -1244,28 +1238,27 @@ class Wizard extends LDAPUtility { /** * appends a list of values fr - * @param resource $result the return value from ldap_get_attributes + * @param array $result the return value from ldap_get_attributes * @param string $attribute the attribute values to look for * @param array &$known new values will be appended here - * @return int, state on of the class constants LRESULT_PROCESSED_OK, - * LRESULT_PROCESSED_INVALID or LRESULT_PROCESSED_SKIP + * @return int state on of the class constants LRESULT_PROCESSED_OK, + * LRESULT_PROCESSED_INVALID or LRESULT_PROCESSED_SKIP */ - private function getAttributeValuesFromEntry($result, $attribute, &$known) { - if(!is_array($result) - || !isset($result['count']) + private function getAttributeValuesFromEntry(array $result, string $attribute, array &$known): int { + if (!isset($result['count']) || !$result['count'] > 0) { return self::LRESULT_PROCESSED_INVALID; } // strtolower on all keys for proper comparison - $result = \OCP\Util::mb_array_change_key_case($result); + $result = Util::mb_array_change_key_case($result); $attribute = strtolower($attribute); - if(isset($result[$attribute])) { - foreach($result[$attribute] as $key => $val) { - if($key === 'count') { + if (isset($result[$attribute])) { + foreach ($result[$attribute] as $key => $val) { + if ($key === 'count') { continue; } - if(!in_array($val, $known)) { + if (!in_array($val, $known)) { $known[] = $val; } } @@ -1276,10 +1269,10 @@ class Wizard extends LDAPUtility { } /** - * @return bool|mixed + * @return \LDAP\Connection|false a link resource on success, otherwise false */ - private function getConnection() { - if(!is_null($this->cr)) { + private function getConnection(): \LDAP\Connection|false { + if (!is_null($this->cr)) { return $this->cr; } @@ -1288,18 +1281,22 @@ class Wizard extends LDAPUtility { $this->configuration->ldapPort ); + if ($cr === false) { + return false; + } + $this->ldap->setOption($cr, LDAP_OPT_PROTOCOL_VERSION, 3); $this->ldap->setOption($cr, LDAP_OPT_REFERRALS, 0); $this->ldap->setOption($cr, LDAP_OPT_NETWORK_TIMEOUT, self::LDAP_NW_TIMEOUT); - if($this->configuration->ldapTLS === 1) { + if ($this->configuration->ldapTLS) { $this->ldap->startTls($cr); } $lo = @$this->ldap->bind($cr, - $this->configuration->ldapAgentName, - $this->configuration->ldapAgentPassword); - if($lo === true) { - $this->$cr = $cr; + $this->configuration->ldapAgentName, + $this->configuration->ldapAgentPassword); + if ($lo === true) { + $this->cr = $cr; return $cr; } @@ -1307,48 +1304,48 @@ class Wizard extends LDAPUtility { } /** - * @return array + * @return array<array{port:int,tls:bool}> */ - private function getDefaultLdapPortSettings() { - static $settings = array( - array('port' => 7636, 'tls' => false), - array('port' => 636, 'tls' => false), - array('port' => 7389, 'tls' => true), - array('port' => 389, 'tls' => true), - array('port' => 7389, 'tls' => false), - array('port' => 389, 'tls' => false), - ); + private function getDefaultLdapPortSettings(): array { + static $settings = [ + ['port' => 7636, 'tls' => false], + ['port' => 636, 'tls' => false], + ['port' => 7389, 'tls' => true], + ['port' => 389, 'tls' => true], + ['port' => 7389, 'tls' => false], + ['port' => 389, 'tls' => false], + ]; return $settings; } /** - * @return array + * @return array<array{port:int,tls:bool}> */ - private function getPortSettingsToTry() { + private function getPortSettingsToTry(): array { //389 ← LDAP / Unencrypted or StartTLS //636 ← LDAPS / SSL //7xxx ← UCS. need to be checked first, because both ports may be open $host = $this->configuration->ldapHost; - $port = intval($this->configuration->ldapPort); - $portSettings = array(); + $port = (int)$this->configuration->ldapPort; + $portSettings = []; //In case the port is already provided, we will check this first - if($port > 0) { + if ($port > 0) { $hostInfo = parse_url($host); - if(!(is_array($hostInfo) + if (!(is_array($hostInfo) && isset($hostInfo['scheme']) && stripos($hostInfo['scheme'], 'ldaps') !== false)) { - $portSettings[] = array('port' => $port, 'tls' => true); + $portSettings[] = ['port' => $port, 'tls' => true]; } - $portSettings[] =array('port' => $port, 'tls' => false); + $portSettings[] = ['port' => $port, 'tls' => false]; + } elseif ($this->configuration->usesLdapi()) { + $portSettings[] = ['port' => 0, 'tls' => false]; } //default ports $portSettings = array_merge($portSettings, - $this->getDefaultLdapPortSettings()); + $this->getDefaultLdapPortSettings()); return $portSettings; } - - } diff --git a/apps/user_ldap/lib/WizardResult.php b/apps/user_ldap/lib/WizardResult.php new file mode 100644 index 00000000000..d6fd67d4204 --- /dev/null +++ b/apps/user_ldap/lib/WizardResult.php @@ -0,0 +1,57 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\User_LDAP; + +class WizardResult { + protected $changes = []; + protected $options = []; + protected $markedChange = false; + + /** + * @param string $key + * @param mixed $value + */ + public function addChange($key, $value) { + $this->changes[$key] = $value; + } + + + public function markChange() { + $this->markedChange = true; + } + + /** + * @param string $key + * @param array|string $values + */ + public function addOptions($key, $values) { + if (!is_array($values)) { + $values = [$values]; + } + $this->options[$key] = $values; + } + + /** + * @return bool + */ + public function hasChanges() { + return (count($this->changes) > 0 || $this->markedChange); + } + + /** + * @return array + */ + public function getResultArray() { + $result = []; + $result['changes'] = $this->changes; + if (count($this->options) > 0) { + $result['options'] = $this->options; + } + return $result; + } +} diff --git a/apps/user_ldap/lib/access.php b/apps/user_ldap/lib/access.php deleted file mode 100644 index 135eca1e625..00000000000 --- a/apps/user_ldap/lib/access.php +++ /dev/null @@ -1,1760 +0,0 @@ -<?php -/** - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Andreas Fischer <bantu@owncloud.com> - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Benjamin Diele <benjamin@diele.be> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Donald Buczek <buczek@molgen.mpg.de> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lorenzo M. Catucci <lorenzo@sancho.ccd.uniroma2.it> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Lyonel Vincent <lyonel@ezix.org> - * @author Mario Kolling <mario.kolling@serpro.gov.br> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nicolas Grekas <nicolas.grekas@gmail.com> - * @author Ralph Krimmel <rkrimme1@gwdg.de> - * @author Renaud Fortier <Renaud.Fortier@fsaa.ulaval.ca> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OCA\user_ldap\lib\user\OfflineUser; -use OCA\User_LDAP\Mapping\AbstractMapping; - -/** - * Class Access - * @package OCA\user_ldap\lib - */ -class Access extends LDAPUtility implements user\IUserTools { - /** - * @var \OCA\user_ldap\lib\Connection - */ - public $connection; - public $userManager; - //never ever check this var directly, always use getPagedSearchResultState - protected $pagedSearchedSuccessful; - - /** - * @var string[] $cookies an array of returned Paged Result cookies - */ - protected $cookies = array(); - - /** - * @var string $lastCookie the last cookie returned from a Paged Results - * operation, defaults to an empty string - */ - protected $lastCookie = ''; - - /** - * @var AbstractMapping $userMapper - */ - protected $userMapper; - - /** - * @var AbstractMapping $userMapper - */ - protected $groupMapper; - - public function __construct(Connection $connection, ILDAPWrapper $ldap, - user\Manager $userManager) { - parent::__construct($ldap); - $this->connection = $connection; - $this->userManager = $userManager; - $this->userManager->setLdapAccess($this); - } - - /** - * sets the User Mapper - * @param AbstractMapping $mapper - */ - public function setUserMapper(AbstractMapping $mapper) { - $this->userMapper = $mapper; - } - - /** - * returns the User Mapper - * @throws \Exception - * @return AbstractMapping - */ - public function getUserMapper() { - if(is_null($this->userMapper)) { - throw new \Exception('UserMapper was not assigned to this Access instance.'); - } - return $this->userMapper; - } - - /** - * sets the Group Mapper - * @param AbstractMapping $mapper - */ - public function setGroupMapper(AbstractMapping $mapper) { - $this->groupMapper = $mapper; - } - - /** - * returns the Group Mapper - * @throws \Exception - * @return AbstractMapping - */ - public function getGroupMapper() { - if(is_null($this->groupMapper)) { - throw new \Exception('GroupMapper was not assigned to this Access instance.'); - } - return $this->groupMapper; - } - - /** - * @return bool - */ - private function checkConnection() { - return ($this->connection instanceof Connection); - } - - /** - * returns the Connection instance - * @return \OCA\user_ldap\lib\Connection - */ - public function getConnection() { - return $this->connection; - } - - /** - * reads a given attribute for an LDAP record identified by a DN - * @param string $dn the record in question - * @param string $attr the attribute that shall be retrieved - * if empty, just check the record's existence - * @param string $filter - * @return array|false an array of values on success or an empty - * array if $attr is empty, false otherwise - */ - public function readAttribute($dn, $attr, $filter = 'objectClass=*') { - if(!$this->checkConnection()) { - \OCP\Util::writeLog('user_ldap', - 'No LDAP Connector assigned, access impossible for readAttribute.', - \OCP\Util::WARN); - return false; - } - $cr = $this->connection->getConnectionResource(); - if(!$this->ldap->isResource($cr)) { - //LDAP not available - \OCP\Util::writeLog('user_ldap', 'LDAP resource not available.', \OCP\Util::DEBUG); - return false; - } - //Cancel possibly running Paged Results operation, otherwise we run in - //LDAP protocol errors - $this->abandonPagedSearch(); - // openLDAP requires that we init a new Paged Search. Not needed by AD, - // but does not hurt either. - $pagingSize = intval($this->connection->ldapPagingSize); - // 0 won't result in replies, small numbers may leave out groups - // (cf. #12306), 500 is default for paging and should work everywhere. - $maxResults = $pagingSize > 20 ? $pagingSize : 500; - $this->initPagedSearch($filter, array($dn), array($attr), $maxResults, 0); - $dn = $this->DNasBaseParameter($dn); - $rr = @$this->ldap->read($cr, $dn, $filter, array($attr)); - if(!$this->ldap->isResource($rr)) { - if(!empty($attr)) { - //do not throw this message on userExists check, irritates - \OCP\Util::writeLog('user_ldap', 'readAttribute failed for DN '.$dn, \OCP\Util::DEBUG); - } - //in case an error occurs , e.g. object does not exist - return false; - } - if (empty($attr) && ($filter === 'objectclass=*' || $this->ldap->countEntries($cr, $rr) === 1)) { - \OCP\Util::writeLog('user_ldap', 'readAttribute: '.$dn.' found', \OCP\Util::DEBUG); - return array(); - } - $er = $this->ldap->firstEntry($cr, $rr); - if(!$this->ldap->isResource($er)) { - //did not match the filter, return false - return false; - } - //LDAP attributes are not case sensitive - $result = \OCP\Util::mb_array_change_key_case( - $this->ldap->getAttributes($cr, $er), MB_CASE_LOWER, 'UTF-8'); - $attr = mb_strtolower($attr, 'UTF-8'); - - if(isset($result[$attr]) && $result[$attr]['count'] > 0) { - $values = array(); - for($i=0;$i<$result[$attr]['count'];$i++) { - if($this->resemblesDN($attr)) { - $values[] = $this->sanitizeDN($result[$attr][$i]); - } elseif(strtolower($attr) === 'objectguid' || strtolower($attr) === 'guid') { - $values[] = $this->convertObjectGUID2Str($result[$attr][$i]); - } else { - $values[] = $result[$attr][$i]; - } - } - return $values; - } - \OCP\Util::writeLog('user_ldap', 'Requested attribute '.$attr.' not found for '.$dn, \OCP\Util::DEBUG); - return false; - } - - /** - * checks whether the given attributes value is probably a DN - * @param string $attr the attribute in question - * @return boolean if so true, otherwise false - */ - private function resemblesDN($attr) { - $resemblingAttributes = array( - 'dn', - 'uniquemember', - 'member', - // memberOf is an "operational" attribute, without a definition in any RFC - 'memberof' - ); - return in_array($attr, $resemblingAttributes); - } - - /** - * checks whether the given string is probably a DN - * @param string $string - * @return boolean - */ - public function stringResemblesDN($string) { - $r = $this->ldap->explodeDN($string, 0); - // if exploding a DN succeeds and does not end up in - // an empty array except for $r[count] being 0. - return (is_array($r) && count($r) > 1); - } - - /** - * sanitizes a DN received from the LDAP server - * @param array $dn the DN in question - * @return array the sanitized DN - */ - private function sanitizeDN($dn) { - //treating multiple base DNs - if(is_array($dn)) { - $result = array(); - foreach($dn as $singleDN) { - $result[] = $this->sanitizeDN($singleDN); - } - return $result; - } - - //OID sometimes gives back DNs with whitespace after the comma - // a la "uid=foo, cn=bar, dn=..." We need to tackle this! - $dn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn); - - //make comparisons and everything work - $dn = mb_strtolower($dn, 'UTF-8'); - - //escape DN values according to RFC 2253 – this is already done by ldap_explode_dn - //to use the DN in search filters, \ needs to be escaped to \5c additionally - //to use them in bases, we convert them back to simple backslashes in readAttribute() - $replacements = array( - '\,' => '\5c2C', - '\=' => '\5c3D', - '\+' => '\5c2B', - '\<' => '\5c3C', - '\>' => '\5c3E', - '\;' => '\5c3B', - '\"' => '\5c22', - '\#' => '\5c23', - '(' => '\28', - ')' => '\29', - '*' => '\2A', - ); - $dn = str_replace(array_keys($replacements), array_values($replacements), $dn); - - return $dn; - } - - /** - * returns a DN-string that is cleaned from not domain parts, e.g. - * cn=foo,cn=bar,dc=foobar,dc=server,dc=org - * becomes dc=foobar,dc=server,dc=org - * @param string $dn - * @return string - */ - public function getDomainDNFromDN($dn) { - $allParts = $this->ldap->explodeDN($dn, 0); - if($allParts === false) { - //not a valid DN - return ''; - } - $domainParts = array(); - $dcFound = false; - foreach($allParts as $part) { - if(!$dcFound && strpos($part, 'dc=') === 0) { - $dcFound = true; - } - if($dcFound) { - $domainParts[] = $part; - } - } - $domainDN = implode(',', $domainParts); - return $domainDN; - } - - /** - * returns the LDAP DN for the given internal ownCloud name of the group - * @param string $name the ownCloud name in question - * @return string|false LDAP DN on success, otherwise false - */ - public function groupname2dn($name) { - return $this->groupMapper->getDNbyName($name); - } - - /** - * returns the LDAP DN for the given internal ownCloud name of the user - * @param string $name the ownCloud name in question - * @return string|false with the LDAP DN on success, otherwise false - */ - public function username2dn($name) { - $fdn = $this->userMapper->getDNbyName($name); - - //Check whether the DN belongs to the Base, to avoid issues on multi- - //server setups - if(is_string($fdn) && $this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { - return $fdn; - } - - return false; - } - - /** - public function ocname2dn($name, $isUser) { - * returns the internal ownCloud name for the given LDAP DN of the group, false on DN outside of search DN or failure - * @param string $fdn the dn of the group object - * @param string $ldapName optional, the display name of the object - * @return string|false with the name to use in ownCloud, false on DN outside of search DN - */ - public function dn2groupname($fdn, $ldapName = null) { - //To avoid bypassing the base DN settings under certain circumstances - //with the group support, check whether the provided DN matches one of - //the given Bases - if(!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseGroups)) { - return false; - } - - return $this->dn2ocname($fdn, $ldapName, false); - } - - /** - * accepts an array of group DNs and tests whether they match the user - * filter by doing read operations against the group entries. Returns an - * array of DNs that match the filter. - * - * @param string[] $groupDNs - * @return string[] - */ - public function groupsMatchFilter($groupDNs) { - $validGroupDNs = []; - foreach($groupDNs as $dn) { - $cacheKey = 'groupsMatchFilter-'.$dn; - if($this->connection->isCached($cacheKey)) { - if($this->connection->getFromCache($cacheKey)) { - $validGroupDNs[] = $dn; - } - continue; - } - - // Check the base DN first. If this is not met already, we don't - // need to ask the server at all. - if(!$this->isDNPartOfBase($dn, $this->connection->ldapBaseGroups)) { - $this->connection->writeToCache($cacheKey, false); - continue; - } - - $result = $this->readAttribute($dn, 'cn', $this->connection->ldapGroupFilter); - if(is_array($result)) { - $this->connection->writeToCache($cacheKey, true); - $validGroupDNs[] = $dn; - } else { - $this->connection->writeToCache($cacheKey, false); - } - - } - return $validGroupDNs; - } - - /** - * returns the internal ownCloud name for the given LDAP DN of the user, false on DN outside of search DN or failure - * @param string $dn the dn of the user object - * @param string $ldapName optional, the display name of the object - * @return string|false with with the name to use in ownCloud - */ - public function dn2username($fdn, $ldapName = null) { - //To avoid bypassing the base DN settings under certain circumstances - //with the group support, check whether the provided DN matches one of - //the given Bases - if(!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { - return false; - } - - return $this->dn2ocname($fdn, $ldapName, true); - } - - /** - * returns an internal ownCloud name for the given LDAP DN, false on DN outside of search DN - * @param string $dn the dn of the user object - * @param string $ldapName optional, the display name of the object - * @param bool $isUser optional, whether it is a user object (otherwise group assumed) - * @return string|false with with the name to use in ownCloud - */ - public function dn2ocname($fdn, $ldapName = null, $isUser = true) { - if($isUser) { - $mapper = $this->getUserMapper(); - $nameAttribute = $this->connection->ldapUserDisplayName; - } else { - $mapper = $this->getGroupMapper(); - $nameAttribute = $this->connection->ldapGroupDisplayName; - } - - //let's try to retrieve the ownCloud name from the mappings table - $ocName = $mapper->getNameByDN($fdn); - if(is_string($ocName)) { - return $ocName; - } - - //second try: get the UUID and check if it is known. Then, update the DN and return the name. - $uuid = $this->getUUID($fdn, $isUser); - if(is_string($uuid)) { - $ocName = $mapper->getNameByUUID($uuid); - if(is_string($ocName)) { - $mapper->setDNbyUUID($fdn, $uuid); - return $ocName; - } - } else { - //If the UUID can't be detected something is foul. - \OCP\Util::writeLog('user_ldap', 'Cannot determine UUID for '.$fdn.'. Skipping.', \OCP\Util::INFO); - return false; - } - - if(is_null($ldapName)) { - $ldapName = $this->readAttribute($fdn, $nameAttribute); - if(!isset($ldapName[0]) && empty($ldapName[0])) { - \OCP\Util::writeLog('user_ldap', 'No or empty name for '.$fdn.'.', \OCP\Util::INFO); - return false; - } - $ldapName = $ldapName[0]; - } - - if($isUser) { - $usernameAttribute = $this->connection->ldapExpertUsernameAttr; - if(!empty($usernameAttribute)) { - $username = $this->readAttribute($fdn, $usernameAttribute); - $username = $username[0]; - } else { - $username = $uuid; - } - $intName = $this->sanitizeUsername($username); - } else { - $intName = $ldapName; - } - - //a new user/group! Add it only if it doesn't conflict with other backend's users or existing groups - //disabling Cache is required to avoid that the new user is cached as not-existing in fooExists check - //NOTE: mind, disabling cache affects only this instance! Using it - // outside of core user management will still cache the user as non-existing. - $originalTTL = $this->connection->ldapCacheTTL; - $this->connection->setConfiguration(array('ldapCacheTTL' => 0)); - if(($isUser && !\OCP\User::userExists($intName)) - || (!$isUser && !\OC_Group::groupExists($intName))) { - if($mapper->map($fdn, $intName, $uuid)) { - $this->connection->setConfiguration(array('ldapCacheTTL' => $originalTTL)); - return $intName; - } - } - $this->connection->setConfiguration(array('ldapCacheTTL' => $originalTTL)); - - $altName = $this->createAltInternalOwnCloudName($intName, $isUser); - if(is_string($altName) && $mapper->map($fdn, $altName, $uuid)) { - return $altName; - } - - //if everything else did not help.. - \OCP\Util::writeLog('user_ldap', 'Could not create unique name for '.$fdn.'.', \OCP\Util::INFO); - return false; - } - - /** - * gives back the user names as they are used ownClod internally - * @param array $ldapUsers as returned by fetchList() - * @return array an array with the user names to use in ownCloud - * - * gives back the user names as they are used ownClod internally - */ - public function ownCloudUserNames($ldapUsers) { - return $this->ldap2ownCloudNames($ldapUsers, true); - } - - /** - * gives back the group names as they are used ownClod internally - * @param array $ldapGroups as returned by fetchList() - * @return array an array with the group names to use in ownCloud - * - * gives back the group names as they are used ownClod internally - */ - public function ownCloudGroupNames($ldapGroups) { - return $this->ldap2ownCloudNames($ldapGroups, false); - } - - /** - * @param array $ldapObjects as returned by fetchList() - * @param bool $isUsers - * @return array - */ - private function ldap2ownCloudNames($ldapObjects, $isUsers) { - if($isUsers) { - $nameAttribute = $this->connection->ldapUserDisplayName; - $sndAttribute = $this->connection->ldapUserDisplayName2; - } else { - $nameAttribute = $this->connection->ldapGroupDisplayName; - } - $ownCloudNames = array(); - - foreach($ldapObjects as $ldapObject) { - $nameByLDAP = null; - if( isset($ldapObject[$nameAttribute]) - && is_array($ldapObject[$nameAttribute]) - && isset($ldapObject[$nameAttribute][0]) - ) { - // might be set, but not necessarily. if so, we use it. - $nameByLDAP = $ldapObject[$nameAttribute][0]; - } - - $ocName = $this->dn2ocname($ldapObject['dn'][0], $nameByLDAP, $isUsers); - if($ocName) { - $ownCloudNames[] = $ocName; - if($isUsers) { - //cache the user names so it does not need to be retrieved - //again later (e.g. sharing dialogue). - if(is_null($nameByLDAP)) { - continue; - } - $sndName = isset($ldapObject[$sndAttribute][0]) - ? $ldapObject[$sndAttribute][0] : ''; - $this->cacheUserDisplayName($ocName, $nameByLDAP, $sndName); - } - } - } - return $ownCloudNames; - } - - /** - * caches the user display name - * @param string $ocName the internal ownCloud username - * @param string|false $home the home directory path - */ - public function cacheUserHome($ocName, $home) { - $cacheKey = 'getHome'.$ocName; - $this->connection->writeToCache($cacheKey, $home); - } - - /** - * caches a user as existing - * @param string $ocName the internal ownCloud username - */ - public function cacheUserExists($ocName) { - $this->connection->writeToCache('userExists'.$ocName, true); - } - - /** - * caches the user display name - * @param string $ocName the internal ownCloud username - * @param string $displayName the display name - * @param string $displayName2 the second display name - */ - public function cacheUserDisplayName($ocName, $displayName, $displayName2 = '') { - $user = $this->userManager->get($ocName); - $displayName = $user->composeAndStoreDisplayName($displayName, $displayName2); - $cacheKeyTrunk = 'getDisplayName'; - $this->connection->writeToCache($cacheKeyTrunk.$ocName, $displayName); - } - - /** - * creates a unique name for internal ownCloud use for users. Don't call it directly. - * @param string $name the display name of the object - * @return string|false with with the name to use in ownCloud or false if unsuccessful - * - * Instead of using this method directly, call - * createAltInternalOwnCloudName($name, true) - */ - private function _createAltInternalOwnCloudNameForUsers($name) { - $attempts = 0; - //while loop is just a precaution. If a name is not generated within - //20 attempts, something else is very wrong. Avoids infinite loop. - while($attempts < 20){ - $altName = $name . '_' . rand(1000,9999); - if(!\OCP\User::userExists($altName)) { - return $altName; - } - $attempts++; - } - return false; - } - - /** - * creates a unique name for internal ownCloud use for groups. Don't call it directly. - * @param string $name the display name of the object - * @return string|false with with the name to use in ownCloud or false if unsuccessful. - * - * Instead of using this method directly, call - * createAltInternalOwnCloudName($name, false) - * - * Group names are also used as display names, so we do a sequential - * numbering, e.g. Developers_42 when there are 41 other groups called - * "Developers" - */ - private function _createAltInternalOwnCloudNameForGroups($name) { - $usedNames = $this->groupMapper->getNamesBySearch($name.'_%'); - if(!($usedNames) || count($usedNames) === 0) { - $lastNo = 1; //will become name_2 - } else { - natsort($usedNames); - $lastName = array_pop($usedNames); - $lastNo = intval(substr($lastName, strrpos($lastName, '_') + 1)); - } - $altName = $name.'_'.strval($lastNo+1); - unset($usedNames); - - $attempts = 1; - while($attempts < 21){ - // Check to be really sure it is unique - // while loop is just a precaution. If a name is not generated within - // 20 attempts, something else is very wrong. Avoids infinite loop. - if(!\OC_Group::groupExists($altName)) { - return $altName; - } - $altName = $name . '_' . ($lastNo + $attempts); - $attempts++; - } - return false; - } - - /** - * creates a unique name for internal ownCloud use. - * @param string $name the display name of the object - * @param boolean $isUser whether name should be created for a user (true) or a group (false) - * @return string|false with with the name to use in ownCloud or false if unsuccessful - */ - private function createAltInternalOwnCloudName($name, $isUser) { - $originalTTL = $this->connection->ldapCacheTTL; - $this->connection->setConfiguration(array('ldapCacheTTL' => 0)); - if($isUser) { - $altName = $this->_createAltInternalOwnCloudNameForUsers($name); - } else { - $altName = $this->_createAltInternalOwnCloudNameForGroups($name); - } - $this->connection->setConfiguration(array('ldapCacheTTL' => $originalTTL)); - - return $altName; - } - - /** - * fetches a list of users according to a provided loginName and utilizing - * the login filter. - * - * @param string $loginName - * @param array $attributes optional, list of attributes to read - * @return array - */ - public function fetchUsersByLoginName($loginName, $attributes = array('dn')) { - $loginName = $this->escapeFilterPart($loginName); - $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); - $users = $this->fetchListOfUsers($filter, $attributes); - return $users; - } - - /** - * counts the number of users according to a provided loginName and - * utilizing the login filter. - * - * @param string $loginName - * @return array - */ - public function countUsersByLoginName($loginName) { - $loginName = $this->escapeFilterPart($loginName); - $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); - $users = $this->countUsers($filter); - return $users; - } - - /** - * @param string $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset - * @return array - */ - public function fetchListOfUsers($filter, $attr, $limit = null, $offset = null) { - $ldapRecords = $this->searchUsers($filter, $attr, $limit, $offset); - $this->batchApplyUserAttributes($ldapRecords); - return $this->fetchList($ldapRecords, (count($attr) > 1)); - } - - /** - * provided with an array of LDAP user records the method will fetch the - * user object and requests it to process the freshly fetched attributes and - * and their values - * @param array $ldapRecords - */ - public function batchApplyUserAttributes(array $ldapRecords){ - $displayNameAttribute = strtolower($this->connection->ldapUserDisplayName); - foreach($ldapRecords as $userRecord) { - if(!isset($userRecord[$displayNameAttribute])) { - // displayName is obligatory - continue; - } - $ocName = $this->dn2ocname($userRecord['dn'][0]); - if($ocName === false) { - continue; - } - $this->cacheUserExists($ocName); - $user = $this->userManager->get($ocName); - if($user instanceof OfflineUser) { - $user->unmark(); - $user = $this->userManager->get($ocName); - } - $user->processAttributes($userRecord); - } - } - - /** - * @param string $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset - * @return array - */ - public function fetchListOfGroups($filter, $attr, $limit = null, $offset = null) { - return $this->fetchList($this->searchGroups($filter, $attr, $limit, $offset), (count($attr) > 1)); - } - - /** - * @param array $list - * @param bool $manyAttributes - * @return array - */ - private function fetchList($list, $manyAttributes) { - if(is_array($list)) { - if($manyAttributes) { - return $list; - } else { - $list = array_reduce($list, function($carry, $item) { - $attribute = array_keys($item)[0]; - $carry[] = $item[$attribute][0]; - return $carry; - }, array()); - return array_unique($list, SORT_LOCALE_STRING); - } - } - - //error cause actually, maybe throw an exception in future. - return array(); - } - - /** - * executes an LDAP search, optimized for Users - * @param string $filter the LDAP filter for the search - * @param string|string[] $attr optional, when a certain attribute shall be filtered out - * @param integer $limit - * @param integer $offset - * @return array with the search result - * - * Executes an LDAP search - */ - public function searchUsers($filter, $attr = null, $limit = null, $offset = null) { - return $this->search($filter, $this->connection->ldapBaseUsers, $attr, $limit, $offset); - } - - /** - * @param string $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset - * @return false|int - */ - public function countUsers($filter, $attr = array('dn'), $limit = null, $offset = null) { - return $this->count($filter, $this->connection->ldapBaseUsers, $attr, $limit, $offset); - } - - /** - * executes an LDAP search, optimized for Groups - * @param string $filter the LDAP filter for the search - * @param string|string[] $attr optional, when a certain attribute shall be filtered out - * @param integer $limit - * @param integer $offset - * @return array with the search result - * - * Executes an LDAP search - */ - public function searchGroups($filter, $attr = null, $limit = null, $offset = null) { - return $this->search($filter, $this->connection->ldapBaseGroups, $attr, $limit, $offset); - } - - /** - * returns the number of available groups - * @param string $filter the LDAP search filter - * @param string[] $attr optional - * @param int|null $limit - * @param int|null $offset - * @return int|bool - */ - public function countGroups($filter, $attr = array('dn'), $limit = null, $offset = null) { - return $this->count($filter, $this->connection->ldapBaseGroups, $attr, $limit, $offset); - } - - /** - * returns the number of available objects on the base DN - * - * @param int|null $limit - * @param int|null $offset - * @return int|bool - */ - public function countObjects($limit = null, $offset = null) { - return $this->count('objectclass=*', $this->connection->ldapBase, array('dn'), $limit, $offset); - } - - /** - * retrieved. Results will according to the order in the array. - * @param int $limit optional, maximum results to be counted - * @param int $offset optional, a starting point - * @return array|false array with the search result as first value and pagedSearchOK as - * second | false if not successful - */ - private function executeSearch($filter, $base, &$attr = null, $limit = null, $offset = null) { - if(!is_null($attr) && !is_array($attr)) { - $attr = array(mb_strtolower($attr, 'UTF-8')); - } - - // See if we have a resource, in case not cancel with message - $cr = $this->connection->getConnectionResource(); - if(!$this->ldap->isResource($cr)) { - // Seems like we didn't find any resource. - // Return an empty array just like before. - \OCP\Util::writeLog('user_ldap', 'Could not search, because resource is missing.', \OCP\Util::DEBUG); - return false; - } - - //check whether paged search should be attempted - $pagedSearchOK = $this->initPagedSearch($filter, $base, $attr, intval($limit), $offset); - - $linkResources = array_pad(array(), count($base), $cr); - $sr = $this->ldap->search($linkResources, $base, $filter, $attr); - $error = $this->ldap->errno($cr); - if(!is_array($sr) || $error !== 0) { - \OCP\Util::writeLog('user_ldap', - 'Error when searching: '.$this->ldap->error($cr). - ' code '.$this->ldap->errno($cr), - \OCP\Util::ERROR); - \OCP\Util::writeLog('user_ldap', 'Attempt for Paging? '.print_r($pagedSearchOK, true), \OCP\Util::ERROR); - return false; - } - - return array($sr, $pagedSearchOK); - } - - /** - * processes an LDAP paged search operation - * @param array $sr the array containing the LDAP search resources - * @param string $filter the LDAP filter for the search - * @param array $base an array containing the LDAP subtree(s) that shall be searched - * @param int $iFoundItems number of results in the search operation - * @param int $limit maximum results to be counted - * @param int $offset a starting point - * @param bool $pagedSearchOK whether a paged search has been executed - * @param bool $skipHandling required for paged search when cookies to - * prior results need to be gained - * @return bool cookie validity, true if we have more pages, false otherwise. - */ - private function processPagedSearchStatus($sr, $filter, $base, $iFoundItems, $limit, $offset, $pagedSearchOK, $skipHandling) { - $cookie = null; - if($pagedSearchOK) { - $cr = $this->connection->getConnectionResource(); - foreach($sr as $key => $res) { - if($this->ldap->controlPagedResultResponse($cr, $res, $cookie)) { - $this->setPagedResultCookie($base[$key], $filter, $limit, $offset, $cookie); - } - } - - //browsing through prior pages to get the cookie for the new one - if($skipHandling) { - return; - } - // if count is bigger, then the server does not support - // paged search. Instead, he did a normal search. We set a - // flag here, so the callee knows how to deal with it. - if($iFoundItems <= $limit) { - $this->pagedSearchedSuccessful = true; - } - } else { - if(!is_null($limit)) { - \OCP\Util::writeLog('user_ldap', 'Paged search was not available', \OCP\Util::INFO); - } - } - /* ++ Fixing RHDS searches with pages with zero results ++ - * Return cookie status. If we don't have more pages, with RHDS - * cookie is null, with openldap cookie is an empty string and - * to 386ds '0' is a valid cookie. Even if $iFoundItems == 0 - */ - return !empty($cookie) || $cookie === '0'; - } - - /** - * executes an LDAP search, but counts the results only - * @param string $filter the LDAP filter for the search - * @param array $base an array containing the LDAP subtree(s) that shall be searched - * @param string|string[] $attr optional, array, one or more attributes that shall be - * retrieved. Results will according to the order in the array. - * @param int $limit optional, maximum results to be counted - * @param int $offset optional, a starting point - * @param bool $skipHandling indicates whether the pages search operation is - * completed - * @return int|false Integer or false if the search could not be initialized - * - */ - private function count($filter, $base, $attr = null, $limit = null, $offset = null, $skipHandling = false) { - \OCP\Util::writeLog('user_ldap', 'Count filter: '.print_r($filter, true), \OCP\Util::DEBUG); - - $limitPerPage = intval($this->connection->ldapPagingSize); - if(!is_null($limit) && $limit < $limitPerPage && $limit > 0) { - $limitPerPage = $limit; - } - - $counter = 0; - $count = null; - $this->connection->getConnectionResource(); - - do { - $search = $this->executeSearch($filter, $base, $attr, - $limitPerPage, $offset); - if($search === false) { - return $counter > 0 ? $counter : false; - } - list($sr, $pagedSearchOK) = $search; - - /* ++ Fixing RHDS searches with pages with zero results ++ - * countEntriesInSearchResults() method signature changed - * by removing $limit and &$hasHitLimit parameters - */ - $count = $this->countEntriesInSearchResults($sr); - $counter += $count; - - $hasMorePages = $this->processPagedSearchStatus($sr, $filter, $base, $count, $limitPerPage, - $offset, $pagedSearchOK, $skipHandling); - $offset += $limitPerPage; - /* ++ Fixing RHDS searches with pages with zero results ++ - * Continue now depends on $hasMorePages value - */ - $continue = $pagedSearchOK && $hasMorePages; - } while($continue && (is_null($limit) || $limit <= 0 || $limit > $counter)); - - return $counter; - } - - /** - * @param array $searchResults - * @return int - */ - private function countEntriesInSearchResults($searchResults) { - $cr = $this->connection->getConnectionResource(); - $counter = 0; - - foreach($searchResults as $res) { - $count = intval($this->ldap->countEntries($cr, $res)); - $counter += $count; - } - - return $counter; - } - - /** - * Executes an LDAP search - * @param string $filter the LDAP filter for the search - * @param array $base an array containing the LDAP subtree(s) that shall be searched - * @param string|string[] $attr optional, array, one or more attributes that shall be - * @param int $limit - * @param int $offset - * @param bool $skipHandling - * @return array with the search result - */ - private function search($filter, $base, $attr = null, $limit = null, $offset = null, $skipHandling = false) { - if($limit <= 0) { - //otherwise search will fail - $limit = null; - } - - /* ++ Fixing RHDS searches with pages with zero results ++ - * As we can have pages with zero results and/or pages with less - * than $limit results but with a still valid server 'cookie', - * loops through until we get $continue equals true and - * $findings['count'] < $limit - */ - $findings = array(); - $savedoffset = $offset; - do { - $continue = false; - $search = $this->executeSearch($filter, $base, $attr, $limit, $offset); - if($search === false) { - return array(); - } - list($sr, $pagedSearchOK) = $search; - $cr = $this->connection->getConnectionResource(); - - if($skipHandling) { - //i.e. result do not need to be fetched, we just need the cookie - //thus pass 1 or any other value as $iFoundItems because it is not - //used - $this->processPagedSearchStatus($sr, $filter, $base, 1, $limit, - $offset, $pagedSearchOK, - $skipHandling); - return array(); - } - - foreach($sr as $res) { - $findings = array_merge($findings, $this->ldap->getEntries($cr , $res )); - } - - $continue = $this->processPagedSearchStatus($sr, $filter, $base, $findings['count'], - $limit, $offset, $pagedSearchOK, - $skipHandling); - $offset += $limit; - } while ($continue && $pagedSearchOK && $findings['count'] < $limit); - // reseting offset - $offset = $savedoffset; - - // if we're here, probably no connection resource is returned. - // to make ownCloud behave nicely, we simply give back an empty array. - if(is_null($findings)) { - return array(); - } - - if(!is_null($attr)) { - $selection = array(); - $i = 0; - foreach($findings as $item) { - if(!is_array($item)) { - continue; - } - $item = \OCP\Util::mb_array_change_key_case($item, MB_CASE_LOWER, 'UTF-8'); - foreach($attr as $key) { - $key = mb_strtolower($key, 'UTF-8'); - if(isset($item[$key])) { - if(is_array($item[$key]) && isset($item[$key]['count'])) { - unset($item[$key]['count']); - } - if($key !== 'dn') { - $selection[$i][$key] = $this->resemblesDN($key) ? - $this->sanitizeDN($item[$key]) - : $item[$key]; - } else { - $selection[$i][$key] = [$this->sanitizeDN($item[$key])]; - } - } - - } - $i++; - } - $findings = $selection; - } - //we slice the findings, when - //a) paged search unsuccessful, though attempted - //b) no paged search, but limit set - if((!$this->getPagedSearchResultState() - && $pagedSearchOK) - || ( - !$pagedSearchOK - && !is_null($limit) - ) - ) { - $findings = array_slice($findings, intval($offset), $limit); - } - return $findings; - } - - /** - * @param string $name - * @return bool|mixed|string - */ - public function sanitizeUsername($name) { - if($this->connection->ldapIgnoreNamingRules) { - return $name; - } - - // Transliteration - // latin characters to ASCII - $name = iconv('UTF-8', 'ASCII//TRANSLIT', $name); - - // Replacements - $name = str_replace(' ', '_', $name); - - // Every remaining disallowed characters will be removed - $name = preg_replace('/[^a-zA-Z0-9_.@-]/u', '', $name); - - return $name; - } - - /** - * escapes (user provided) parts for LDAP filter - * @param string $input, the provided value - * @param bool $allowAsterisk whether in * at the beginning should be preserved - * @return string the escaped string - */ - public function escapeFilterPart($input, $allowAsterisk = false) { - $asterisk = ''; - if($allowAsterisk && strlen($input) > 0 && $input[0] === '*') { - $asterisk = '*'; - $input = mb_substr($input, 1, null, 'UTF-8'); - } - $search = array('*', '\\', '(', ')'); - $replace = array('\\*', '\\\\', '\\(', '\\)'); - return $asterisk . str_replace($search, $replace, $input); - } - - /** - * combines the input filters with AND - * @param string[] $filters the filters to connect - * @return string the combined filter - */ - public function combineFilterWithAnd($filters) { - return $this->combineFilter($filters, '&'); - } - - /** - * combines the input filters with OR - * @param string[] $filters the filters to connect - * @return string the combined filter - * Combines Filter arguments with OR - */ - public function combineFilterWithOr($filters) { - return $this->combineFilter($filters, '|'); - } - - /** - * combines the input filters with given operator - * @param string[] $filters the filters to connect - * @param string $operator either & or | - * @return string the combined filter - */ - private function combineFilter($filters, $operator) { - $combinedFilter = '('.$operator; - foreach($filters as $filter) { - if(!empty($filter) && $filter[0] !== '(') { - $filter = '('.$filter.')'; - } - $combinedFilter.=$filter; - } - $combinedFilter.=')'; - return $combinedFilter; - } - - /** - * creates a filter part for to perform search for users - * @param string $search the search term - * @return string the final filter part to use in LDAP searches - */ - public function getFilterPartForUserSearch($search) { - return $this->getFilterPartForSearch($search, - $this->connection->ldapAttributesForUserSearch, - $this->connection->ldapUserDisplayName); - } - - /** - * creates a filter part for to perform search for groups - * @param string $search the search term - * @return string the final filter part to use in LDAP searches - */ - public function getFilterPartForGroupSearch($search) { - return $this->getFilterPartForSearch($search, - $this->connection->ldapAttributesForGroupSearch, - $this->connection->ldapGroupDisplayName); - } - - /** - * creates a filter part for searches by splitting up the given search - * string into single words - * @param string $search the search term - * @param string[] $searchAttributes needs to have at least two attributes, - * otherwise it does not make sense :) - * @return string the final filter part to use in LDAP searches - * @throws \Exception - */ - private function getAdvancedFilterPartForSearch($search, $searchAttributes) { - if(!is_array($searchAttributes) || count($searchAttributes) < 2) { - throw new \Exception('searchAttributes must be an array with at least two string'); - } - $searchWords = explode(' ', trim($search)); - $wordFilters = array(); - foreach($searchWords as $word) { - $word = $this->prepareSearchTerm($word); - //every word needs to appear at least once - $wordMatchOneAttrFilters = array(); - foreach($searchAttributes as $attr) { - $wordMatchOneAttrFilters[] = $attr . '=' . $word; - } - $wordFilters[] = $this->combineFilterWithOr($wordMatchOneAttrFilters); - } - return $this->combineFilterWithAnd($wordFilters); - } - - /** - * creates a filter part for searches - * @param string $search the search term - * @param string[]|null $searchAttributes - * @param string $fallbackAttribute a fallback attribute in case the user - * did not define search attributes. Typically the display name attribute. - * @return string the final filter part to use in LDAP searches - */ - private function getFilterPartForSearch($search, $searchAttributes, $fallbackAttribute) { - $filter = array(); - $haveMultiSearchAttributes = (is_array($searchAttributes) && count($searchAttributes) > 0); - if($haveMultiSearchAttributes && strpos(trim($search), ' ') !== false) { - try { - return $this->getAdvancedFilterPartForSearch($search, $searchAttributes); - } catch(\Exception $e) { - \OCP\Util::writeLog( - 'user_ldap', - 'Creating advanced filter for search failed, falling back to simple method.', - \OCP\Util::INFO - ); - } - } - - $search = $this->prepareSearchTerm($search); - if(!is_array($searchAttributes) || count($searchAttributes) === 0) { - if(empty($fallbackAttribute)) { - return ''; - } - $filter[] = $fallbackAttribute . '=' . $search; - } else { - foreach($searchAttributes as $attribute) { - $filter[] = $attribute . '=' . $search; - } - } - if(count($filter) === 1) { - return '('.$filter[0].')'; - } - return $this->combineFilterWithOr($filter); - } - - /** - * returns the search term depending on whether we are allowed - * list users found by ldap with the current input appended by - * a * - * @return string - */ - private function prepareSearchTerm($term) { - $config = \OC::$server->getConfig(); - - $allowEnum = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes'); - - $result = empty($term) ? '*' : - $allowEnum !== 'no' ? $term . '*' : $term; - return $result; - } - - /** - * returns the filter used for counting users - * @return string - */ - public function getFilterForUserCount() { - $filter = $this->combineFilterWithAnd(array( - $this->connection->ldapUserFilter, - $this->connection->ldapUserDisplayName . '=*' - )); - - return $filter; - } - - /** - * @param string $name - * @param string $password - * @return bool - */ - public function areCredentialsValid($name, $password) { - $name = $this->DNasBaseParameter($name); - $testConnection = clone $this->connection; - $credentials = array( - 'ldapAgentName' => $name, - 'ldapAgentPassword' => $password - ); - if(!$testConnection->setConfiguration($credentials)) { - return false; - } - $result=$testConnection->bind(); - $this->ldap->unbind($this->connection->getConnectionResource()); - return $result; - } - - /** - * reverse lookup of a DN given a known UUID - * - * @param string $uuid - * @return string - * @throws \Exception - */ - public function getUserDnByUuid($uuid) { - $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; - $filter = $this->connection->ldapUserFilter; - $base = $this->connection->ldapBaseUsers; - - if($this->connection->ldapUuidUserAttribute === 'auto' && empty($uuidOverride)) { - // Sacrebleu! The UUID attribute is unknown :( We need first an - // existing DN to be able to reliably detect it. - $result = $this->search($filter, $base, ['dn'], 1); - if(!isset($result[0]) || !isset($result[0]['dn'])) { - throw new \Exception('Cannot determine UUID attribute'); - } - $dn = $result[0]['dn'][0]; - if(!$this->detectUuidAttribute($dn, true)) { - throw new \Exception('Cannot determine UUID attribute'); - } - } else { - // The UUID attribute is either known or an override is given. - // By calling this method we ensure that $this->connection->$uuidAttr - // is definitely set - if(!$this->detectUuidAttribute('', true)) { - throw new \Exception('Cannot determine UUID attribute'); - } - } - - $uuidAttr = $this->connection->ldapUuidUserAttribute; - if($uuidAttr === 'guid' || $uuidAttr === 'objectguid') { - $uuid = $this->formatGuid2ForFilterUser($uuid); - } - - $filter = $uuidAttr . '=' . $uuid; - $result = $this->searchUsers($filter, ['dn'], 2); - if(is_array($result) && isset($result[0]) && isset($result[0]['dn']) && count($result) === 1) { - // we put the count into account to make sure that this is - // really unique - return $result[0]['dn'][0]; - } - - throw new \Exception('Cannot determine UUID attribute'); - } - - /** - * auto-detects the directory's UUID attribute - * @param string $dn a known DN used to check against - * @param bool $isUser - * @param bool $force the detection should be run, even if it is not set to auto - * @return bool true on success, false otherwise - */ - private function detectUuidAttribute($dn, $isUser = true, $force = false) { - if($isUser) { - $uuidAttr = 'ldapUuidUserAttribute'; - $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; - } else { - $uuidAttr = 'ldapUuidGroupAttribute'; - $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; - } - - if(($this->connection->$uuidAttr !== 'auto') && !$force) { - return true; - } - - if(!empty($uuidOverride) && !$force) { - $this->connection->$uuidAttr = $uuidOverride; - return true; - } - - // for now, supported attributes are entryUUID, nsuniqueid, objectGUID, ipaUniqueID - $testAttributes = array('entryuuid', 'nsuniqueid', 'objectguid', 'guid', 'ipauniqueid'); - - foreach($testAttributes as $attribute) { - $value = $this->readAttribute($dn, $attribute); - if(is_array($value) && isset($value[0]) && !empty($value[0])) { - \OCP\Util::writeLog('user_ldap', - 'Setting '.$attribute.' as '.$uuidAttr, - \OCP\Util::DEBUG); - $this->connection->$uuidAttr = $attribute; - return true; - } - } - \OCP\Util::writeLog('user_ldap', - 'Could not autodetect the UUID attribute', - \OCP\Util::ERROR); - - return false; - } - - /** - * @param string $dn - * @param bool $isUser - * @return string|bool - */ - public function getUUID($dn, $isUser = true) { - if($isUser) { - $uuidAttr = 'ldapUuidUserAttribute'; - $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; - } else { - $uuidAttr = 'ldapUuidGroupAttribute'; - $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; - } - - $uuid = false; - if($this->detectUuidAttribute($dn, $isUser)) { - $uuid = $this->readAttribute($dn, $this->connection->$uuidAttr); - if( !is_array($uuid) - && !empty($uuidOverride) - && $this->detectUuidAttribute($dn, $isUser, true)) { - $uuid = $this->readAttribute($dn, - $this->connection->$uuidAttr); - } - if(is_array($uuid) && isset($uuid[0]) && !empty($uuid[0])) { - $uuid = $uuid[0]; - } - } - - return $uuid; - } - - /** - * converts a binary ObjectGUID into a string representation - * @param string $oguid the ObjectGUID in it's binary form as retrieved from AD - * @return string - * @link http://www.php.net/manual/en/function.ldap-get-values-len.php#73198 - */ - private function convertObjectGUID2Str($oguid) { - $hex_guid = bin2hex($oguid); - $hex_guid_to_guid_str = ''; - for($k = 1; $k <= 4; ++$k) { - $hex_guid_to_guid_str .= substr($hex_guid, 8 - 2 * $k, 2); - } - $hex_guid_to_guid_str .= '-'; - for($k = 1; $k <= 2; ++$k) { - $hex_guid_to_guid_str .= substr($hex_guid, 12 - 2 * $k, 2); - } - $hex_guid_to_guid_str .= '-'; - for($k = 1; $k <= 2; ++$k) { - $hex_guid_to_guid_str .= substr($hex_guid, 16 - 2 * $k, 2); - } - $hex_guid_to_guid_str .= '-' . substr($hex_guid, 16, 4); - $hex_guid_to_guid_str .= '-' . substr($hex_guid, 20); - - return strtoupper($hex_guid_to_guid_str); - } - - /** - * the first three blocks of the string-converted GUID happen to be in - * reverse order. In order to use it in a filter, this needs to be - * corrected. Furthermore the dashes need to be replaced and \\ preprended - * to every two hax figures. - * - * If an invalid string is passed, it will be returned without change. - * - * @param string $guid - * @return string - */ - public function formatGuid2ForFilterUser($guid) { - if(!is_string($guid)) { - throw new \InvalidArgumentException('String expected'); - } - $blocks = explode('-', $guid); - if(count($blocks) !== 5) { - /* - * Why not throw an Exception instead? This method is a utility - * called only when trying to figure out whether a "missing" known - * LDAP user was or was not renamed on the LDAP server. And this - * even on the use case that a reverse lookup is needed (UUID known, - * not DN), i.e. when finding users (search dialog, users page, - * login, …) this will not be fired. This occurs only if shares from - * a users are supposed to be mounted who cannot be found. Throwing - * an exception here would kill the experience for a valid, acting - * user. Instead we write a log message. - */ - \OC::$server->getLogger()->info( - 'Passed string does not resemble a valid GUID. Known UUID ' . - '({uuid}) probably does not match UUID configuration.', - [ 'app' => 'user_ldap', 'uuid' => $guid ] - ); - return $guid; - } - for($i=0; $i < 3; $i++) { - $pairs = str_split($blocks[$i], 2); - $pairs = array_reverse($pairs); - $blocks[$i] = implode('', $pairs); - } - for($i=0; $i < 5; $i++) { - $pairs = str_split($blocks[$i], 2); - $blocks[$i] = '\\' . implode('\\', $pairs); - } - return implode('', $blocks); - } - - /** - * gets a SID of the domain of the given dn - * @param string $dn - * @return string|bool - */ - public function getSID($dn) { - $domainDN = $this->getDomainDNFromDN($dn); - $cacheKey = 'getSID-'.$domainDN; - if($this->connection->isCached($cacheKey)) { - return $this->connection->getFromCache($cacheKey); - } - - $objectSid = $this->readAttribute($domainDN, 'objectsid'); - if(!is_array($objectSid) || empty($objectSid)) { - $this->connection->writeToCache($cacheKey, false); - return false; - } - $domainObjectSid = $this->convertSID2Str($objectSid[0]); - $this->connection->writeToCache($cacheKey, $domainObjectSid); - - return $domainObjectSid; - } - - /** - * converts a binary SID into a string representation - * @param string $sid - * @return string - */ - public function convertSID2Str($sid) { - // The format of a SID binary string is as follows: - // 1 byte for the revision level - // 1 byte for the number n of variable sub-ids - // 6 bytes for identifier authority value - // n*4 bytes for n sub-ids - // - // Example: 010400000000000515000000a681e50e4d6c6c2bca32055f - // Legend: RRNNAAAAAAAAAAAA11111111222222223333333344444444 - $revision = ord($sid[0]); - $numberSubID = ord($sid[1]); - - $subIdStart = 8; // 1 + 1 + 6 - $subIdLength = 4; - if (strlen($sid) !== $subIdStart + $subIdLength * $numberSubID) { - // Incorrect number of bytes present. - return ''; - } - - // 6 bytes = 48 bits can be represented using floats without loss of - // precision (see https://gist.github.com/bantu/886ac680b0aef5812f71) - $iav = number_format(hexdec(bin2hex(substr($sid, 2, 6))), 0, '', ''); - - $subIDs = array(); - for ($i = 0; $i < $numberSubID; $i++) { - $subID = unpack('V', substr($sid, $subIdStart + $subIdLength * $i, $subIdLength)); - $subIDs[] = sprintf('%u', $subID[1]); - } - - // Result for example above: S-1-5-21-249921958-728525901-1594176202 - return sprintf('S-%d-%s-%s', $revision, $iav, implode('-', $subIDs)); - } - - /** - * converts a stored DN so it can be used as base parameter for LDAP queries, internally we store them for usage in LDAP filters - * @param string $dn the DN - * @return string - */ - private function DNasBaseParameter($dn) { - return str_ireplace('\\5c', '\\', $dn); - } - - /** - * checks if the given DN is part of the given base DN(s) - * @param string $dn the DN - * @param string[] $bases array containing the allowed base DN or DNs - * @return bool - */ - public function isDNPartOfBase($dn, $bases) { - $belongsToBase = false; - $bases = $this->sanitizeDN($bases); - - foreach($bases as $base) { - $belongsToBase = true; - if(mb_strripos($dn, $base, 0, 'UTF-8') !== (mb_strlen($dn, 'UTF-8')-mb_strlen($base, 'UTF-8'))) { - $belongsToBase = false; - } - if($belongsToBase) { - break; - } - } - return $belongsToBase; - } - - /** - * resets a running Paged Search operation - */ - private function abandonPagedSearch() { - if($this->connection->hasPagedResultSupport) { - $cr = $this->connection->getConnectionResource(); - $this->ldap->controlPagedResult($cr, 0, false, $this->lastCookie); - $this->getPagedSearchResultState(); - $this->lastCookie = ''; - $this->cookies = array(); - } - } - - /** - * get a cookie for the next LDAP paged search - * @param string $base a string with the base DN for the search - * @param string $filter the search filter to identify the correct search - * @param int $limit the limit (or 'pageSize'), to identify the correct search well - * @param int $offset the offset for the new search to identify the correct search really good - * @return string containing the key or empty if none is cached - */ - private function getPagedResultCookie($base, $filter, $limit, $offset) { - if($offset === 0) { - return ''; - } - $offset -= $limit; - //we work with cache here - $cacheKey = 'lc' . crc32($base) . '-' . crc32($filter) . '-' . intval($limit) . '-' . intval($offset); - $cookie = ''; - if(isset($this->cookies[$cacheKey])) { - $cookie = $this->cookies[$cacheKey]; - if(is_null($cookie)) { - $cookie = ''; - } - } - return $cookie; - } - - /** - * checks whether an LDAP paged search operation has more pages that can be - * retrieved, typically when offset and limit are provided. - * - * Be very careful to use it: the last cookie value, which is inspected, can - * be reset by other operations. Best, call it immediately after a search(), - * searchUsers() or searchGroups() call. count-methods are probably safe as - * well. Don't rely on it with any fetchList-method. - * @return bool - */ - public function hasMoreResults() { - if(!$this->connection->hasPagedResultSupport) { - return false; - } - - if(empty($this->lastCookie) && $this->lastCookie !== '0') { - // as in RFC 2696, when all results are returned, the cookie will - // be empty. - return false; - } - - return true; - } - - /** - * set a cookie for LDAP paged search run - * @param string $base a string with the base DN for the search - * @param string $filter the search filter to identify the correct search - * @param int $limit the limit (or 'pageSize'), to identify the correct search well - * @param int $offset the offset for the run search to identify the correct search really good - * @param string $cookie string containing the cookie returned by ldap_control_paged_result_response - * @return void - */ - private function setPagedResultCookie($base, $filter, $limit, $offset, $cookie) { - // allow '0' for 389ds - if(!empty($cookie) || $cookie === '0') { - $cacheKey = 'lc' . crc32($base) . '-' . crc32($filter) . '-' .intval($limit) . '-' . intval($offset); - $this->cookies[$cacheKey] = $cookie; - $this->lastCookie = $cookie; - } - } - - /** - * Check whether the most recent paged search was successful. It flushed the state var. Use it always after a possible paged search. - * @return boolean|null true on success, null or false otherwise - */ - public function getPagedSearchResultState() { - $result = $this->pagedSearchedSuccessful; - $this->pagedSearchedSuccessful = null; - return $result; - } - - /** - * Prepares a paged search, if possible - * @param string $filter the LDAP filter for the search - * @param string[] $bases an array containing the LDAP subtree(s) that shall be searched - * @param string[] $attr optional, when a certain attribute shall be filtered outside - * @param int $limit - * @param int $offset - * @return bool|true - */ - private function initPagedSearch($filter, $bases, $attr, $limit, $offset) { - $pagedSearchOK = false; - if($this->connection->hasPagedResultSupport && ($limit !== 0)) { - $offset = intval($offset); //can be null - \OCP\Util::writeLog('user_ldap', - 'initializing paged search for Filter '.$filter.' base '.print_r($bases, true) - .' attr '.print_r($attr, true). ' limit ' .$limit.' offset '.$offset, - \OCP\Util::DEBUG); - //get the cookie from the search for the previous search, required by LDAP - foreach($bases as $base) { - - $cookie = $this->getPagedResultCookie($base, $filter, $limit, $offset); - if(empty($cookie) && $cookie !== "0" && ($offset > 0)) { - // no cookie known, although the offset is not 0. Maybe cache run out. We need - // to start all over *sigh* (btw, Dear Reader, did you know LDAP paged - // searching was designed by MSFT?) - // Lukas: No, but thanks to reading that source I finally know! - // '0' is valid, because 389ds - $reOffset = ($offset - $limit) < 0 ? 0 : $offset - $limit; - //a bit recursive, $offset of 0 is the exit - \OCP\Util::writeLog('user_ldap', 'Looking for cookie L/O '.$limit.'/'.$reOffset, \OCP\Util::INFO); - $this->search($filter, array($base), $attr, $limit, $reOffset, true); - $cookie = $this->getPagedResultCookie($base, $filter, $limit, $offset); - //still no cookie? obviously, the server does not like us. Let's skip paging efforts. - //TODO: remember this, probably does not change in the next request... - if(empty($cookie) && $cookie !== '0') { - // '0' is valid, because 389ds - $cookie = null; - } - } - if(!is_null($cookie)) { - //since offset = 0, this is a new search. We abandon other searches that might be ongoing. - $this->abandonPagedSearch(); - $pagedSearchOK = $this->ldap->controlPagedResult( - $this->connection->getConnectionResource(), $limit, - false, $cookie); - if(!$pagedSearchOK) { - return false; - } - \OCP\Util::writeLog('user_ldap', 'Ready for a paged search', \OCP\Util::DEBUG); - } else { - \OCP\Util::writeLog('user_ldap', - 'No paged search for us, Cpt., Limit '.$limit.' Offset '.$offset, - \OCP\Util::INFO); - } - - } - /* ++ Fixing RHDS searches with pages with zero results ++ - * We coudn't get paged searches working with our RHDS for login ($limit = 0), - * due to pages with zero results. - * So we added "&& !empty($this->lastCookie)" to this test to ignore pagination - * if we don't have a previous paged search. - */ - } else if($this->connection->hasPagedResultSupport && $limit === 0 && !empty($this->lastCookie)) { - // a search without limit was requested. However, if we do use - // Paged Search once, we always must do it. This requires us to - // initialize it with the configured page size. - $this->abandonPagedSearch(); - // in case someone set it to 0 … use 500, otherwise no results will - // be returned. - $pageSize = intval($this->connection->ldapPagingSize) > 0 ? intval($this->connection->ldapPagingSize) : 500; - $pagedSearchOK = $this->ldap->controlPagedResult( - $this->connection->getConnectionResource(), $pageSize, false, '' - ); - } - - return $pagedSearchOK; - } - -} diff --git a/apps/user_ldap/lib/backendutility.php b/apps/user_ldap/lib/backendutility.php deleted file mode 100644 index 87c1649cada..00000000000 --- a/apps/user_ldap/lib/backendutility.php +++ /dev/null @@ -1,37 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - - -abstract class BackendUtility { - protected $access; - - /** - * constructor, make sure the subclasses call this one! - * @param Access $access an instance of Access for LDAP interaction - */ - public function __construct(Access $access) { - $this->access = $access; - } -} diff --git a/apps/user_ldap/lib/configuration.php b/apps/user_ldap/lib/configuration.php deleted file mode 100644 index daec2bed13a..00000000000 --- a/apps/user_ldap/lib/configuration.php +++ /dev/null @@ -1,508 +0,0 @@ -<?php -/** - * @author Alex Weirig <alex.weirig@technolink.lu> - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lennart Rosam <hello@takuto.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -class Configuration { - - protected $configPrefix = null; - protected $configRead = false; - - //settings - protected $config = array( - 'ldapHost' => null, - 'ldapPort' => null, - 'ldapBackupHost' => null, - 'ldapBackupPort' => null, - 'ldapBase' => null, - 'ldapBaseUsers' => null, - 'ldapBaseGroups' => null, - 'ldapAgentName' => null, - 'ldapAgentPassword' => null, - 'ldapTLS' => null, - 'turnOffCertCheck' => null, - 'ldapIgnoreNamingRules' => null, - 'ldapUserDisplayName' => null, - 'ldapUserDisplayName2' => null, - 'ldapUserFilterObjectclass' => null, - 'ldapUserFilterGroups' => null, - 'ldapUserFilter' => null, - 'ldapUserFilterMode' => null, - 'ldapGroupFilter' => null, - 'ldapGroupFilterMode' => null, - 'ldapGroupFilterObjectclass' => null, - 'ldapGroupFilterGroups' => null, - 'ldapGroupDisplayName' => null, - 'ldapGroupMemberAssocAttr' => null, - 'ldapLoginFilter' => null, - 'ldapLoginFilterMode' => null, - 'ldapLoginFilterEmail' => null, - 'ldapLoginFilterUsername' => null, - 'ldapLoginFilterAttributes' => null, - 'ldapQuotaAttribute' => null, - 'ldapQuotaDefault' => null, - 'ldapEmailAttribute' => null, - 'ldapCacheTTL' => null, - 'ldapUuidUserAttribute' => 'auto', - 'ldapUuidGroupAttribute' => 'auto', - 'ldapOverrideMainServer' => false, - 'ldapConfigurationActive' => false, - 'ldapAttributesForUserSearch' => null, - 'ldapAttributesForGroupSearch' => null, - 'ldapExperiencedAdmin' => false, - 'homeFolderNamingRule' => null, - 'hasPagedResultSupport' => false, - 'hasMemberOfFilterSupport' => false, - 'useMemberOfToDetectMembership' => true, - 'ldapExpertUsernameAttr' => null, - 'ldapExpertUUIDUserAttr' => null, - 'ldapExpertUUIDGroupAttr' => null, - 'lastJpegPhotoLookup' => null, - 'ldapNestedGroups' => false, - 'ldapPagingSize' => null, - 'ldapDynamicGroupMemberURL' => null, - ); - - /** - * @param string $configPrefix - * @param bool $autoRead - */ - public function __construct($configPrefix, $autoRead = true) { - $this->configPrefix = $configPrefix; - if($autoRead) { - $this->readConfiguration(); - } - } - - /** - * @param string $name - * @return mixed|void - */ - public function __get($name) { - if(isset($this->config[$name])) { - return $this->config[$name]; - } - } - - /** - * @param string $name - * @param mixed $value - */ - public function __set($name, $value) { - $this->setConfiguration(array($name => $value)); - } - - /** - * @return array - */ - public function getConfiguration() { - return $this->config; - } - - /** - * set LDAP configuration with values delivered by an array, not read - * from configuration. It does not save the configuration! To do so, you - * must call saveConfiguration afterwards. - * @param array $config array that holds the config parameters in an associated - * array - * @param array &$applied optional; array where the set fields will be given to - * @return false|null - */ - public function setConfiguration($config, &$applied = null) { - if(!is_array($config)) { - return false; - } - - $cta = $this->getConfigTranslationArray(); - foreach($config as $inputKey => $val) { - if(strpos($inputKey, '_') !== false && array_key_exists($inputKey, $cta)) { - $key = $cta[$inputKey]; - } elseif(array_key_exists($inputKey, $this->config)) { - $key = $inputKey; - } else { - continue; - } - - $setMethod = 'setValue'; - switch($key) { - case 'ldapAgentPassword': - $setMethod = 'setRawValue'; - break; - case 'homeFolderNamingRule': - $trimmedVal = trim($val); - if(!empty($trimmedVal) && strpos($val, 'attr:') === false) { - $val = 'attr:'.$trimmedVal; - } - break; - case 'ldapBase': - case 'ldapBaseUsers': - case 'ldapBaseGroups': - case 'ldapAttributesForUserSearch': - case 'ldapAttributesForGroupSearch': - case 'ldapUserFilterObjectclass': - case 'ldapUserFilterGroups': - case 'ldapGroupFilterObjectclass': - case 'ldapGroupFilterGroups': - case 'ldapLoginFilterAttributes': - $setMethod = 'setMultiLine'; - break; - } - $this->$setMethod($key, $val); - if(is_array($applied)) { - $applied[] = $inputKey; - } - } - - } - - public function readConfiguration() { - if(!$this->configRead && !is_null($this->configPrefix)) { - $cta = array_flip($this->getConfigTranslationArray()); - foreach($this->config as $key => $val) { - if(!isset($cta[$key])) { - //some are determined - continue; - } - $dbKey = $cta[$key]; - switch($key) { - case 'ldapBase': - case 'ldapBaseUsers': - case 'ldapBaseGroups': - case 'ldapAttributesForUserSearch': - case 'ldapAttributesForGroupSearch': - case 'ldapUserFilterObjectclass': - case 'ldapUserFilterGroups': - case 'ldapGroupFilterObjectclass': - case 'ldapGroupFilterGroups': - case 'ldapLoginFilterAttributes': - $readMethod = 'getMultiLine'; - break; - case 'ldapIgnoreNamingRules': - $readMethod = 'getSystemValue'; - $dbKey = $key; - break; - case 'ldapAgentPassword': - $readMethod = 'getPwd'; - break; - case 'ldapUserDisplayName2': - case 'ldapGroupDisplayName': - $readMethod = 'getLcValue'; - break; - case 'ldapUserDisplayName': - default: - // user display name does not lower case because - // we rely on an upper case N as indicator whether to - // auto-detect it or not. FIXME - $readMethod = 'getValue'; - break; - } - $this->config[$key] = $this->$readMethod($dbKey); - } - $this->configRead = true; - } - } - - /** - * saves the current Configuration in the database - */ - public function saveConfiguration() { - $cta = array_flip($this->getConfigTranslationArray()); - foreach($this->config as $key => $value) { - switch ($key) { - case 'ldapAgentPassword': - $value = base64_encode($value); - break; - case 'ldapBase': - case 'ldapBaseUsers': - case 'ldapBaseGroups': - case 'ldapAttributesForUserSearch': - case 'ldapAttributesForGroupSearch': - case 'ldapUserFilterObjectclass': - case 'ldapUserFilterGroups': - case 'ldapGroupFilterObjectclass': - case 'ldapGroupFilterGroups': - case 'ldapLoginFilterAttributes': - if(is_array($value)) { - $value = implode("\n", $value); - } - break; - //following options are not stored but detected, skip them - case 'ldapIgnoreNamingRules': - case 'hasPagedResultSupport': - case 'ldapUuidUserAttribute': - case 'ldapUuidGroupAttribute': - continue 2; - } - if(is_null($value)) { - $value = ''; - } - $this->saveValue($cta[$key], $value); - } - } - - /** - * @param string $varName - * @return array|string - */ - protected function getMultiLine($varName) { - $value = $this->getValue($varName); - if(empty($value)) { - $value = ''; - } else { - $value = preg_split('/\r\n|\r|\n/', $value); - } - - return $value; - } - - /** - * Sets multi-line values as arrays - * - * @param string $varName name of config-key - * @param array|string $value to set - */ - protected function setMultiLine($varName, $value) { - if(empty($value)) { - $value = ''; - } else if (!is_array($value)) { - $value = preg_split('/\r\n|\r|\n|;/', $value); - if($value === false) { - $value = ''; - } - } - - if(!is_array($value)) { - $finalValue = trim($value); - } else { - $finalValue = []; - foreach($value as $key => $val) { - if(is_string($val)) { - $val = trim($val); - if(!empty($val)) { - //accidental line breaks are not wanted and can cause - // odd behaviour. Thus, away with them. - $finalValue[] = $val; - } - } else { - $finalValue[] = $val; - } - } - } - - $this->setRawValue($varName, $finalValue); - } - - /** - * @param string $varName - * @return string - */ - protected function getPwd($varName) { - return base64_decode($this->getValue($varName)); - } - - /** - * @param string $varName - * @return string - */ - protected function getLcValue($varName) { - return mb_strtolower($this->getValue($varName), 'UTF-8'); - } - - /** - * @param string $varName - * @return string - */ - protected function getSystemValue($varName) { - //FIXME: if another system value is added, softcode the default value - return \OCP\Config::getSystemValue($varName, false); - } - - /** - * @param string $varName - * @return string - */ - protected function getValue($varName) { - static $defaults; - if(is_null($defaults)) { - $defaults = $this->getDefaults(); - } - return \OCP\Config::getAppValue('user_ldap', - $this->configPrefix.$varName, - $defaults[$varName]); - } - - /** - * Sets a scalar value. - * - * @param string $varName name of config key - * @param mixed $value to set - */ - protected function setValue($varName, $value) { - if(is_string($value)) { - $value = trim($value); - } - $this->config[$varName] = $value; - } - - /** - * Sets a scalar value without trimming. - * - * @param string $varName name of config key - * @param mixed $value to set - */ - protected function setRawValue($varName, $value) { - $this->config[$varName] = $value; - } - - /** - * @param string $varName - * @param string $value - * @return bool - */ - protected function saveValue($varName, $value) { - return \OCP\Config::setAppValue('user_ldap', - $this->configPrefix.$varName, - $value); - } - - /** - * @return array an associative array with the default values. Keys are correspond - * to config-value entries in the database table - */ - public function getDefaults() { - return array( - 'ldap_host' => '', - 'ldap_port' => '', - 'ldap_backup_host' => '', - 'ldap_backup_port' => '', - 'ldap_override_main_server' => '', - 'ldap_dn' => '', - 'ldap_agent_password' => '', - 'ldap_base' => '', - 'ldap_base_users' => '', - 'ldap_base_groups' => '', - 'ldap_userlist_filter' => '', - 'ldap_user_filter_mode' => 0, - 'ldap_userfilter_objectclass' => '', - 'ldap_userfilter_groups' => '', - 'ldap_login_filter' => '', - 'ldap_login_filter_mode' => 0, - 'ldap_loginfilter_email' => 0, - 'ldap_loginfilter_username' => 1, - 'ldap_loginfilter_attributes' => '', - 'ldap_group_filter' => '', - 'ldap_group_filter_mode' => 0, - 'ldap_groupfilter_objectclass' => '', - 'ldap_groupfilter_groups' => '', - 'ldap_display_name' => 'displayName', - 'ldap_user_display_name_2' => '', - 'ldap_group_display_name' => 'cn', - 'ldap_tls' => 0, - 'ldap_quota_def' => '', - 'ldap_quota_attr' => '', - 'ldap_email_attr' => '', - 'ldap_group_member_assoc_attribute' => 'uniqueMember', - 'ldap_cache_ttl' => 600, - 'ldap_uuid_user_attribute' => 'auto', - 'ldap_uuid_group_attribute' => 'auto', - 'home_folder_naming_rule' => '', - 'ldap_turn_off_cert_check' => 0, - 'ldap_configuration_active' => 0, - 'ldap_attributes_for_user_search' => '', - 'ldap_attributes_for_group_search' => '', - 'ldap_expert_username_attr' => '', - 'ldap_expert_uuid_user_attr' => '', - 'ldap_expert_uuid_group_attr' => '', - 'has_memberof_filter_support' => 0, - 'use_memberof_to_detect_membership' => 1, - 'last_jpegPhoto_lookup' => 0, - 'ldap_nested_groups' => 0, - 'ldap_paging_size' => 500, - 'ldap_experienced_admin' => 0, - 'ldap_dynamic_group_member_url' => '', - ); - } - - /** - * @return array that maps internal variable names to database fields - */ - public function getConfigTranslationArray() { - //TODO: merge them into one representation - static $array = array( - 'ldap_host' => 'ldapHost', - 'ldap_port' => 'ldapPort', - 'ldap_backup_host' => 'ldapBackupHost', - 'ldap_backup_port' => 'ldapBackupPort', - 'ldap_override_main_server' => 'ldapOverrideMainServer', - 'ldap_dn' => 'ldapAgentName', - 'ldap_agent_password' => 'ldapAgentPassword', - 'ldap_base' => 'ldapBase', - 'ldap_base_users' => 'ldapBaseUsers', - 'ldap_base_groups' => 'ldapBaseGroups', - 'ldap_userfilter_objectclass' => 'ldapUserFilterObjectclass', - 'ldap_userfilter_groups' => 'ldapUserFilterGroups', - 'ldap_userlist_filter' => 'ldapUserFilter', - 'ldap_user_filter_mode' => 'ldapUserFilterMode', - 'ldap_login_filter' => 'ldapLoginFilter', - 'ldap_login_filter_mode' => 'ldapLoginFilterMode', - 'ldap_loginfilter_email' => 'ldapLoginFilterEmail', - 'ldap_loginfilter_username' => 'ldapLoginFilterUsername', - 'ldap_loginfilter_attributes' => 'ldapLoginFilterAttributes', - 'ldap_group_filter' => 'ldapGroupFilter', - 'ldap_group_filter_mode' => 'ldapGroupFilterMode', - 'ldap_groupfilter_objectclass' => 'ldapGroupFilterObjectclass', - 'ldap_groupfilter_groups' => 'ldapGroupFilterGroups', - 'ldap_display_name' => 'ldapUserDisplayName', - 'ldap_user_display_name_2' => 'ldapUserDisplayName2', - 'ldap_group_display_name' => 'ldapGroupDisplayName', - 'ldap_tls' => 'ldapTLS', - 'ldap_quota_def' => 'ldapQuotaDefault', - 'ldap_quota_attr' => 'ldapQuotaAttribute', - 'ldap_email_attr' => 'ldapEmailAttribute', - 'ldap_group_member_assoc_attribute' => 'ldapGroupMemberAssocAttr', - 'ldap_cache_ttl' => 'ldapCacheTTL', - 'home_folder_naming_rule' => 'homeFolderNamingRule', - 'ldap_turn_off_cert_check' => 'turnOffCertCheck', - 'ldap_configuration_active' => 'ldapConfigurationActive', - 'ldap_attributes_for_user_search' => 'ldapAttributesForUserSearch', - 'ldap_attributes_for_group_search' => 'ldapAttributesForGroupSearch', - 'ldap_expert_username_attr' => 'ldapExpertUsernameAttr', - 'ldap_expert_uuid_user_attr' => 'ldapExpertUUIDUserAttr', - 'ldap_expert_uuid_group_attr' => 'ldapExpertUUIDGroupAttr', - 'has_memberof_filter_support' => 'hasMemberOfFilterSupport', - 'use_memberof_to_detect_membership' => 'useMemberOfToDetectMembership', - 'last_jpegPhoto_lookup' => 'lastJpegPhotoLookup', - 'ldap_nested_groups' => 'ldapNestedGroups', - 'ldap_paging_size' => 'ldapPagingSize', - 'ldap_experienced_admin' => 'ldapExperiencedAdmin', - 'ldap_dynamic_group_member_url' => 'ldapDynamicGroupMemberURL', - ); - return $array; - } - -} diff --git a/apps/user_ldap/lib/connection.php b/apps/user_ldap/lib/connection.php deleted file mode 100644 index c485ac74395..00000000000 --- a/apps/user_ldap/lib/connection.php +++ /dev/null @@ -1,634 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Lyonel Vincent <lyonel@ezix.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OC\ServerNotAvailableException; - -/** - * magic properties (incomplete) - * responsible for LDAP connections in context with the provided configuration - * - * @property string ldapHost - * @property string ldapPort holds the port number - * @property string ldapUserFilter - * @property string ldapUserDisplayName - * @property string ldapUserDisplayName2 - * @property boolean hasPagedResultSupport - * @property string[] ldapBaseUsers - * @property int|string ldapPagingSize holds an integer - * @property bool|mixed|void ldapGroupMemberAssocAttr - */ -class Connection extends LDAPUtility { - private $ldapConnectionRes = null; - private $configPrefix; - private $configID; - private $configured = false; - - //whether connection should be kept on __destruct - private $dontDestruct = false; - private $hasPagedResultSupport = true; - - /** - * @var bool runtime flag that indicates whether supported primary groups are available - */ - public $hasPrimaryGroups = true; - - //cache handler - protected $cache; - - /** @var Configuration settings handler **/ - protected $configuration; - - protected $doNotValidate = false; - - protected $ignoreValidation = false; - - /** - * Constructor - * @param ILDAPWrapper $ldap - * @param string $configPrefix a string with the prefix for the configkey column (appconfig table) - * @param string|null $configID a string with the value for the appid column (appconfig table) or null for on-the-fly connections - */ - public function __construct(ILDAPWrapper $ldap, $configPrefix = '', $configID = 'user_ldap') { - parent::__construct($ldap); - $this->configPrefix = $configPrefix; - $this->configID = $configID; - $this->configuration = new Configuration($configPrefix, - !is_null($configID)); - $memcache = \OC::$server->getMemCacheFactory(); - if($memcache->isAvailable()) { - $this->cache = $memcache->create(); - } - $this->hasPagedResultSupport = - $this->ldap->hasPagedResultSupport(); - $helper = new Helper(); - $this->doNotValidate = !in_array($this->configPrefix, - $helper->getServerConfigurationPrefixes()); - } - - public function __destruct() { - if(!$this->dontDestruct && - $this->ldap->isResource($this->ldapConnectionRes)) { - @$this->ldap->unbind($this->ldapConnectionRes); - }; - } - - /** - * defines behaviour when the instance is cloned - */ - public function __clone() { - //a cloned instance inherits the connection resource. It may use it, - //but it may not disconnect it - $this->dontDestruct = true; - $this->configuration = new Configuration($this->configPrefix, - !is_null($this->configID)); - } - - /** - * @param string $name - * @return bool|mixed|void - */ - public function __get($name) { - if(!$this->configured) { - $this->readConfiguration(); - } - - if($name === 'hasPagedResultSupport') { - return $this->hasPagedResultSupport; - } - - return $this->configuration->$name; - } - - /** - * @param string $name - * @param mixed $value - */ - public function __set($name, $value) { - $this->doNotValidate = false; - $before = $this->configuration->$name; - $this->configuration->$name = $value; - $after = $this->configuration->$name; - if($before !== $after) { - if(!empty($this->configID)) { - $this->configuration->saveConfiguration(); - } - $this->validateConfiguration(); - } - } - - /** - * sets whether the result of the configuration validation shall - * be ignored when establishing the connection. Used by the Wizard - * in early configuration state. - * @param bool $state - */ - public function setIgnoreValidation($state) { - $this->ignoreValidation = (bool)$state; - } - - /** - * initializes the LDAP backend - * @param bool $force read the config settings no matter what - */ - public function init($force = false) { - $this->readConfiguration($force); - $this->establishConnection(); - } - - /** - * Returns the LDAP handler - */ - public function getConnectionResource() { - if(!$this->ldapConnectionRes) { - $this->init(); - } else if(!$this->ldap->isResource($this->ldapConnectionRes)) { - $this->ldapConnectionRes = null; - $this->establishConnection(); - } - if(is_null($this->ldapConnectionRes)) { - \OCP\Util::writeLog('user_ldap', 'No LDAP Connection to server ' . $this->configuration->ldapHost, \OCP\Util::ERROR); - throw new ServerNotAvailableException('Connection to LDAP server could not be established'); - } - return $this->ldapConnectionRes; - } - - /** - * resets the connection resource - */ - public function resetConnectionResource() { - if(!is_null($this->ldapConnectionRes)) { - @$this->ldap->unbind($this->ldapConnectionRes); - $this->ldapConnectionRes = null; - } - } - - /** - * @param string|null $key - * @return string - */ - private function getCacheKey($key) { - $prefix = 'LDAP-'.$this->configID.'-'.$this->configPrefix.'-'; - if(is_null($key)) { - return $prefix; - } - return $prefix.md5($key); - } - - /** - * @param string $key - * @return mixed|null - */ - public function getFromCache($key) { - if(!$this->configured) { - $this->readConfiguration(); - } - if(is_null($this->cache) || !$this->configuration->ldapCacheTTL) { - return null; - } - if(!$this->isCached($key)) { - return null; - - } - $key = $this->getCacheKey($key); - - return json_decode(base64_decode($this->cache->get($key)), true); - } - - /** - * @param string $key - * @return bool - */ - public function isCached($key) { - if(!$this->configured) { - $this->readConfiguration(); - } - if(is_null($this->cache) || !$this->configuration->ldapCacheTTL) { - return false; - } - $key = $this->getCacheKey($key); - return $this->cache->hasKey($key); - } - - /** - * @param string $key - * @param mixed $value - * - * @return string - */ - public function writeToCache($key, $value) { - if(!$this->configured) { - $this->readConfiguration(); - } - if(is_null($this->cache) - || !$this->configuration->ldapCacheTTL - || !$this->configuration->ldapConfigurationActive) { - return null; - } - $key = $this->getCacheKey($key); - $value = base64_encode(json_encode($value)); - $this->cache->set($key, $value, $this->configuration->ldapCacheTTL); - } - - public function clearCache() { - if(!is_null($this->cache)) { - $this->cache->clear($this->getCacheKey(null)); - } - } - - /** - * Caches the general LDAP configuration. - * @param bool $force optional. true, if the re-read should be forced. defaults - * to false. - * @return null - */ - private function readConfiguration($force = false) { - if((!$this->configured || $force) && !is_null($this->configID)) { - $this->configuration->readConfiguration(); - $this->configured = $this->validateConfiguration(); - } - } - - /** - * set LDAP configuration with values delivered by an array, not read from configuration - * @param array $config array that holds the config parameters in an associated array - * @param array &$setParameters optional; array where the set fields will be given to - * @return boolean true if config validates, false otherwise. Check with $setParameters for detailed success on single parameters - */ - public function setConfiguration($config, &$setParameters = null) { - if(is_null($setParameters)) { - $setParameters = array(); - } - $this->doNotValidate = false; - $this->configuration->setConfiguration($config, $setParameters); - if(count($setParameters) > 0) { - $this->configured = $this->validateConfiguration(); - } - - - return $this->configured; - } - - /** - * saves the current Configuration in the database and empties the - * cache - * @return null - */ - public function saveConfiguration() { - $this->configuration->saveConfiguration(); - $this->clearCache(); - } - - /** - * get the current LDAP configuration - * @return array - */ - public function getConfiguration() { - $this->readConfiguration(); - $config = $this->configuration->getConfiguration(); - $cta = $this->configuration->getConfigTranslationArray(); - $result = array(); - foreach($cta as $dbkey => $configkey) { - switch($configkey) { - case 'homeFolderNamingRule': - if(strpos($config[$configkey], 'attr:') === 0) { - $result[$dbkey] = substr($config[$configkey], 5); - } else { - $result[$dbkey] = ''; - } - break; - case 'ldapBase': - case 'ldapBaseUsers': - case 'ldapBaseGroups': - case 'ldapAttributesForUserSearch': - case 'ldapAttributesForGroupSearch': - if(is_array($config[$configkey])) { - $result[$dbkey] = implode("\n", $config[$configkey]); - break; - } //else follows default - default: - $result[$dbkey] = $config[$configkey]; - } - } - return $result; - } - - private function doSoftValidation() { - //if User or Group Base are not set, take over Base DN setting - foreach(array('ldapBaseUsers', 'ldapBaseGroups') as $keyBase) { - $val = $this->configuration->$keyBase; - if(empty($val)) { - $obj = strpos('Users', $keyBase) !== false ? 'Users' : 'Groups'; - \OCP\Util::writeLog('user_ldap', - 'Base tree for '.$obj. - ' is empty, using Base DN', - \OCP\Util::INFO); - $this->configuration->$keyBase = $this->configuration->ldapBase; - } - } - - foreach(array('ldapExpertUUIDUserAttr' => 'ldapUuidUserAttribute', - 'ldapExpertUUIDGroupAttr' => 'ldapUuidGroupAttribute') - as $expertSetting => $effectiveSetting) { - $uuidOverride = $this->configuration->$expertSetting; - if(!empty($uuidOverride)) { - $this->configuration->$effectiveSetting = $uuidOverride; - } else { - $uuidAttributes = array('auto', 'entryuuid', 'nsuniqueid', - 'objectguid', 'guid'); - if(!in_array($this->configuration->$effectiveSetting, - $uuidAttributes) - && (!is_null($this->configID))) { - $this->configuration->$effectiveSetting = 'auto'; - $this->configuration->saveConfiguration(); - \OCP\Util::writeLog('user_ldap', - 'Illegal value for the '. - $effectiveSetting.', '.'reset to '. - 'autodetect.', \OCP\Util::INFO); - } - - } - } - - $backupPort = $this->configuration->ldapBackupPort; - if(empty($backupPort)) { - $this->configuration->backupPort = $this->configuration->ldapPort; - } - - //make sure empty search attributes are saved as simple, empty array - $saKeys = array('ldapAttributesForUserSearch', - 'ldapAttributesForGroupSearch'); - foreach($saKeys as $key) { - $val = $this->configuration->$key; - if(is_array($val) && count($val) === 1 && empty($val[0])) { - $this->configuration->$key = array(); - } - } - - if((stripos($this->configuration->ldapHost, 'ldaps://') === 0) - && $this->configuration->ldapTLS) { - $this->configuration->ldapTLS = false; - \OCP\Util::writeLog('user_ldap', - 'LDAPS (already using secure connection) and '. - 'TLS do not work together. Switched off TLS.', - \OCP\Util::INFO); - } - } - - /** - * @return bool - */ - private function doCriticalValidation() { - $configurationOK = true; - $errorStr = 'Configuration Error (prefix '. - strval($this->configPrefix).'): '; - - //options that shall not be empty - $options = array('ldapHost', 'ldapPort', 'ldapUserDisplayName', - 'ldapGroupDisplayName', 'ldapLoginFilter'); - foreach($options as $key) { - $val = $this->configuration->$key; - if(empty($val)) { - switch($key) { - case 'ldapHost': - $subj = 'LDAP Host'; - break; - case 'ldapPort': - $subj = 'LDAP Port'; - break; - case 'ldapUserDisplayName': - $subj = 'LDAP User Display Name'; - break; - case 'ldapGroupDisplayName': - $subj = 'LDAP Group Display Name'; - break; - case 'ldapLoginFilter': - $subj = 'LDAP Login Filter'; - break; - default: - $subj = $key; - break; - } - $configurationOK = false; - \OCP\Util::writeLog('user_ldap', - $errorStr.'No '.$subj.' given!', - \OCP\Util::WARN); - } - } - - //combinations - $agent = $this->configuration->ldapAgentName; - $pwd = $this->configuration->ldapAgentPassword; - if((empty($agent) && !empty($pwd)) || (!empty($agent) && empty($pwd))) { - \OCP\Util::writeLog('user_ldap', - $errorStr.'either no password is given for the'. - 'user agent or a password is given, but not an'. - 'LDAP agent.', - \OCP\Util::WARN); - $configurationOK = false; - } - - $base = $this->configuration->ldapBase; - $baseUsers = $this->configuration->ldapBaseUsers; - $baseGroups = $this->configuration->ldapBaseGroups; - - if(empty($base) && empty($baseUsers) && empty($baseGroups)) { - \OCP\Util::writeLog('user_ldap', - $errorStr.'Not a single Base DN given.', - \OCP\Util::WARN); - $configurationOK = false; - } - - if(mb_strpos($this->configuration->ldapLoginFilter, '%uid', 0, 'UTF-8') - === false) { - \OCP\Util::writeLog('user_ldap', - $errorStr.'login filter does not contain %uid '. - 'place holder.', - \OCP\Util::WARN); - $configurationOK = false; - } - - return $configurationOK; - } - - /** - * Validates the user specified configuration - * @return bool true if configuration seems OK, false otherwise - */ - private function validateConfiguration() { - - if($this->doNotValidate) { - //don't do a validation if it is a new configuration with pure - //default values. Will be allowed on changes via __set or - //setConfiguration - return false; - } - - // first step: "soft" checks: settings that are not really - // necessary, but advisable. If left empty, give an info message - $this->doSoftValidation(); - - //second step: critical checks. If left empty or filled wrong, mark as - //not configured and give a warning. - return $this->doCriticalValidation(); - } - - - /** - * Connects and Binds to LDAP - */ - private function establishConnection() { - if(!$this->configuration->ldapConfigurationActive) { - return null; - } - static $phpLDAPinstalled = true; - if(!$phpLDAPinstalled) { - return false; - } - if(!$this->ignoreValidation && !$this->configured) { - \OCP\Util::writeLog('user_ldap', - 'Configuration is invalid, cannot connect', - \OCP\Util::WARN); - return false; - } - if(!$this->ldapConnectionRes) { - if(!$this->ldap->areLDAPFunctionsAvailable()) { - $phpLDAPinstalled = false; - \OCP\Util::writeLog('user_ldap', - 'function ldap_connect is not available. Make '. - 'sure that the PHP ldap module is installed.', - \OCP\Util::ERROR); - - return false; - } - if($this->configuration->turnOffCertCheck) { - if(putenv('LDAPTLS_REQCERT=never')) { - \OCP\Util::writeLog('user_ldap', - 'Turned off SSL certificate validation successfully.', - \OCP\Util::DEBUG); - } else { - \OCP\Util::writeLog('user_ldap', - 'Could not turn off SSL certificate validation.', - \OCP\Util::WARN); - } - } - - $bindStatus = false; - $error = -1; - try { - if (!$this->configuration->ldapOverrideMainServer - && !$this->getFromCache('overrideMainServer') - ) { - $this->doConnect($this->configuration->ldapHost, - $this->configuration->ldapPort); - $bindStatus = $this->bind(); - $error = $this->ldap->isResource($this->ldapConnectionRes) ? - $this->ldap->errno($this->ldapConnectionRes) : -1; - } - if($bindStatus === true) { - return $bindStatus; - } - } catch (\OC\ServerNotAvailableException $e) { - if(trim($this->configuration->ldapBackupHost) === "") { - throw $e; - } - } - - //if LDAP server is not reachable, try the Backup (Replica!) Server - if( $error !== 0 - || $this->configuration->ldapOverrideMainServer - || $this->getFromCache('overrideMainServer')) - { - $this->doConnect($this->configuration->ldapBackupHost, - $this->configuration->ldapBackupPort); - $bindStatus = $this->bind(); - if($bindStatus && $error === -1 && !$this->getFromCache('overrideMainServer')) { - //when bind to backup server succeeded and failed to main server, - //skip contacting him until next cache refresh - $this->writeToCache('overrideMainServer', true); - } - } - return $bindStatus; - } - } - - /** - * @param string $host - * @param string $port - * @return false|void - * @throws \OC\ServerNotAvailableException - */ - private function doConnect($host, $port) { - if(empty($host)) { - return false; - } - $this->ldapConnectionRes = $this->ldap->connect($host, $port); - if($this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_PROTOCOL_VERSION, 3)) { - if($this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_REFERRALS, 0)) { - if($this->configuration->ldapTLS) { - $this->ldap->startTls($this->ldapConnectionRes); - } - } - } else { - throw new \OC\ServerNotAvailableException('Could not set required LDAP Protocol version.'); - } - } - - /** - * Binds to LDAP - */ - public function bind() { - static $getConnectionResourceAttempt = false; - if(!$this->configuration->ldapConfigurationActive) { - return false; - } - if($getConnectionResourceAttempt) { - $getConnectionResourceAttempt = false; - return false; - } - $getConnectionResourceAttempt = true; - $cr = $this->getConnectionResource(); - $getConnectionResourceAttempt = false; - if(!$this->ldap->isResource($cr)) { - return false; - } - $ldapLogin = @$this->ldap->bind($cr, - $this->configuration->ldapAgentName, - $this->configuration->ldapAgentPassword); - if(!$ldapLogin) { - \OCP\Util::writeLog('user_ldap', - 'Bind failed: ' . $this->ldap->errno($cr) . ': ' . $this->ldap->error($cr), - \OCP\Util::WARN); - $this->ldapConnectionRes = null; - return false; - } - return true; - } - -} diff --git a/apps/user_ldap/lib/filesystemhelper.php b/apps/user_ldap/lib/filesystemhelper.php deleted file mode 100644 index 03f4c4274f4..00000000000 --- a/apps/user_ldap/lib/filesystemhelper.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -/** - * @brief wraps around static ownCloud core methods - */ -class FilesystemHelper { - - /** - * @brief states whether the filesystem was loaded - * @return bool - */ - public function isLoaded() { - return \OC\Files\Filesystem::$loaded; - } - - /** - * @brief initializes the filesystem for the given user - * @param string $uid the ownCloud username of the user - */ - public function setup($uid) { - \OC_Util::setupFS($uid); - } -} diff --git a/apps/user_ldap/lib/helper.php b/apps/user_ldap/lib/helper.php deleted file mode 100644 index bfff6baf0d3..00000000000 --- a/apps/user_ldap/lib/helper.php +++ /dev/null @@ -1,214 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Brice Maron <brice@bmaron.net> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OCA\user_ldap\User_Proxy; - -class Helper { - - /** - * returns prefixes for each saved LDAP/AD server configuration. - * @param bool $activeConfigurations optional, whether only active configuration shall be - * retrieved, defaults to false - * @return array with a list of the available prefixes - * - * Configuration prefixes are used to set up configurations for n LDAP or - * AD servers. Since configuration is stored in the database, table - * appconfig under appid user_ldap, the common identifiers in column - * 'configkey' have a prefix. The prefix for the very first server - * configuration is empty. - * Configkey Examples: - * Server 1: ldap_login_filter - * Server 2: s1_ldap_login_filter - * Server 3: s2_ldap_login_filter - * - * The prefix needs to be passed to the constructor of Connection class, - * except the default (first) server shall be connected to. - * - */ - public function getServerConfigurationPrefixes($activeConfigurations = false) { - $referenceConfigkey = 'ldap_configuration_active'; - - $sql = ' - SELECT DISTINCT `configkey` - FROM `*PREFIX*appconfig` - WHERE `appid` = \'user_ldap\' - AND `configkey` LIKE ? - '; - - if($activeConfigurations) { - if (\OC::$server->getConfig()->getSystemValue( 'dbtype', 'sqlite' ) === 'oci') { - //FIXME oracle hack: need to explicitly cast CLOB to CHAR for comparison - $sql .= ' AND to_char(`configvalue`)=\'1\''; - } else { - $sql .= ' AND `configvalue` = \'1\''; - } - } - - $stmt = \OCP\DB::prepare($sql); - - $serverConfigs = $stmt->execute(array('%'.$referenceConfigkey))->fetchAll(); - $prefixes = array(); - - foreach($serverConfigs as $serverConfig) { - $len = strlen($serverConfig['configkey']) - strlen($referenceConfigkey); - $prefixes[] = substr($serverConfig['configkey'], 0, $len); - } - - return $prefixes; - } - - /** - * - * determines the host for every configured connection - * @return array an array with configprefix as keys - * - */ - public function getServerConfigurationHosts() { - $referenceConfigkey = 'ldap_host'; - - $query = ' - SELECT DISTINCT `configkey`, `configvalue` - FROM `*PREFIX*appconfig` - WHERE `appid` = \'user_ldap\' - AND `configkey` LIKE ? - '; - $query = \OCP\DB::prepare($query); - $configHosts = $query->execute(array('%'.$referenceConfigkey))->fetchAll(); - $result = array(); - - foreach($configHosts as $configHost) { - $len = strlen($configHost['configkey']) - strlen($referenceConfigkey); - $prefix = substr($configHost['configkey'], 0, $len); - $result[$prefix] = $configHost['configvalue']; - } - - return $result; - } - - /** - * deletes a given saved LDAP/AD server configuration. - * @param string $prefix the configuration prefix of the config to delete - * @return bool true on success, false otherwise - */ - public function deleteServerConfiguration($prefix) { - if(!in_array($prefix, self::getServerConfigurationPrefixes())) { - return false; - } - - $saveOtherConfigurations = ''; - if(empty($prefix)) { - $saveOtherConfigurations = 'AND `configkey` NOT LIKE \'s%\''; - } - - $query = \OCP\DB::prepare(' - DELETE - FROM `*PREFIX*appconfig` - WHERE `configkey` LIKE ? - '.$saveOtherConfigurations.' - AND `appid` = \'user_ldap\' - AND `configkey` NOT IN (\'enabled\', \'installed_version\', \'types\', \'bgjUpdateGroupsLastRun\') - '); - $delRows = $query->execute(array($prefix.'%')); - - if(\OCP\DB::isError($delRows)) { - return false; - } - - if($delRows === 0) { - return false; - } - - return true; - } - - /** - * checks whether there is one or more disabled LDAP configurations - * @throws \Exception - * @return bool - */ - public function haveDisabledConfigurations() { - $all = $this->getServerConfigurationPrefixes(false); - $active = $this->getServerConfigurationPrefixes(true); - - if(!is_array($all) || !is_array($active)) { - throw new \Exception('Unexpected Return Value'); - } - - return count($all) !== count($active) || count($all) === 0; - } - - /** - * extracts the domain from a given URL - * @param string $url the URL - * @return string|false domain as string on success, false otherwise - */ - public function getDomainFromURL($url) { - $uinfo = parse_url($url); - if(!is_array($uinfo)) { - return false; - } - - $domain = false; - if(isset($uinfo['host'])) { - $domain = $uinfo['host']; - } else if(isset($uinfo['path'])) { - $domain = $uinfo['path']; - } - - return $domain; - } - - /** - * listens to a hook thrown by server2server sharing and replaces the given - * login name by a username, if it matches an LDAP user. - * - * @param array $param - * @throws \Exception - */ - public static function loginName2UserName($param) { - if(!isset($param['uid'])) { - throw new \Exception('key uid is expected to be set in $param'); - } - - //ain't it ironic? - $helper = new Helper(); - - $configPrefixes = $helper->getServerConfigurationPrefixes(true); - $ldapWrapper = new LDAP(); - $ocConfig = \OC::$server->getConfig(); - - $userBackend = new User_Proxy( - $configPrefixes, $ldapWrapper, $ocConfig - ); - $uid = $userBackend->loginName2UserName($param['uid'] ); - if($uid !== false) { - $param['uid'] = $uid; - } - } -} diff --git a/apps/user_ldap/lib/ildapwrapper.php b/apps/user_ldap/lib/ildapwrapper.php deleted file mode 100644 index d607a3fdb66..00000000000 --- a/apps/user_ldap/lib/ildapwrapper.php +++ /dev/null @@ -1,209 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -interface ILDAPWrapper { - - //LDAP functions in use - - /** - * Bind to LDAP directory - * @param resource $link LDAP link resource - * @param string $dn an RDN to log in with - * @param string $password the password - * @return bool true on success, false otherwise - * - * with $dn and $password as null a anonymous bind is attempted. - */ - public function bind($link, $dn, $password); - - /** - * connect to an LDAP server - * @param string $host The host to connect to - * @param string $port The port to connect to - * @return mixed a link resource on success, otherwise false - */ - public function connect($host, $port); - - /** - * Send LDAP pagination control - * @param resource $link LDAP link resource - * @param int $pageSize number of results per page - * @param bool $isCritical Indicates whether the pagination is critical of not. - * @param string $cookie structure sent by LDAP server - * @return bool true on success, false otherwise - */ - public function controlPagedResult($link, $pageSize, $isCritical, $cookie); - - /** - * Retrieve the LDAP pagination cookie - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @param string $cookie structure sent by LDAP server - * @return bool true on success, false otherwise - * - * Corresponds to ldap_control_paged_result_response - */ - public function controlPagedResultResponse($link, $result, &$cookie); - - /** - * Count the number of entries in a search - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return int|false number of results on success, false otherwise - */ - public function countEntries($link, $result); - - /** - * Return the LDAP error number of the last LDAP command - * @param resource $link LDAP link resource - * @return string error message as string - */ - public function errno($link); - - /** - * Return the LDAP error message of the last LDAP command - * @param resource $link LDAP link resource - * @return int error code as integer - */ - public function error($link); - - /** - * Splits DN into its component parts - * @param string $dn - * @param int @withAttrib - * @return array|false - * @link http://www.php.net/manual/en/function.ldap-explode-dn.php - */ - public function explodeDN($dn, $withAttrib); - - /** - * Return first result id - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return Resource an LDAP search result resource - * */ - public function firstEntry($link, $result); - - /** - * Get attributes from a search result entry - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return array containing the results, false on error - * */ - public function getAttributes($link, $result); - - /** - * Get the DN of a result entry - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return string containing the DN, false on error - */ - public function getDN($link, $result); - - /** - * Get all result entries - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return array containing the results, false on error - */ - public function getEntries($link, $result); - - /** - * Return next result id - * @param resource $link LDAP link resource - * @param resource $result LDAP entry result resource - * @return resource an LDAP search result resource - * */ - public function nextEntry($link, $result); - - /** - * Read an entry - * @param resource $link LDAP link resource - * @param array $baseDN The DN of the entry to read from - * @param string $filter An LDAP filter - * @param array $attr array of the attributes to read - * @return resource an LDAP search result resource - */ - public function read($link, $baseDN, $filter, $attr); - - /** - * Search LDAP tree - * @param resource $link LDAP link resource - * @param string $baseDN The DN of the entry to read from - * @param string $filter An LDAP filter - * @param array $attr array of the attributes to read - * @param int $attrsOnly optional, 1 if only attribute types shall be returned - * @param int $limit optional, limits the result entries - * @return resource|false an LDAP search result resource, false on error - */ - public function search($link, $baseDN, $filter, $attr, $attrsOnly = 0, $limit = 0); - - /** - * Sets the value of the specified option to be $value - * @param resource $link LDAP link resource - * @param string $option a defined LDAP Server option - * @param int $value the new value for the option - * @return bool true on success, false otherwise - */ - public function setOption($link, $option, $value); - - /** - * establish Start TLS - * @param resource $link LDAP link resource - * @return bool true on success, false otherwise - */ - public function startTls($link); - - /** - * Unbind from LDAP directory - * @param resource $link LDAP link resource - * @return bool true on success, false otherwise - */ - public function unbind($link); - - //additional required methods in ownCloud - - /** - * Checks whether the server supports LDAP - * @return bool true if it the case, false otherwise - * */ - public function areLDAPFunctionsAvailable(); - - /** - * Checks whether PHP supports LDAP Paged Results - * @return bool true if it the case, false otherwise - * */ - public function hasPagedResultSupport(); - - /** - * Checks whether the submitted parameter is a resource - * @param resource $resource the resource variable to check - * @return bool true if it is a resource, false otherwise - */ - public function isResource($resource); - -} diff --git a/apps/user_ldap/lib/jobs.php b/apps/user_ldap/lib/jobs.php deleted file mode 100644 index ecf2e6daa0f..00000000000 --- a/apps/user_ldap/lib/jobs.php +++ /dev/null @@ -1,214 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OCA\User_LDAP\Mapping\GroupMapping; -use OCA\User_LDAP\Mapping\UserMapping; - -class Jobs extends \OC\BackgroundJob\TimedJob { - static private $groupsFromDB; - - static private $groupBE; - static private $connector; - - public function __construct(){ - $this->interval = self::getRefreshInterval(); - } - - /** - * @param mixed $argument - */ - public function run($argument){ - Jobs::updateGroups(); - } - - static public function updateGroups() { - \OCP\Util::writeLog('user_ldap', 'Run background job "updateGroups"', \OCP\Util::DEBUG); - - $knownGroups = array_keys(self::getKnownGroups()); - $actualGroups = self::getGroupBE()->getGroups(); - - if(empty($actualGroups) && empty($knownGroups)) { - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – groups do not seem to be configured properly, aborting.', - \OCP\Util::INFO); - return; - } - - self::handleKnownGroups(array_intersect($actualGroups, $knownGroups)); - self::handleCreatedGroups(array_diff($actualGroups, $knownGroups)); - self::handleRemovedGroups(array_diff($knownGroups, $actualGroups)); - - \OCP\Util::writeLog('user_ldap', 'bgJ "updateGroups" – Finished.', \OCP\Util::DEBUG); - } - - /** - * @return int - */ - static private function getRefreshInterval() { - //defaults to every hour - return \OCP\Config::getAppValue('user_ldap', 'bgjRefreshInterval', 3600); - } - - /** - * @param string[] $groups - */ - static private function handleKnownGroups($groups) { - \OCP\Util::writeLog('user_ldap', 'bgJ "updateGroups" – Dealing with known Groups.', \OCP\Util::DEBUG); - $query = \OCP\DB::prepare(' - UPDATE `*PREFIX*ldap_group_members` - SET `owncloudusers` = ? - WHERE `owncloudname` = ? - '); - foreach($groups as $group) { - //we assume, that self::$groupsFromDB has been retrieved already - $knownUsers = unserialize(self::$groupsFromDB[$group]['owncloudusers']); - $actualUsers = self::getGroupBE()->usersInGroup($group); - $hasChanged = false; - foreach(array_diff($knownUsers, $actualUsers) as $removedUser) { - \OCP\Util::emitHook('OC_User', 'post_removeFromGroup', array('uid' => $removedUser, 'gid' => $group)); - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – "'.$removedUser.'" removed from "'.$group.'".', - \OCP\Util::INFO); - $hasChanged = true; - } - foreach(array_diff($actualUsers, $knownUsers) as $addedUser) { - \OCP\Util::emitHook('OC_User', 'post_addToGroup', array('uid' => $addedUser, 'gid' => $group)); - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – "'.$addedUser.'" added to "'.$group.'".', - \OCP\Util::INFO); - $hasChanged = true; - } - if($hasChanged) { - $query->execute(array(serialize($actualUsers), $group)); - } - } - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – FINISHED dealing with known Groups.', - \OCP\Util::DEBUG); - } - - /** - * @param string[] $createdGroups - */ - static private function handleCreatedGroups($createdGroups) { - \OCP\Util::writeLog('user_ldap', 'bgJ "updateGroups" – dealing with created Groups.', \OCP\Util::DEBUG); - $query = \OCP\DB::prepare(' - INSERT - INTO `*PREFIX*ldap_group_members` (`owncloudname`, `owncloudusers`) - VALUES (?, ?) - '); - foreach($createdGroups as $createdGroup) { - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – new group "'.$createdGroup.'" found.', - \OCP\Util::INFO); - $users = serialize(self::getGroupBE()->usersInGroup($createdGroup)); - $query->execute(array($createdGroup, $users)); - } - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – FINISHED dealing with created Groups.', - \OCP\Util::DEBUG); - } - - /** - * @param string[] $removedGroups - */ - static private function handleRemovedGroups($removedGroups) { - \OCP\Util::writeLog('user_ldap', 'bgJ "updateGroups" – dealing with removed groups.', \OCP\Util::DEBUG); - $query = \OCP\DB::prepare(' - DELETE - FROM `*PREFIX*ldap_group_members` - WHERE `owncloudname` = ? - '); - foreach($removedGroups as $removedGroup) { - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – group "'.$removedGroup.'" was removed.', - \OCP\Util::INFO); - $query->execute(array($removedGroup)); - } - \OCP\Util::writeLog('user_ldap', - 'bgJ "updateGroups" – FINISHED dealing with removed groups.', - \OCP\Util::DEBUG); - } - - /** - * @return \OCA\user_ldap\GROUP_LDAP|\OCA\user_ldap\Group_Proxy - */ - static private function getGroupBE() { - if(!is_null(self::$groupBE)) { - return self::$groupBE; - } - $helper = new Helper(); - $configPrefixes = $helper->getServerConfigurationPrefixes(true); - $ldapWrapper = new LDAP(); - if(count($configPrefixes) === 1) { - //avoid the proxy when there is only one LDAP server configured - $dbc = \OC::$server->getDatabaseConnection(); - $userManager = new user\Manager( - \OC::$server->getConfig(), - new FilesystemHelper(), - new LogWrapper(), - \OC::$server->getAvatarManager(), - new \OCP\Image(), - $dbc, - \OC::$server->getUserManager()); - $connector = new Connection($ldapWrapper, $configPrefixes[0]); - $ldapAccess = new Access($connector, $ldapWrapper, $userManager); - $groupMapper = new GroupMapping($dbc); - $userMapper = new UserMapping($dbc); - $ldapAccess->setGroupMapper($groupMapper); - $ldapAccess->setUserMapper($userMapper); - self::$groupBE = new \OCA\user_ldap\GROUP_LDAP($ldapAccess); - } else { - self::$groupBE = new \OCA\user_ldap\Group_Proxy($configPrefixes, $ldapWrapper); - } - - return self::$groupBE; - } - - /** - * @return array - */ - static private function getKnownGroups() { - if(is_array(self::$groupsFromDB)) { - return self::$groupsFromDB; - } - $query = \OCP\DB::prepare(' - SELECT `owncloudname`, `owncloudusers` - FROM `*PREFIX*ldap_group_members` - '); - $result = $query->execute()->fetchAll(); - self::$groupsFromDB = array(); - foreach($result as $dataset) { - self::$groupsFromDB[$dataset['owncloudname']] = $dataset; - } - - return self::$groupsFromDB; - } -} diff --git a/apps/user_ldap/lib/jobs/cleanup.php b/apps/user_ldap/lib/jobs/cleanup.php deleted file mode 100644 index c9f5f2021eb..00000000000 --- a/apps/user_ldap/lib/jobs/cleanup.php +++ /dev/null @@ -1,231 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\User_LDAP\Jobs; - -use \OC\BackgroundJob\TimedJob; -use \OCA\user_ldap\User_LDAP; -use \OCA\user_ldap\User_Proxy; -use \OCA\user_ldap\lib\Helper; -use \OCA\user_ldap\lib\LDAP; -use \OCA\user_ldap\lib\user\DeletedUsersIndex; -use \OCA\User_LDAP\Mapping\UserMapping; - -/** - * Class CleanUp - * - * a Background job to clean up deleted users - * - * @package OCA\user_ldap\lib; - */ -class CleanUp extends TimedJob { - /** @var int $limit amount of users that should be checked per run */ - protected $limit = 50; - - /** @var int $defaultIntervalMin default interval in minutes */ - protected $defaultIntervalMin = 51; - - /** @var User_LDAP|User_Proxy $userBackend */ - protected $userBackend; - - /** @var \OCP\IConfig $ocConfig */ - protected $ocConfig; - - /** @var \OCP\IDBConnection $db */ - protected $db; - - /** @var Helper $ldapHelper */ - protected $ldapHelper; - - /** @var \OCA\User_LDAP\Mapping\UserMapping */ - protected $mapping; - - /** @var \OCA\User_LDAP\lib\User\DeletedUsersIndex */ - protected $dui; - - public function __construct() { - $minutes = \OC::$server->getConfig()->getSystemValue( - 'ldapUserCleanupInterval', strval($this->defaultIntervalMin)); - $this->setInterval(intval($minutes) * 60); - } - - /** - * assigns the instances passed to run() to the class properties - * @param array $arguments - */ - public function setArguments($arguments) { - //Dependency Injection is not possible, because the constructor will - //only get values that are serialized to JSON. I.e. whatever we would - //pass in app.php we do add here, except something else is passed e.g. - //in tests. - - if(isset($arguments['helper'])) { - $this->ldapHelper = $arguments['helper']; - } else { - $this->ldapHelper = new Helper(); - } - - if(isset($arguments['ocConfig'])) { - $this->ocConfig = $arguments['ocConfig']; - } else { - $this->ocConfig = \OC::$server->getConfig(); - } - - if(isset($arguments['userBackend'])) { - $this->userBackend = $arguments['userBackend']; - } else { - $this->userBackend = new User_Proxy( - $this->ldapHelper->getServerConfigurationPrefixes(true), - new LDAP(), - $this->ocConfig - ); - } - - if(isset($arguments['db'])) { - $this->db = $arguments['db']; - } else { - $this->db = \OC::$server->getDatabaseConnection(); - } - - if(isset($arguments['mapping'])) { - $this->mapping = $arguments['mapping']; - } else { - $this->mapping = new UserMapping($this->db); - } - - if(isset($arguments['deletedUsersIndex'])) { - $this->dui = $arguments['deletedUsersIndex']; - } else { - $this->dui = new DeletedUsersIndex( - $this->ocConfig, $this->db, $this->mapping); - } - } - - /** - * makes the background job do its work - * @param array $argument - */ - public function run($argument) { - $this->setArguments($argument); - - if(!$this->isCleanUpAllowed()) { - return; - } - $users = $this->mapping->getList($this->getOffset(), $this->limit); - if(!is_array($users)) { - //something wrong? Let's start from the beginning next time and - //abort - $this->setOffset(true); - return; - } - $resetOffset = $this->isOffsetResetNecessary(count($users)); - $this->checkUsers($users); - $this->setOffset($resetOffset); - } - - /** - * checks whether next run should start at 0 again - * @param int $resultCount - * @return bool - */ - public function isOffsetResetNecessary($resultCount) { - return ($resultCount < $this->limit) ? true : false; - } - - /** - * checks whether cleaning up LDAP users is allowed - * @return bool - */ - public function isCleanUpAllowed() { - try { - if($this->ldapHelper->haveDisabledConfigurations()) { - return false; - } - } catch (\Exception $e) { - return false; - } - - $enabled = $this->isCleanUpEnabled(); - - return $enabled; - } - - /** - * checks whether clean up is enabled by configuration - * @return bool - */ - private function isCleanUpEnabled() { - return (bool)$this->ocConfig->getSystemValue( - 'ldapUserCleanupInterval', strval($this->defaultIntervalMin)); - } - - /** - * checks users whether they are still existing - * @param array $users result from getMappedUsers() - */ - private function checkUsers(array $users) { - foreach($users as $user) { - $this->checkUser($user); - } - } - - /** - * checks whether a user is still existing in LDAP - * @param string[] $user - */ - private function checkUser(array $user) { - if($this->userBackend->userExistsOnLDAP($user['name'])) { - //still available, all good - - return; - } - - $this->dui->markUser($user['name']); - } - - /** - * gets the offset to fetch users from the mappings table - * @return int - */ - private function getOffset() { - return intval($this->ocConfig->getAppValue('user_ldap', 'cleanUpJobOffset', 0)); - } - - /** - * sets the new offset for the next run - * @param bool $reset whether the offset should be set to 0 - */ - public function setOffset($reset = false) { - $newOffset = $reset ? 0 : - $this->getOffset() + $this->limit; - $this->ocConfig->setAppValue('user_ldap', 'cleanUpJobOffset', $newOffset); - } - - /** - * returns the chunk size (limit in DB speak) - * @return int - */ - public function getChunkSize() { - return $this->limit; - } - -} diff --git a/apps/user_ldap/lib/ldap.php b/apps/user_ldap/lib/ldap.php deleted file mode 100644 index 383d71ca6eb..00000000000 --- a/apps/user_ldap/lib/ldap.php +++ /dev/null @@ -1,301 +0,0 @@ -<?php -/** - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OC\ServerNotAvailableException; - -class LDAP implements ILDAPWrapper { - protected $curFunc = ''; - protected $curArgs = array(); - - /** - * @param resource $link - * @param string $dn - * @param string $password - * @return bool|mixed - */ - public function bind($link, $dn, $password) { - return $this->invokeLDAPMethod('bind', $link, $dn, $password); - } - - /** - * @param string $host - * @param string $port - * @return mixed - */ - public function connect($host, $port) { - if(strpos($host, '://') === false) { - $host = 'ldap://' . $host; - } - if(strpos($host, ':', strpos($host, '://') + 1) === false) { - //ldap_connect ignores port parameter when URLs are passed - $host .= ':' . $port; - } - return $this->invokeLDAPMethod('connect', $host); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @param string $cookie - * @return bool|LDAP - */ - public function controlPagedResultResponse($link, $result, &$cookie) { - $this->preFunctionCall('ldap_control_paged_result_response', - array($link, $result, $cookie)); - $result = ldap_control_paged_result_response($link, $result, $cookie); - $this->postFunctionCall(); - - return $result; - } - - /** - * @param LDAP $link - * @param int $pageSize - * @param bool $isCritical - * @param string $cookie - * @return mixed|true - */ - public function controlPagedResult($link, $pageSize, $isCritical, $cookie) { - return $this->invokeLDAPMethod('control_paged_result', $link, $pageSize, - $isCritical, $cookie); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @return mixed - */ - public function countEntries($link, $result) { - return $this->invokeLDAPMethod('count_entries', $link, $result); - } - - /** - * @param LDAP $link - * @return mixed|string - */ - public function errno($link) { - return $this->invokeLDAPMethod('errno', $link); - } - - /** - * @param LDAP $link - * @return int|mixed - */ - public function error($link) { - return $this->invokeLDAPMethod('error', $link); - } - - /** - * Splits DN into its component parts - * @param string $dn - * @param int @withAttrib - * @return array|false - * @link http://www.php.net/manual/en/function.ldap-explode-dn.php - */ - public function explodeDN($dn, $withAttrib) { - return $this->invokeLDAPMethod('explode_dn', $dn, $withAttrib); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @return mixed - */ - public function firstEntry($link, $result) { - return $this->invokeLDAPMethod('first_entry', $link, $result); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @return array|mixed - */ - public function getAttributes($link, $result) { - return $this->invokeLDAPMethod('get_attributes', $link, $result); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @return mixed|string - */ - public function getDN($link, $result) { - return $this->invokeLDAPMethod('get_dn', $link, $result); - } - - /** - * @param LDAP $link - * @param LDAP $result - * @return array|mixed - */ - public function getEntries($link, $result) { - return $this->invokeLDAPMethod('get_entries', $link, $result); - } - - /** - * @param LDAP $link - * @param resource $result - * @return mixed|an - */ - public function nextEntry($link, $result) { - return $this->invokeLDAPMethod('next_entry', $link, $result); - } - - /** - * @param LDAP $link - * @param string $baseDN - * @param string $filter - * @param array $attr - * @return mixed - */ - public function read($link, $baseDN, $filter, $attr) { - return $this->invokeLDAPMethod('read', $link, $baseDN, $filter, $attr); - } - - /** - * @param LDAP $link - * @param string $baseDN - * @param string $filter - * @param array $attr - * @param int $attrsOnly - * @param int $limit - * @return mixed - */ - public function search($link, $baseDN, $filter, $attr, $attrsOnly = 0, $limit = 0) { - return $this->invokeLDAPMethod('search', $link, $baseDN, $filter, $attr, $attrsOnly, $limit); - } - - /** - * @param LDAP $link - * @param string $option - * @param int $value - * @return bool|mixed - */ - public function setOption($link, $option, $value) { - return $this->invokeLDAPMethod('set_option', $link, $option, $value); - } - - /** - * @param LDAP $link - * @return mixed|true - */ - public function startTls($link) { - return $this->invokeLDAPMethod('start_tls', $link); - } - - /** - * @param resource $link - * @return bool|mixed - */ - public function unbind($link) { - return $this->invokeLDAPMethod('unbind', $link); - } - - /** - * Checks whether the server supports LDAP - * @return boolean if it the case, false otherwise - * */ - public function areLDAPFunctionsAvailable() { - return function_exists('ldap_connect'); - } - - /** - * Checks whether PHP supports LDAP Paged Results - * @return boolean if it the case, false otherwise - * */ - public function hasPagedResultSupport() { - $hasSupport = function_exists('ldap_control_paged_result') - && function_exists('ldap_control_paged_result_response'); - return $hasSupport; - } - - /** - * Checks whether the submitted parameter is a resource - * @param Resource $resource the resource variable to check - * @return bool true if it is a resource, false otherwise - */ - public function isResource($resource) { - return is_resource($resource); - } - - /** - * @return mixed - */ - private function invokeLDAPMethod() { - $arguments = func_get_args(); - $func = 'ldap_' . array_shift($arguments); - if(function_exists($func)) { - $this->preFunctionCall($func, $arguments); - $result = call_user_func_array($func, $arguments); - if ($result === FALSE) { - $this->postFunctionCall(); - } - return $result; - } - } - - /** - * @param string $functionName - * @param array $args - */ - private function preFunctionCall($functionName, $args) { - $this->curFunc = $functionName; - $this->curArgs = $args; - } - - private function postFunctionCall() { - if($this->isResource($this->curArgs[0])) { - $errorCode = ldap_errno($this->curArgs[0]); - $errorMsg = ldap_error($this->curArgs[0]); - if($errorCode !== 0) { - if($this->curFunc === 'ldap_get_entries' - && $errorCode === -4) { - } else if ($errorCode === 32) { - //for now - } else if ($errorCode === 10) { - //referrals, we switch them off, but then there is AD :) - } else if ($errorCode === -1) { - throw new ServerNotAvailableException('Lost connection to LDAP server.'); - } else if ($errorCode === 48) { - throw new \Exception('LDAP authentication method rejected', $errorCode); - } else if ($errorCode === 1) { - throw new \Exception('LDAP Operations error', $errorCode); - } else { - \OCP\Util::writeLog('user_ldap', - 'LDAP error '.$errorMsg.' (' . - $errorCode.') after calling '. - $this->curFunc, - \OCP\Util::DEBUG); - } - } - } - - $this->curFunc = ''; - $this->curArgs = array(); - } -} diff --git a/apps/user_ldap/lib/ldaputility.php b/apps/user_ldap/lib/ldaputility.php deleted file mode 100644 index e80fc12e087..00000000000 --- a/apps/user_ldap/lib/ldaputility.php +++ /dev/null @@ -1,36 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -abstract class LDAPUtility { - protected $ldap; - - /** - * constructor, make sure the subclasses call this one! - * @param ILDAPWrapper $ldapWrapper an instance of an ILDAPWrapper - */ - public function __construct(ILDAPWrapper $ldapWrapper) { - $this->ldap = $ldapWrapper; - } -} diff --git a/apps/user_ldap/lib/logwrapper.php b/apps/user_ldap/lib/logwrapper.php deleted file mode 100644 index 41ae4fc3426..00000000000 --- a/apps/user_ldap/lib/logwrapper.php +++ /dev/null @@ -1,38 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -/** - * @brief wraps around static ownCloud core methods - */ -class LogWrapper { - protected $app = 'user_ldap'; - - /** - * @brief states whether the filesystem was loaded - * @return bool - */ - public function log($msg, $level) { - \OCP\Util::writeLog($this->app, $msg, $level); - } -} diff --git a/apps/user_ldap/lib/mapping/abstractmapping.php b/apps/user_ldap/lib/mapping/abstractmapping.php deleted file mode 100644 index 1c896a9bbf4..00000000000 --- a/apps/user_ldap/lib/mapping/abstractmapping.php +++ /dev/null @@ -1,246 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\User_LDAP\Mapping; - -/** -* Class AbstractMapping -* @package OCA\User_LDAP\Mapping -*/ -abstract class AbstractMapping { - /** - * @var \OCP\IDBConnection $dbc - */ - protected $dbc; - - /** - * returns the DB table name which holds the mappings - * @return string - */ - abstract protected function getTableName(); - - /** - * @param \OCP\IDBConnection $dbc - */ - public function __construct(\OCP\IDBConnection $dbc) { - $this->dbc = $dbc; - } - - /** - * checks whether a provided string represents an existing table col - * @param string $col - * @return bool - */ - public function isColNameValid($col) { - switch($col) { - case 'ldap_dn': - case 'owncloud_name': - case 'directory_uuid': - return true; - default: - return false; - } - } - - /** - * Gets the value of one column based on a provided value of another column - * @param string $fetchCol - * @param string $compareCol - * @param string $search - * @throws \Exception - * @return string|false - */ - protected function getXbyY($fetchCol, $compareCol, $search) { - if(!$this->isColNameValid($fetchCol)) { - //this is used internally only, but we don't want to risk - //having SQL injection at all. - throw new \Exception('Invalid Column Name'); - } - $query = $this->dbc->prepare(' - SELECT `' . $fetchCol . '` - FROM `'. $this->getTableName() .'` - WHERE `' . $compareCol . '` = ? - '); - - $res = $query->execute(array($search)); - if($res !== false) { - return $query->fetchColumn(); - } - - return false; - } - - /** - * Performs a DELETE or UPDATE query to the database. - * @param \Doctrine\DBAL\Driver\Statement $query - * @param array $parameters - * @return bool true if at least one row was modified, false otherwise - */ - protected function modify($query, $parameters) { - $result = $query->execute($parameters); - return ($result === true && $query->rowCount() > 0); - } - - /** - * Gets the LDAP DN based on the provided name. - * Replaces Access::ocname2dn - * @param string $name - * @return string|false - */ - public function getDNByName($name) { - return $this->getXbyY('ldap_dn', 'owncloud_name', $name); - } - - /** - * Updates the DN based on the given UUID - * @param string $fdn - * @param string $uuid - * @return bool - */ - public function setDNbyUUID($fdn, $uuid) { - $query = $this->dbc->prepare(' - UPDATE `' . $this->getTableName() . '` - SET `ldap_dn` = ? - WHERE `directory_uuid` = ? - '); - - return $this->modify($query, array($fdn, $uuid)); - } - - /** - * Gets the name based on the provided LDAP DN. - * @param string $fdn - * @return string|false - */ - public function getNameByDN($fdn) { - return $this->getXbyY('owncloud_name', 'ldap_dn', $fdn); - } - - /** - * Searches mapped names by the giving string in the name column - * @param string $search - * @return string[] - */ - public function getNamesBySearch($search) { - $query = $this->dbc->prepare(' - SELECT `owncloud_name` - FROM `'. $this->getTableName() .'` - WHERE `owncloud_name` LIKE ? - '); - - $res = $query->execute(array($search)); - $names = array(); - if($res !== false) { - while($row = $query->fetch()) { - $names[] = $row['owncloud_name']; - } - } - return $names; - } - - /** - * Gets the name based on the provided LDAP UUID. - * @param string $uuid - * @return string|false - */ - public function getNameByUUID($uuid) { - return $this->getXbyY('owncloud_name', 'directory_uuid', $uuid); - } - - /** - * Gets the UUID based on the provided LDAP DN - * @param string $dn - * @return false|string - * @throws \Exception - */ - public function getUUIDByDN($dn) { - return $this->getXbyY('directory_uuid', 'ldap_dn', $dn); - } - - /** - * gets a piece of the mapping list - * @param int $offset - * @param int $limit - * @return array - */ - public function getList($offset = null, $limit = null) { - $query = $this->dbc->prepare(' - SELECT - `ldap_dn` AS `dn`, - `owncloud_name` AS `name`, - `directory_uuid` AS `uuid` - FROM `' . $this->getTableName() . '`', - $limit, - $offset - ); - - $query->execute(); - return $query->fetchAll(); - } - - /** - * attempts to map the given entry - * @param string $fdn fully distinguished name (from LDAP) - * @param string $name - * @param string $uuid a unique identifier as used in LDAP - * @return bool - */ - public function map($fdn, $name, $uuid) { - $row = array( - 'ldap_dn' => $fdn, - 'owncloud_name' => $name, - 'directory_uuid' => $uuid - ); - - try { - $result = $this->dbc->insertIfNotExist($this->getTableName(), $row); - // insertIfNotExist returns values as int - return (bool)$result; - } catch (\Exception $e) { - return false; - } - } - - /** - * removes a mapping based on the owncloud_name of the entry - * @param string $name - * @return bool - */ - public function unmap($name) { - $query = $this->dbc->prepare(' - DELETE FROM `'. $this->getTableName() .'` - WHERE `owncloud_name` = ?'); - - return $this->modify($query, array($name)); - } - - /** - * Truncate's the mapping table - * @return bool - */ - public function clear() { - $sql = $this->dbc - ->getDatabasePlatform() - ->getTruncateTableSQL('`' . $this->getTableName() . '`'); - return $this->dbc->prepare($sql)->execute(); - } -} diff --git a/apps/user_ldap/lib/mapping/groupmapping.php b/apps/user_ldap/lib/mapping/groupmapping.php deleted file mode 100644 index 49bb41b8c76..00000000000 --- a/apps/user_ldap/lib/mapping/groupmapping.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\User_LDAP\Mapping; - -/** -* Class UserMapping -* @package OCA\User_LDAP\Mapping -*/ -class GroupMapping extends AbstractMapping { - - /** - * returns the DB table name which holds the mappings - * @return string - */ - protected function getTableName() { - return '*PREFIX*ldap_group_mapping'; - } - -} diff --git a/apps/user_ldap/lib/mapping/usermapping.php b/apps/user_ldap/lib/mapping/usermapping.php deleted file mode 100644 index b39f738ea8c..00000000000 --- a/apps/user_ldap/lib/mapping/usermapping.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\User_LDAP\Mapping; - -/** -* Class UserMapping -* @package OCA\User_LDAP\Mapping -*/ -class UserMapping extends AbstractMapping { - - /** - * returns the DB table name which holds the mappings - * @return string - */ - protected function getTableName() { - return '*PREFIX*ldap_user_mapping'; - } - -} diff --git a/apps/user_ldap/lib/proxy.php b/apps/user_ldap/lib/proxy.php deleted file mode 100644 index 7002aaadaa5..00000000000 --- a/apps/user_ldap/lib/proxy.php +++ /dev/null @@ -1,200 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -use OCA\user_ldap\lib\Access; -use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\Mapping\GroupMapping; - -abstract class Proxy { - static private $accesses = array(); - private $ldap = null; - - /** @var \OCP\ICache|null */ - private $cache; - - /** - * @param ILDAPWrapper $ldap - */ - public function __construct(ILDAPWrapper $ldap) { - $this->ldap = $ldap; - $memcache = \OC::$server->getMemCacheFactory(); - if($memcache->isAvailable()) { - $this->cache = $memcache->create(); - } - } - - /** - * @param string $configPrefix - */ - private function addAccess($configPrefix) { - static $ocConfig; - static $fs; - static $log; - static $avatarM; - static $userMap; - static $groupMap; - static $db; - static $coreUserManager; - if(is_null($fs)) { - $ocConfig = \OC::$server->getConfig(); - $fs = new FilesystemHelper(); - $log = new LogWrapper(); - $avatarM = \OC::$server->getAvatarManager(); - $db = \OC::$server->getDatabaseConnection(); - $userMap = new UserMapping($db); - $groupMap = new GroupMapping($db); - $coreUserManager = \OC::$server->getUserManager(); - } - $userManager = - new user\Manager($ocConfig, $fs, $log, $avatarM, new \OCP\Image(), $db, $coreUserManager); - $connector = new Connection($this->ldap, $configPrefix); - $access = new Access($connector, $this->ldap, $userManager); - $access->setUserMapper($userMap); - $access->setGroupMapper($groupMap); - self::$accesses[$configPrefix] = $access; - } - - /** - * @param string $configPrefix - * @return mixed - */ - protected function getAccess($configPrefix) { - if(!isset(self::$accesses[$configPrefix])) { - $this->addAccess($configPrefix); - } - return self::$accesses[$configPrefix]; - } - - /** - * @param string $uid - * @return string - */ - protected function getUserCacheKey($uid) { - return 'user-'.$uid.'-lastSeenOn'; - } - - /** - * @param string $gid - * @return string - */ - protected function getGroupCacheKey($gid) { - return 'group-'.$gid.'-lastSeenOn'; - } - - /** - * @param string $id - * @param string $method - * @param array $parameters - * @param bool $passOnWhen - * @return mixed - */ - abstract protected function callOnLastSeenOn($id, $method, $parameters, $passOnWhen); - - /** - * @param string $id - * @param string $method - * @param array $parameters - * @return mixed - */ - abstract protected function walkBackends($id, $method, $parameters); - - /** - * Takes care of the request to the User backend - * @param string $id - * @param string $method string, the method of the user backend that shall be called - * @param array $parameters an array of parameters to be passed - * @param bool $passOnWhen - * @return mixed, the result of the specified method - */ - protected function handleRequest($id, $method, $parameters, $passOnWhen = false) { - $result = $this->callOnLastSeenOn($id, $method, $parameters, $passOnWhen); - if($result === $passOnWhen) { - $result = $this->walkBackends($id, $method, $parameters); - } - return $result; - } - - /** - * @param string|null $key - * @return string - */ - private function getCacheKey($key) { - $prefix = 'LDAP-Proxy-'; - if(is_null($key)) { - return $prefix; - } - return $prefix.md5($key); - } - - /** - * @param string $key - * @return mixed|null - */ - public function getFromCache($key) { - if(is_null($this->cache) || !$this->isCached($key)) { - return null; - } - $key = $this->getCacheKey($key); - - return json_decode(base64_decode($this->cache->get($key))); - } - - /** - * @param string $key - * @return bool - */ - public function isCached($key) { - if(is_null($this->cache)) { - return false; - } - $key = $this->getCacheKey($key); - return $this->cache->hasKey($key); - } - - /** - * @param string $key - * @param mixed $value - */ - public function writeToCache($key, $value) { - if(is_null($this->cache)) { - return; - } - $key = $this->getCacheKey($key); - $value = base64_encode(json_encode($value)); - $this->cache->set($key, $value, '2592000'); - } - - public function clearCache() { - if(is_null($this->cache)) { - return; - } - $this->cache->clear($this->getCacheKey(null)); - } -} diff --git a/apps/user_ldap/lib/user/deletedusersindex.php b/apps/user_ldap/lib/user/deletedusersindex.php deleted file mode 100644 index 48daeb9b8bc..00000000000 --- a/apps/user_ldap/lib/user/deletedusersindex.php +++ /dev/null @@ -1,113 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib\user; - -use OCA\User_LDAP\Mapping\UserMapping; - -/** - * Class DeletedUsersIndex - * @package OCA\User_LDAP - */ -class DeletedUsersIndex { - /** - * @var \OCP\IConfig $config - */ - protected $config; - - /** - * @var \OCP\IDBConnection $db - */ - protected $db; - - /** - * @var \OCA\User_LDAP\Mapping\UserMapping $mapping - */ - protected $mapping; - - /** - * @var array $deletedUsers - */ - protected $deletedUsers; - - /** - * @param \OCP\IConfig $config - * @param \OCP\IDBConnection $db - * @param \OCA\User_LDAP\Mapping\UserMapping $mapping - */ - public function __construct(\OCP\IConfig $config, \OCP\IDBConnection $db, UserMapping $mapping) { - $this->config = $config; - $this->db = $db; - $this->mapping = $mapping; - } - - /** - * reads LDAP users marked as deleted from the database - * @return \OCA\user_ldap\lib\user\OfflineUser[] - */ - private function fetchDeletedUsers() { - $deletedUsers = $this->config->getUsersForUserValue( - 'user_ldap', 'isDeleted', '1'); - - $userObjects = array(); - foreach($deletedUsers as $user) { - $userObjects[] = new OfflineUser($user, $this->config, $this->db, $this->mapping); - } - $this->deletedUsers = $userObjects; - - return $this->deletedUsers; - } - - /** - * returns all LDAP users that are marked as deleted - * @return \OCA\user_ldap\lib\user\OfflineUser[] - */ - public function getUsers() { - if(is_array($this->deletedUsers)) { - return $this->deletedUsers; - } - return $this->fetchDeletedUsers(); - } - - /** - * whether at least one user was detected as deleted - * @return bool - */ - public function hasUsers() { - if($this->deletedUsers === false) { - $this->fetchDeletedUsers(); - } - if(is_array($this->deletedUsers) && count($this->deletedUsers) > 0) { - return true; - } - return false; - } - - /** - * marks a user as deleted - * @param string $ocName - */ - public function markUser($ocName) { - $this->config->setUserValue($ocName, 'user_ldap', 'isDeleted', '1'); - } -} diff --git a/apps/user_ldap/lib/user/iusertools.php b/apps/user_ldap/lib/user/iusertools.php deleted file mode 100644 index b0eb9e1ffb3..00000000000 --- a/apps/user_ldap/lib/user/iusertools.php +++ /dev/null @@ -1,40 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib\user; - -/** - * IUserTools - * - * defines methods that are required by User class for LDAP interaction - */ -interface IUserTools { - public function getConnection(); - - public function readAttribute($dn, $attr, $filter = 'objectClass=*'); - - public function stringResemblesDN($string); - - public function dn2username($dn, $ldapname = null); - - public function username2dn($name); -} diff --git a/apps/user_ldap/lib/user/manager.php b/apps/user_ldap/lib/user/manager.php deleted file mode 100644 index dc12ebd6e9d..00000000000 --- a/apps/user_ldap/lib/user/manager.php +++ /dev/null @@ -1,241 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib\user; - -use OCA\user_ldap\lib\user\IUserTools; -use OCA\user_ldap\lib\user\User; -use OCA\user_ldap\lib\LogWrapper; -use OCA\user_ldap\lib\FilesystemHelper; -use OCA\user_ldap\lib\user\OfflineUser; -use OCP\IAvatarManager; -use OCP\IConfig; -use OCP\IDBConnection; -use OCP\Image; -use OCP\IUserManager; - -/** - * Manager - * - * upon request, returns an LDAP user object either by creating or from run-time - * cache - */ -class Manager { - /** @var IUserTools */ - protected $access; - - /** @var IConfig */ - protected $ocConfig; - - /** @var IDBConnection */ - protected $db; - - /** @var FilesystemHelper */ - protected $ocFilesystem; - - /** @var LogWrapper */ - protected $ocLog; - - /** @var Image */ - protected $image; - - /** @param \OCP\IAvatarManager */ - protected $avatarManager; - - /** - * array['byDN'] \OCA\user_ldap\lib\User[] - * ['byUid'] \OCA\user_ldap\lib\User[] - * @var array $users - */ - protected $users = array( - 'byDN' => array(), - 'byUid' => array(), - ); - - /** - * @param IConfig $ocConfig - * @param \OCA\user_ldap\lib\FilesystemHelper $ocFilesystem object that - * gives access to necessary functions from the OC filesystem - * @param \OCA\user_ldap\lib\LogWrapper $ocLog - * @param IAvatarManager $avatarManager - * @param Image $image an empty image instance - * @param IDBConnection $db - * @throws \Exception when the methods mentioned above do not exist - */ - public function __construct(IConfig $ocConfig, - FilesystemHelper $ocFilesystem, LogWrapper $ocLog, - IAvatarManager $avatarManager, Image $image, - IDBConnection $db, IUserManager $userManager) { - - $this->ocConfig = $ocConfig; - $this->ocFilesystem = $ocFilesystem; - $this->ocLog = $ocLog; - $this->avatarManager = $avatarManager; - $this->image = $image; - $this->db = $db; - $this->userManager = $userManager; - } - - /** - * @brief binds manager to an instance of IUserTools (implemented by - * Access). It needs to be assigned first before the manager can be used. - * @param IUserTools - */ - public function setLdapAccess(IUserTools $access) { - $this->access = $access; - } - - /** - * @brief creates an instance of User and caches (just runtime) it in the - * property array - * @param string $dn the DN of the user - * @param string $uid the internal (owncloud) username - * @return \OCA\user_ldap\lib\User\User - */ - private function createAndCache($dn, $uid) { - $this->checkAccess(); - $user = new User($uid, $dn, $this->access, $this->ocConfig, - $this->ocFilesystem, clone $this->image, $this->ocLog, - $this->avatarManager, $this->userManager); - $this->users['byDN'][$dn] = $user; - $this->users['byUid'][$uid] = $user; - return $user; - } - - /** - * @brief checks whether the Access instance has been set - * @throws \Exception if Access has not been set - * @return null - */ - private function checkAccess() { - if(is_null($this->access)) { - throw new \Exception('LDAP Access instance must be set first'); - } - } - - /** - * returns a list of attributes that will be processed further, e.g. quota, - * email, displayname, or others. - * @param bool $minimal - optional, set to true to skip attributes with big - * payload - * @return string[] - */ - public function getAttributes($minimal = false) { - $attributes = array('dn', 'uid', 'samaccountname', 'memberof'); - $possible = array( - $this->access->getConnection()->ldapQuotaAttribute, - $this->access->getConnection()->ldapEmailAttribute, - $this->access->getConnection()->ldapUserDisplayName, - $this->access->getConnection()->ldapUserDisplayName2, - ); - foreach($possible as $attr) { - if(!is_null($attr)) { - $attributes[] = $attr; - } - } - - $homeRule = $this->access->getConnection()->homeFolderNamingRule; - if(strpos($homeRule, 'attr:') === 0) { - $attributes[] = substr($homeRule, strlen('attr:')); - } - - if(!$minimal) { - // attributes that are not really important but may come with big - // payload. - $attributes = array_merge($attributes, array( - 'jpegphoto', - 'thumbnailphoto' - )); - } - - return $attributes; - } - - /** - * Checks whether the specified user is marked as deleted - * @param string $id the ownCloud user name - * @return bool - */ - public function isDeletedUser($id) { - $isDeleted = $this->ocConfig->getUserValue( - $id, 'user_ldap', 'isDeleted', 0); - return intval($isDeleted) === 1; - } - - /** - * creates and returns an instance of OfflineUser for the specified user - * @param string $id - * @return \OCA\user_ldap\lib\user\OfflineUser - */ - public function getDeletedUser($id) { - return new OfflineUser( - $id, - $this->ocConfig, - $this->db, - $this->access->getUserMapper()); - } - - /** - * @brief returns a User object by it's ownCloud username - * @param string $id the DN or username of the user - * @return \OCA\user_ldap\lib\user\User|\OCA\user_ldap\lib\user\OfflineUser|null - */ - protected function createInstancyByUserName($id) { - //most likely a uid. Check whether it is a deleted user - if($this->isDeletedUser($id)) { - return $this->getDeletedUser($id); - } - $dn = $this->access->username2dn($id); - if($dn !== false) { - return $this->createAndCache($dn, $id); - } - return null; - } - - /** - * @brief returns a User object by it's DN or ownCloud username - * @param string $id the DN or username of the user - * @return \OCA\user_ldap\lib\user\User|\OCA\user_ldap\lib\user\OfflineUser|null - * @throws \Exception when connection could not be established - */ - public function get($id) { - $this->checkAccess(); - if(isset($this->users['byDN'][$id])) { - return $this->users['byDN'][$id]; - } else if(isset($this->users['byUid'][$id])) { - return $this->users['byUid'][$id]; - } - - if($this->access->stringResemblesDN($id) ) { - $uid = $this->access->dn2username($id); - if($uid !== false) { - return $this->createAndCache($id, $uid); - } - } - - return $this->createInstancyByUserName($id); - } - -} diff --git a/apps/user_ldap/lib/user/user.php b/apps/user_ldap/lib/user/user.php deleted file mode 100644 index 9bf505c5c22..00000000000 --- a/apps/user_ldap/lib/user/user.php +++ /dev/null @@ -1,529 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib\user; - -use OCA\user_ldap\lib\user\IUserTools; -use OCA\user_ldap\lib\Connection; -use OCA\user_ldap\lib\FilesystemHelper; -use OCA\user_ldap\lib\LogWrapper; -use OCP\IAvatarManager; -use OCP\IConfig; -use OCP\IUserManager; - -/** - * User - * - * represents an LDAP user, gets and holds user-specific information from LDAP - */ -class User { - /** - * @var IUserTools - */ - protected $access; - /** - * @var Connection - */ - protected $connection; - /** - * @var IConfig - */ - protected $config; - /** - * @var FilesystemHelper - */ - protected $fs; - /** - * @var \OCP\Image - */ - protected $image; - /** - * @var LogWrapper - */ - protected $log; - /** - * @var IAvatarManager - */ - protected $avatarManager; - /** - * @var IUserManager - */ - protected $userManager; - /** - * @var string - */ - protected $dn; - /** - * @var string - */ - protected $uid; - /** - * @var string[] - */ - protected $refreshedFeatures = array(); - /** - * @var string - */ - protected $avatarImage; - - /** - * DB config keys for user preferences - */ - const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished'; - const USER_PREFKEY_LASTREFRESH = 'lastFeatureRefresh'; - - /** - * @brief constructor, make sure the subclasses call this one! - * @param string $username the internal username - * @param string $dn the LDAP DN - * @param IUserTools $access an instance that implements IUserTools for - * LDAP interaction - * @param IConfig $config - * @param FilesystemHelper $fs - * @param \OCP\Image $image any empty instance - * @param LogWrapper $log - * @param IAvatarManager $avatarManager - * @param IUserManager $userManager - */ - public function __construct($username, $dn, IUserTools $access, - IConfig $config, FilesystemHelper $fs, \OCP\Image $image, - LogWrapper $log, IAvatarManager $avatarManager, IUserManager $userManager) { - - $this->access = $access; - $this->connection = $access->getConnection(); - $this->config = $config; - $this->fs = $fs; - $this->dn = $dn; - $this->uid = $username; - $this->image = $image; - $this->log = $log; - $this->avatarManager = $avatarManager; - $this->userManager = $userManager; - } - - /** - * @brief updates properties like email, quota or avatar provided by LDAP - * @return null - */ - public function update() { - if(is_null($this->dn)) { - return null; - } - - $hasLoggedIn = $this->config->getUserValue($this->uid, 'user_ldap', - self::USER_PREFKEY_FIRSTLOGIN, 0); - - if($this->needsRefresh()) { - $this->updateEmail(); - $this->updateQuota(); - if($hasLoggedIn !== 0) { - //we do not need to try it, when the user has not been logged in - //before, because the file system will not be ready. - $this->updateAvatar(); - //in order to get an avatar as soon as possible, mark the user - //as refreshed only when updating the avatar did happen - $this->markRefreshTime(); - } - } - } - - /** - * processes results from LDAP for attributes as returned by getAttributesToRead() - * @param array $ldapEntry the user entry as retrieved from LDAP - */ - public function processAttributes($ldapEntry) { - $this->markRefreshTime(); - //Quota - $attr = strtolower($this->connection->ldapQuotaAttribute); - if(isset($ldapEntry[$attr])) { - $this->updateQuota($ldapEntry[$attr][0]); - } - unset($attr); - - //Email - $attr = strtolower($this->connection->ldapEmailAttribute); - if(isset($ldapEntry[$attr])) { - $this->updateEmail($ldapEntry[$attr][0]); - } - unset($attr); - - //displayName - $displayName = $displayName2 = ''; - $attr = strtolower($this->connection->ldapUserDisplayName); - if(isset($ldapEntry[$attr])) { - $displayName = $ldapEntry[$attr][0]; - } - $attr = strtolower($this->connection->ldapUserDisplayName2); - if(isset($ldapEntry[$attr])) { - $displayName2 = $ldapEntry[$attr][0]; - } - if(!empty($displayName)) { - $this->composeAndStoreDisplayName($displayName); - $this->access->cacheUserDisplayName( - $this->getUsername(), - $displayName, - $displayName2 - ); - } - unset($attr); - - // LDAP Username, needed for s2s sharing - if(isset($ldapEntry['uid'])) { - $this->storeLDAPUserName($ldapEntry['uid'][0]); - } else if(isset($ldapEntry['samaccountname'])) { - $this->storeLDAPUserName($ldapEntry['samaccountname'][0]); - } - - //homePath - if(strpos($this->connection->homeFolderNamingRule, 'attr:') === 0) { - $attr = strtolower(substr($this->connection->homeFolderNamingRule, strlen('attr:'))); - if(isset($ldapEntry[$attr])) { - $this->access->cacheUserHome( - $this->getUsername(), $this->getHomePath($ldapEntry[$attr][0])); - } - } - - //memberOf groups - $cacheKey = 'getMemberOf'.$this->getUsername(); - $groups = false; - if(isset($ldapEntry['memberof'])) { - $groups = $ldapEntry['memberof']; - } - $this->connection->writeToCache($cacheKey, $groups); - - //Avatar - $attrs = array('jpegphoto', 'thumbnailphoto'); - foreach ($attrs as $attr) { - if(isset($ldapEntry[$attr])) { - $this->avatarImage = $ldapEntry[$attr][0]; - // the call to the method that saves the avatar in the file - // system must be postponed after the login. It is to ensure - // external mounts are mounted properly (e.g. with login - // credentials from the session). - \OCP\Util::connectHook('OC_User', 'post_login', $this, 'updateAvatarPostLogin'); - break; - } - } - } - - /** - * @brief returns the LDAP DN of the user - * @return string - */ - public function getDN() { - return $this->dn; - } - - /** - * @brief returns the ownCloud internal username of the user - * @return string - */ - public function getUsername() { - return $this->uid; - } - - /** - * returns the home directory of the user if specified by LDAP settings - * @param string $valueFromLDAP - * @return bool|string - * @throws \Exception - */ - public function getHomePath($valueFromLDAP = null) { - $path = $valueFromLDAP; - $attr = null; - - if( is_null($path) - && strpos($this->access->connection->homeFolderNamingRule, 'attr:') === 0 - && $this->access->connection->homeFolderNamingRule !== 'attr:') - { - $attr = substr($this->access->connection->homeFolderNamingRule, strlen('attr:')); - $homedir = $this->access->readAttribute( - $this->access->username2dn($this->getUsername()), $attr); - if ($homedir && isset($homedir[0])) { - $path = $homedir[0]; - } - } - - if(!empty($path)) { - //if attribute's value is an absolute path take this, otherwise append it to data dir - //check for / at the beginning or pattern c:\ resp. c:/ - if( '/' !== $path[0] - && !(3 < strlen($path) && ctype_alpha($path[0]) - && $path[1] === ':' && ('\\' === $path[2] || '/' === $path[2])) - ) { - $path = $this->config->getSystemValue('datadirectory', - \OC::$SERVERROOT.'/data' ) . '/' . $path; - } - //we need it to store it in the DB as well in case a user gets - //deleted so we can clean up afterwards - $this->config->setUserValue( - $this->getUsername(), 'user_ldap', 'homePath', $path - ); - return $path; - } - - if( !is_null($attr) - && $this->config->getAppValue('user_ldap', 'enforce_home_folder_naming_rule', true) - ) { - // a naming rule attribute is defined, but it doesn't exist for that LDAP user - throw new \Exception('Home dir attribute can\'t be read from LDAP for uid: ' . $this->getUsername()); - } - - //false will apply default behaviour as defined and done by OC_User - $this->config->setUserValue($this->getUsername(), 'user_ldap', 'homePath', ''); - return false; - } - - public function getMemberOfGroups() { - $cacheKey = 'getMemberOf'.$this->getUsername(); - if($this->connection->isCached($cacheKey)) { - return $this->connection->getFromCache($cacheKey); - } - $groupDNs = $this->access->readAttribute($this->getDN(), 'memberOf'); - $this->connection->writeToCache($cacheKey, $groupDNs); - return $groupDNs; - } - - /** - * @brief reads the image from LDAP that shall be used as Avatar - * @return string data (provided by LDAP) | false - */ - public function getAvatarImage() { - if(!is_null($this->avatarImage)) { - return $this->avatarImage; - } - - $this->avatarImage = false; - $attributes = array('jpegPhoto', 'thumbnailPhoto'); - foreach($attributes as $attribute) { - $result = $this->access->readAttribute($this->dn, $attribute); - if($result !== false && is_array($result) && isset($result[0])) { - $this->avatarImage = $result[0]; - break; - } - } - - return $this->avatarImage; - } - - /** - * @brief marks the user as having logged in at least once - * @return null - */ - public function markLogin() { - $this->config->setUserValue( - $this->uid, 'user_ldap', self::USER_PREFKEY_FIRSTLOGIN, 1); - } - - /** - * @brief marks the time when user features like email have been updated - * @return null - */ - public function markRefreshTime() { - $this->config->setUserValue( - $this->uid, 'user_ldap', self::USER_PREFKEY_LASTREFRESH, time()); - } - - /** - * @brief checks whether user features needs to be updated again by - * comparing the difference of time of the last refresh to now with the - * desired interval - * @return bool - */ - private function needsRefresh() { - $lastChecked = $this->config->getUserValue($this->uid, 'user_ldap', - self::USER_PREFKEY_LASTREFRESH, 0); - - //TODO make interval configurable - if((time() - intval($lastChecked)) < 86400 ) { - return false; - } - return true; - } - - /** - * Stores a key-value pair in relation to this user - * - * @param string $key - * @param string $value - */ - private function store($key, $value) { - $this->config->setUserValue($this->uid, 'user_ldap', $key, $value); - } - - /** - * Composes the display name and stores it in the database. The final - * display name is returned. - * - * @param string $displayName - * @param string $displayName2 - * @returns string the effective display name - */ - public function composeAndStoreDisplayName($displayName, $displayName2 = '') { - if(!empty($displayName2)) { - $displayName .= ' (' . $displayName2 . ')'; - } - $this->store('displayName', $displayName); - return $displayName; - } - - /** - * Stores the LDAP Username in the Database - * @param string $userName - */ - public function storeLDAPUserName($userName) { - $this->store('uid', $userName); - } - - /** - * @brief checks whether an update method specified by feature was run - * already. If not, it will marked like this, because it is expected that - * the method will be run, when false is returned. - * @param string $feature email | quota | avatar (can be extended) - * @return bool - */ - private function wasRefreshed($feature) { - if(isset($this->refreshedFeatures[$feature])) { - return true; - } - $this->refreshedFeatures[$feature] = 1; - return false; - } - - /** - * fetches the email from LDAP and stores it as ownCloud user value - * @param string $valueFromLDAP if known, to save an LDAP read request - * @return null - */ - public function updateEmail($valueFromLDAP = null) { - if($this->wasRefreshed('email')) { - return; - } - $email = $valueFromLDAP; - if(is_null($valueFromLDAP)) { - $emailAttribute = $this->connection->ldapEmailAttribute; - if(!empty($emailAttribute)) { - $aEmail = $this->access->readAttribute($this->dn, $emailAttribute); - if(is_array($aEmail) && (count($aEmail) > 0)) { - $email = $aEmail[0]; - } - } - } - if(!is_null($email)) { - $user = $this->userManager->get($this->uid); - $user->setEMailAddress($email); - } - } - - /** - * fetches the quota from LDAP and stores it as ownCloud user value - * @param string $valueFromLDAP the quota attribute's value can be passed, - * to save the readAttribute request - * @return null - */ - public function updateQuota($valueFromLDAP = null) { - if($this->wasRefreshed('quota')) { - return; - } - //can be null - $quotaDefault = $this->connection->ldapQuotaDefault; - $quota = $quotaDefault !== '' ? $quotaDefault : null; - $quota = !is_null($valueFromLDAP) ? $valueFromLDAP : $quota; - - if(is_null($valueFromLDAP)) { - $quotaAttribute = $this->connection->ldapQuotaAttribute; - if(!empty($quotaAttribute)) { - $aQuota = $this->access->readAttribute($this->dn, $quotaAttribute); - if($aQuota && (count($aQuota) > 0)) { - $quota = $aQuota[0]; - } - } - } - if(!is_null($quota)) { - $user = $this->userManager->get($this->uid)->setQuota($quota); - } - } - - /** - * called by a post_login hook to save the avatar picture - * - * @param array $params - */ - public function updateAvatarPostLogin($params) { - if(isset($params['uid']) && $params['uid'] === $this->getUsername()) { - $this->updateAvatar(); - } - } - - /** - * @brief attempts to get an image from LDAP and sets it as ownCloud avatar - * @return null - */ - public function updateAvatar() { - if($this->wasRefreshed('avatar')) { - return; - } - $avatarImage = $this->getAvatarImage(); - if($avatarImage === false) { - //not set, nothing left to do; - return; - } - $this->image->loadFromBase64(base64_encode($avatarImage)); - $this->setOwnCloudAvatar(); - } - - /** - * @brief sets an image as ownCloud avatar - * @return null - */ - private function setOwnCloudAvatar() { - if(!$this->image->valid()) { - $this->log->log('user_ldap', 'jpegPhoto data invalid for '.$this->dn, - \OCP\Util::ERROR); - return; - } - //make sure it is a square and not bigger than 128x128 - $size = min(array($this->image->width(), $this->image->height(), 128)); - if(!$this->image->centerCrop($size)) { - $this->log->log('user_ldap', - 'croping image for avatar failed for '.$this->dn, - \OCP\Util::ERROR); - return; - } - - if(!$this->fs->isLoaded()) { - $this->fs->setup($this->uid); - } - - try { - $avatar = $this->avatarManager->getAvatar($this->uid); - $avatar->set($this->image); - } catch (\Exception $e) { - \OC::$server->getLogger()->notice( - 'Could not set avatar for ' . $this->dn . ', because: ' . $e->getMessage(), - ['app' => 'user_ldap']); - } - } - -} diff --git a/apps/user_ldap/lib/wizardresult.php b/apps/user_ldap/lib/wizardresult.php deleted file mode 100644 index 54f01cf59b8..00000000000 --- a/apps/user_ldap/lib/wizardresult.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\user_ldap\lib; - -class WizardResult { - protected $changes = array(); - protected $options = array(); - protected $markedChange = false; - - /** - * @param string $key - * @param mixed $value - */ - public function addChange($key, $value) { - $this->changes[$key] = $value; - } - - /** - * - */ - public function markChange() { - $this->markedChange = true; - } - - /** - * @param string $key - * @param array|string $values - */ - public function addOptions($key, $values) { - if(!is_array($values)) { - $values = array($values); - } - $this->options[$key] = $values; - } - - /** - * @return bool - */ - public function hasChanges() { - return (count($this->changes) > 0 || $this->markedChange); - } - - /** - * @return array - */ - public function getResultArray() { - $result = array(); - $result['changes'] = $this->changes; - if(count($this->options) > 0) { - $result['options'] = $this->options; - } - return $result; - } -} |