diff options
Diffstat (limited to 'apps/user_ldap/lib')
92 files changed, 9185 insertions, 6063 deletions
diff --git a/apps/user_ldap/lib/Access.php b/apps/user_ldap/lib/Access.php index 43feeb4c1f0..9fe0aa64268 100644 --- a/apps/user_ldap/lib/Access.php +++ b/apps/user_ldap/lib/Access.php @@ -1,131 +1,82 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Aaron Wood <aaronjwood@gmail.com> - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Andreas Fischer <bantu@owncloud.com> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Benjamin Diele <benjamin@diele.be> - * @author bline <scottbeck@gmail.com> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Juan Pablo Villafáñez <jvillafanez@solidgear.es> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lorenzo M. Catucci <lorenzo@sancho.ccd.uniroma2.it> - * @author Lukas Reschke <lukas@statuscode.ch> - * @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 Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @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; -use OC\HintException; +use DomainException; +use OC\Hooks\PublicEmitter; +use OC\ServerNotAvailableException; use OCA\User_LDAP\Exceptions\ConstraintViolationException; -use OCA\User_LDAP\User\IUserTools; +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 OCA\User_LDAP\Mapping\AbstractMapping; - -use OC\ServerNotAvailableException; +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 implements IUserTools { - const UUID_ATTRIBUTES = ['entryuuid', 'nsuniqueid', 'objectguid', 'guid', 'ipauniqueid']; - - /** @var \OCA\User_LDAP\Connection */ - public $connection; - /** @var Manager */ - 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(); +class Access extends LDAPUtility { + public const UUID_ATTRIBUTES = ['entryuuid', 'nsuniqueid', 'objectguid', 'guid', 'ipauniqueid']; /** - * @var string $lastCookie the last cookie returned from a Paged Results - * operation, defaults to an empty string + * never ever check this var directly, always use getPagedSearchResultState + * @var ?bool */ - protected $lastCookie = ''; + protected $pagedSearchedSuccessful; - /** - * @var AbstractMapping $userMapper - */ + /** @var ?AbstractMapping */ protected $userMapper; - /** - * @var AbstractMapping $userMapper - */ + /** @var ?AbstractMapping */ protected $groupMapper; - /** - * @var \OCA\User_LDAP\Helper - */ - private $helper; - /** @var IConfig */ - private $config; + private string $lastCookie = ''; public function __construct( - Connection $connection, ILDAPWrapper $ldap, - Manager $userManager, - Helper $helper, - IConfig $config + 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->connection = $connection; - $this->userManager = $userManager; $this->userManager->setLdapAccess($this); - $this->helper = $helper; - $this->config = $config; } /** * sets the User Mapper - * @param AbstractMapping $mapper */ - public function setUserMapper(AbstractMapping $mapper) { + public function setUserMapper(AbstractMapping $mapper): void { $this->userMapper = $mapper; } /** - * returns the User Mapper * @throws \Exception - * @return AbstractMapping */ - public function getUserMapper() { - if(is_null($this->userMapper)) { + public function getUserMapper(): AbstractMapping { + if (is_null($this->userMapper)) { throw new \Exception('UserMapper was not assigned to this Access instance.'); } return $this->userMapper; @@ -133,19 +84,18 @@ class Access extends LDAPUtility implements IUserTools { /** * sets the Group Mapper - * @param AbstractMapping $mapper */ - public function setGroupMapper(AbstractMapping $mapper) { + public function setGroupMapper(AbstractMapping $mapper): void { $this->groupMapper = $mapper; } /** * returns the Group Mapper + * * @throws \Exception - * @return AbstractMapping */ - public function getGroupMapper() { - if(is_null($this->groupMapper)) { + public function getGroupMapper(): AbstractMapping { + if (is_null($this->groupMapper)) { throw new \Exception('GroupMapper was not assigned to this Access instance.'); } return $this->groupMapper; @@ -160,6 +110,7 @@ class Access extends LDAPUtility implements IUserTools { /** * returns the Connection instance + * * @return \OCA\User_LDAP\Connection */ public function getConnection() { @@ -167,36 +118,75 @@ class Access extends LDAPUtility implements IUserTools { } /** - * reads a given attribute for an LDAP record identified by a DN + * 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 string $attr the attribute that shall be retrieved - * if empty, just check the record's existence + * @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 + * array if $attr is empty, false otherwise + * @throws ServerNotAvailableException */ - public function readAttribute($dn, $attr, $filter = 'objectClass=*') { - if(!$this->checkConnection()) { - \OCP\Util::writeLog('user_ldap', + 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.', - \OCP\Util::WARN); + ['app' => 'user_ldap'] + ); 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); + $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; } - //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; + $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. @@ -205,8 +195,8 @@ class Access extends LDAPUtility implements IUserTools { $values = []; $isRangeRequest = false; do { - $result = $this->executeRead($cr, $dn, $attrToRead, $filter, $maxResults); - if(is_bool($result)) { + $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; @@ -223,63 +213,58 @@ class Access extends LDAPUtility implements IUserTools { $result = $this->extractRangeData($result, $attr); if (!empty($result)) { $normalizedResult = $this->extractAttributeValuesFromResult( - [ $attr => $result['values'] ], + [$attr => $result['values']], $attr ); $values = array_merge($values, $normalizedResult); - if($result['rangeHigh'] === '*') { + if ($result['rangeHigh'] === '*') { // when server replies with * as high range value, there are // no more results left return $values; } else { - $low = $result['rangeHigh'] + 1; + $low = $result['rangeHigh'] + 1; $attrToRead = $result['attributeName'] . ';range=' . $low . '-*'; $isRangeRequest = true; } } - } while($isRangeRequest); + } while ($isRangeRequest); - \OCP\Util::writeLog('user_ldap', 'Requested attribute '.$attr.' not found for '.$dn, \OCP\Util::DEBUG); + $this->logger->debug('Requested attribute ' . $attr . ' not found for ' . $dn, ['app' => 'user_ldap']); return false; } /** * Runs an read operation against LDAP * - * @param resource $cr the LDAP connection - * @param string $dn - * @param string $attribute - * @param string $filter - * @param int $maxResults * @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($cr, $dn, $attribute, $filter, $maxResults) { - $this->initPagedSearch($filter, array($dn), array($attribute), $maxResults, 0); + public function executeRead(string $dn, string|array $attribute, string $filter) { $dn = $this->helper->DNasBaseParameter($dn); - $rr = @$this->invokeLDAPMethod('read', $cr, $dn, $filter, array($attribute)); + $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 - \OCP\Util::writeLog('user_ldap', 'readAttribute failed for DN ' . $dn, \OCP\Util::DEBUG); + $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', $cr, $rr) === 1)) { - \OCP\Util::writeLog('user_ldap', 'readAttribute: ' . $dn . ' found', \OCP\Util::DEBUG); + if ($attribute === '' && ($filter === 'objectclass=*' || $this->invokeLDAPMethod('countEntries', $rr) === 1)) { + $this->logger->debug('readAttribute: ' . $dn . ' found', ['app' => 'user_ldap']); return true; } - $er = $this->invokeLDAPMethod('firstEntry', $cr, $rr); + $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 = \OCP\Util::mb_array_change_key_case( - $this->invokeLDAPMethod('getAttributes', $cr, $er), MB_CASE_LOWER, 'UTF-8'); + $result = Util::mb_array_change_key_case( + $this->invokeLDAPMethod('getAttributes', $er), MB_CASE_LOWER, 'UTF-8'); return $result; } @@ -288,18 +273,20 @@ class Access extends LDAPUtility implements IUserTools { * 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) { + if (isset($result[$attribute]) && $result[$attribute]['count'] > 0) { $lowercaseAttribute = strtolower($attribute); - for($i=0;$i<$result[$attribute]['count'];$i++) { - if($this->resemblesDN($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') { + } elseif ($lowercaseAttribute === 'objectguid' || $lowercaseAttribute === 'guid') { $values[] = $this->convertObjectGUID2Str($result[$attribute][$i]); } else { $values[] = $result[$attribute][$i]; @@ -319,26 +306,25 @@ class Access extends LDAPUtility implements IUserTools { * @return array If a range was detected with keys 'values', 'attributeName', * 'attributeFull' and 'rangeHigh', otherwise empty. */ - public function extractRangeData($result, $attribute) { + public function extractRangeData(array $result, string $attribute): array { $keys = array_keys($result); - foreach($keys as $key) { - if($key !== $attribute && strpos($key, $attribute) === 0) { - $queryData = explode(';', $key); - if(strpos($queryData[1], 'range=') === 0) { + 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], '-')); - $data = [ + return [ 'values' => $result[$key], 'attributeName' => $queryData[0], 'attributeFull' => $key, 'rangeHigh' => $high, ]; - return $data; } } } return []; } - + /** * Set password for an LDAP user identified by a DN * @@ -349,40 +335,39 @@ class Access extends LDAPUtility implements IUserTools { * @throws \Exception */ public function setPassword($userDN, $password) { - if(intval($this->connection->turnOnPasswordChange) !== 1) { + if ((int)$this->connection->turnOnPasswordChange !== 1) { throw new \Exception('LDAP password changes are disabled.'); } $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; - } try { - return @$this->invokeLDAPMethod('modReplace', $cr, $userDN, $password); - } catch(ConstraintViolationException $e) { - throw new HintException('Password change rejected.', \OC::$server->getL10N('user_ldap')->t('Password change rejected. Hint: ').$e->getMessage(), $e->getCode()); + // 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 = array( + $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 */ @@ -397,49 +382,51 @@ class Access extends LDAPUtility implements IUserTools { * 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) { + if ($allParts === false) { //not a valid DN return ''; } - $domainParts = array(); + $domainParts = []; $dcFound = false; - foreach($allParts as $part) { - if(!$dcFound && strpos($part, 'dc=') === 0) { + foreach ($allParts as $part) { + if (!$dcFound && str_starts_with($part, 'dc=')) { $dcFound = true; } - if($dcFound) { + if ($dcFound) { $domainParts[] = $part; } } - $domainDN = implode(',', $domainParts); - return $domainDN; + 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->groupMapper->getDNByName($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->userMapper->getDNByName($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)) { + if (is_string($fdn) && $this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { return $fdn; } @@ -448,75 +435,41 @@ class Access extends LDAPUtility implements IUserTools { /** * 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) { + 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)) { + 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; - $groupMatchFilter = $this->connection->getFromCache($cacheKey); - if(!is_null($groupMatchFilter)) { - if($groupMatchFilter) { - $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; + 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 $dn the dn of the user object + * + * @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, $ldapName = null) { + 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)) { + if (!$this->isDNPartOfBase($fdn, $this->connection->ldapBaseUsers)) { return false; } - return $this->dn2ocname($fdn, $ldapName, true); + return $this->dn2ocname($fdn, null, true); } /** @@ -527,59 +480,112 @@ class Access extends LDAPUtility implements IUserTools { * @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) { + 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) { + if ($isUser) { $mapper = $this->getUserMapper(); - $nameAttribute = $this->connection->ldapUserDisplayName; } else { $mapper = $this->getGroupMapper(); - $nameAttribute = $this->connection->ldapGroupDisplayName; } //let's try to retrieve the Nextcloud name from the mappings table $ncName = $mapper->getNameByDN($fdn); - if(is_string($ncName)) { + 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)) { + if (is_string($uuid)) { $ncName = $mapper->getNameByUUID($uuid); - if(is_string($ncName)) { + if (is_string($ncName)) { $mapper->setDNbyUUID($fdn, $uuid); return $ncName; } } 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); + $this->logger->debug('Cannot determine UUID for ' . $fdn . '. Skipping.', ['app' => 'user_ldap']); 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 = strval($this->connection->ldapExpertUsernameAttr); + if ($isUser) { if ($usernameAttribute !== '') { - $username = $this->readAttribute($fdn, $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; } - $intName = $this->sanitizeUsername($username); + 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 { - $intName = $ldapName; + 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 @@ -587,34 +593,72 @@ class Access extends LDAPUtility implements IUserTools { //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 && $intName !== '' && !\OC::$server->getUserManager()->userExists($intName)) - || (!$isUser && !\OC::$server->getGroupManager()->groupExists($intName))) { - if($mapper->map($fdn, $intName, $uuid)) { - $this->connection->setConfiguration(array('ldapCacheTTL' => $originalTTL)); - $newlyMapped = true; + $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(array('ldapCacheTTL' => $originalTTL)); + $this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]); $altName = $this->createAltInternalOwnCloudName($intName, $isUser); - if(is_string($altName) && $mapper->map($fdn, $altName, $uuid)) { - $newlyMapped = true; - return $altName; + 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.. - \OCP\Util::writeLog('user_ldap', 'Could not create unique name for '.$fdn.'.', \OCP\Util::INFO); + $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 an array with the user names to use in Nextcloud + * @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); @@ -622,51 +666,49 @@ class Access extends LDAPUtility implements IUserTools { /** * 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 Nextcloud + * @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() - * @param bool $isUsers - * @return array + * @param array[] $ldapObjects as returned by fetchList() + * @return array<int,string> + * @throws \Exception */ - private function ldap2NextcloudNames($ldapObjects, $isUsers) { - if($isUsers) { + private function ldap2NextcloudNames(array $ldapObjects, bool $isUsers): array { + if ($isUsers) { $nameAttribute = $this->connection->ldapUserDisplayName; - $sndAttribute = $this->connection->ldapUserDisplayName2; + $sndAttribute = $this->connection->ldapUserDisplayName2; } else { $nameAttribute = $this->connection->ldapGroupDisplayName; + $sndAttribute = null; } - $nextcloudNames = array(); + $nextcloudNames = []; - 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]; - } + foreach ($ldapObjects as $ldapObject) { + $nameByLDAP = $ldapObject[$nameAttribute][0] ?? null; $ncName = $this->dn2ocname($ldapObject['dn'][0], $nameByLDAP, $isUsers); - if($ncName) { + if ($ncName) { $nextcloudNames[] = $ncName; - if($isUsers) { + 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)) { + if (is_null($nameByLDAP)) { continue; } - $sndName = isset($ldapObject[$sndAttribute][0]) - ? $ldapObject[$sndAttribute][0] : ''; + $sndName = $ldapObject[$sndAttribute][0] ?? ''; $this->cacheUserDisplayName($ncName, $nameByLDAP, $sndName); + } elseif ($nameByLDAP !== null) { + $this->cacheGroupDisplayName($ncName, $nameByLDAP); } } } @@ -674,54 +716,83 @@ class Access extends LDAPUtility implements IUserTools { } /** + * 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($ocName, $home) { - $cacheKey = 'getHome'.$ocName; + public function cacheUserHome(string $ocName, $home): void { + $cacheKey = 'getHome' . $ocName; $this->connection->writeToCache($cacheKey, $home); } /** * caches a user as existing - * @param string $ocName the internal Nextcloud username */ - public function cacheUserExists($ocName) { - $this->connection->writeToCache('userExists'.$ocName, true); + 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($ocName, $displayName, $displayName2 = '') { + public function cacheUserDisplayName(string $ocName, string $displayName, string $displayName2 = ''): void { $user = $this->userManager->get($ocName); - if($user === null) { + if ($user === null) { return; } $displayName = $user->composeAndStoreDisplayName($displayName, $displayName2); $cacheKeyTrunk = 'getDisplayName'; - $this->connection->writeToCache($cacheKeyTrunk.$ocName, $displayName); + $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($name) { + 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(!\OC::$server->getUserManager()->userExists($altName)) { + while ($attempts < 20) { + $altName = $name . '_' . rand(1000, 9999); + if (!$this->ncUserManager->userExists($altName)) { return $altName; } $attempts++; @@ -731,6 +802,7 @@ class Access extends LDAPUtility implements IUserTools { /** * 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. * @@ -741,24 +813,24 @@ class Access extends LDAPUtility implements IUserTools { * 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) { + 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 = intval(substr($lastName, strrpos($lastName, '_') + 1)); + $lastNo = (int)substr($lastName, strrpos($lastName, '_') + 1); } - $altName = $name.'_'.strval($lastNo+1); + $altName = $name . '_' . (string)($lastNo + 1); unset($usedNames); $attempts = 1; - while($attempts < 21){ + 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::$server->getGroupManager()->groupExists($altName)) { + if (!Server::get(IGroupManager::class)->groupExists($altName)) { return $altName; } $altName = $name . '_' . ($lastNo + $attempts); @@ -769,19 +841,25 @@ class Access extends LDAPUtility implements IUserTools { /** * creates a unique name for internal Nextcloud 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) + * @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($name, $isUser) { + 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(array('ldapCacheTTL' => 0)); - if($isUser) { + $this->connection->setConfiguration(['ldapCacheTTL' => 0]); + if ($isUser) { $altName = $this->_createAltInternalOwnCloudNameForUsers($name); } else { $altName = $this->_createAltInternalOwnCloudNameForGroups($name); } - $this->connection->setConfiguration(array('ldapCacheTTL' => $originalTTL)); + $this->connection->setConfiguration(['ldapCacheTTL' => $originalTTL]); return $altName; } @@ -789,16 +867,11 @@ class Access extends LDAPUtility implements IUserTools { /** * 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')) { + public function fetchUsersByLoginName(string $loginName, array $attributes = ['dn']): array { $loginName = $this->escapeFilterPart($loginName); $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); - $users = $this->fetchListOfUsers($filter, $attributes); - return $users; + return $this->fetchListOfUsers($filter, $attributes); } /** @@ -806,197 +879,198 @@ class Access extends LDAPUtility implements IUserTools { * utilizing the login filter. * * @param string $loginName - * @return int + * @return false|int */ public function countUsersByLoginName($loginName) { $loginName = $this->escapeFilterPart($loginName); $filter = str_replace('%uid', $loginName, $this->connection->ldapLoginFilter); - $users = $this->countUsers($filter); - return $users; + return $this->countUsers($filter); } /** - * @param string $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset - * @param bool $forceApplyAttributes - * @return array + * @throws \Exception */ - public function fetchListOfUsers($filter, $attr, $limit = null, $offset = null, $forceApplyAttributes = false) { + 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->config - ->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'ajax'; - $recordsToUpdate = array_filter($ldapRecords, function($record) use ($isBackgroundJobModeAjax) { + 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 = $this->dn2ocname($record['dn'][0], null, true, $newlyMapped, $record); - if(is_string($uid)) { + $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, (count($attr) > 1)); + 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 - * @param array $ldapRecords + * + * @throws \Exception */ - public function batchApplyUserAttributes(array $ldapRecords){ - $displayNameAttribute = strtolower($this->connection->ldapUserDisplayName); - foreach($ldapRecords as $userRecord) { - if(!isset($userRecord[$displayNameAttribute])) { + 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) { + $ocName = $this->dn2ocname($userRecord['dn'][0], null, true); + if ($ocName === false) { continue; } + $this->updateUserState($ocName); $user = $this->userManager->get($ocName); - if($user instanceof OfflineUser) { - $user->unmark(); - $user = $this->userManager->get($ocName); - } if ($user !== null) { $user->processAttributes($userRecord); } else { - \OC::$server->getLogger()->debug( + $this->logger->debug( "The ldap user manager returned null for $ocName", - ['app'=>'user_ldap'] + ['app' => 'user_ldap'] ); } } } /** - * @param string $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset - * @return array + * @return array[] */ - public function fetchListOfGroups($filter, $attr, $limit = null, $offset = null) { - return $this->fetchList($this->searchGroups($filter, $attr, $limit, $offset), (count($attr) > 1)); + 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; } - /** - * @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); - } + 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); } - - //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 + * @throws ServerNotAvailableException */ - public function searchUsers($filter, $attr = null, $limit = null, $offset = null) { - return $this->search($filter, $this->connection->ldapBaseUsers, $attr, $limit, $offset); + 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 $filter - * @param string|string[] $attr - * @param int $limit - * @param int $offset + * @param string[] $attr * @return false|int + * @throws ServerNotAvailableException */ - public function countUsers($filter, $attr = array('dn'), $limit = null, $offset = null) { - return $this->count($filter, $this->connection->ldapBaseUsers, $attr, $limit, $offset); + 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 $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 + * + * @param ?string[] $attr optional, when certain attributes shall be filtered out * * Executes an LDAP search + * @throws ServerNotAvailableException */ - public function searchGroups($filter, $attr = null, $limit = null, $offset = null) { - return $this->search($filter, $this->connection->ldapBaseGroups, $attr, $limit, $offset); + 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 - * @param string $filter the LDAP search filter - * @param string[] $attr optional - * @param int|null $limit - * @param int|null $offset + * * @return int|bool + * @throws ServerNotAvailableException */ - public function countGroups($filter, $attr = array('dn'), $limit = null, $offset = null) { - return $this->count($filter, $this->connection->ldapBaseGroups, $attr, $limit, $offset); + 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 * - * @param int|null $limit - * @param int|null $offset * @return int|bool + * @throws ServerNotAvailableException */ - public function countObjects($limit = null, $offset = null) { - return $this->count('objectclass=*', $this->connection->ldapBase, array('dn'), $limit, $offset); + 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() { - $arguments = func_get_args(); - $command = array_shift($arguments); - $cr = array_shift($arguments); + 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, $cr); - // php no longer supports call-time pass-by-reference - // thus cannot support controlPagedResultResponse as the third argument - // is a reference + array_unshift($arguments, $this->connection->getConnectionResource()); $doMethod = function () use ($command, &$arguments) { - if ($command == 'controlPagedResultResponse') { - throw new \InvalidArgumentException('Invoker does not support controlPagedResultResponse, call LDAP Wrapper directly instead.'); - } else { - return call_user_func_array(array($this->ldap, $command), $arguments); - } + return call_user_func_array([$this->ldap, $command], $arguments); }; try { $ret = $doMethod(); @@ -1005,17 +1079,17 @@ class Access extends LDAPUtility implements IUserTools { * Maybe implement exponential backoff? * This was enough to get solr indexer working which has large delays between LDAP fetches. */ - \OCP\Util::writeLog('user_ldap', "Connection lost on $command, attempting to reestablish.", \OCP\Util::DEBUG); + $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)) { + if (!$this->ldap->isResource($cr)) { // Seems like we didn't find any resource. - \OCP\Util::writeLog('user_ldap', "Could not $command, because resource is missing.", \OCP\Util::DEBUG); + $this->logger->debug("Could not $command, because resource is missing.", ['app' => 'user_ldap']); throw $e; } - $arguments[0] = array_pad([], count($arguments[0]), $cr); + $arguments[0] = $cr; $ret = $doMethod(); } return $ret; @@ -1024,82 +1098,84 @@ class Access extends LDAPUtility implements IUserTools { /** * retrieved. Results will according to the order in the array. * - * @param $filter - * @param $base - * @param string[]|string|null $attr - * @param int $limit optional, maximum results to be counted - * @param int $offset optional, a starting point + * @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 + * second | false if not successful * @throws ServerNotAvailableException */ - 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')); - } - + 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(); - 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); + try { + [$pagedSearchOK, $pageSize, $cookie] = $this->initPagedSearch($filter, $base, $attr, (int)$pageSize, (int)$offset); + } catch (NoMoreResults $e) { + // beyond last results page + return false; + } - $linkResources = array_pad(array(), count($base), $cr); - $sr = $this->invokeLDAPMethod('search', $linkResources, $base, $filter, $attr); - // cannot use $cr anymore, might have changed in the previous call! + $sr = $this->invokeLDAPMethod('search', $base, $filter, $attr, 0, 0, $pageSize, $cookie); $error = $this->ldap->errno($this->connection->getConnectionResource()); - if(!is_array($sr) || $error !== 0) { - \OCP\Util::writeLog('user_ldap', 'Attempt for Paging? '.print_r($pagedSearchOK, true), \OCP\Util::ERROR); + if (!$this->ldap->isResource($sr) || $error !== 0) { + $this->logger->error('Attempt for Paging? ' . print_r($pagedSearchOK, true), ['app' => 'user_ldap']); return false; } - return array($sr, $pagedSearchOK); + return [$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 single 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 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 + * prior results need to be gained * @return bool cookie validity, true if we have more pages, false otherwise. + * @throws ServerNotAvailableException */ - private function processPagedSearchStatus($sr, $filter, $base, $iFoundItems, $limit, $offset, $pagedSearchOK, $skipHandling) { - $cookie = null; - if($pagedSearchOK) { + private function processPagedSearchStatus( + $sr, + int $foundItems, + int $limit, + bool $pagedSearchOK, + bool $skipHandling, + ): bool { + $cookie = ''; + 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); - } + if ($this->ldap->controlPagedResultResponse($cr, $sr, $cookie)) { + $this->lastCookie = $cookie; } //browsing through prior pages to get the cookie for the new one - if($skipHandling) { + if ($skipHandling) { return false; } // if count is bigger, then the server does not support - // paged search. Instead, he did a normal search. We set a + // paged search. Instead, they did a normal search. We set a // flag here, so the callee knows how to deal with it. - if($iFoundItems <= $limit) { + if ($foundItems <= $limit) { $this->pagedSearchedSuccessful = true; } } else { - if(!is_null($limit) && intval($this->connection->ldapPagingSize) !== 0) { - \OC::$server->getLogger()->debug( + if ((int)$this->connection->ldapPagingSize !== 0) { + $this->logger->debug( 'Paged search was not available', - [ 'app' => 'user_ldap' ] + ['app' => 'user_ldap'] ); } } @@ -1115,21 +1191,31 @@ class Access extends LDAPUtility implements IUserTools { * 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 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 + * completed * @return int|false Integer or false if the search could not be initialized * @throws ServerNotAvailableException */ - 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); + 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 = intval($this->connection->ldapPagingSize); - if(!is_null($limit) && $limit < $limitPerPage && $limit > 0) { + $limitPerPage = (int)$this->connection->ldapPagingSize; + if ($limit < $limitPerPage && $limit > 0) { $limitPerPage = $limit; } @@ -1137,62 +1223,59 @@ class Access extends LDAPUtility implements IUserTools { $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; + 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; + /* ++ 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)); + $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 array $searchResults + * @param \LDAP\Result|\LDAP\Result[] $sr * @return int + * @throws ServerNotAvailableException */ - private function countEntriesInSearchResults($searchResults) { - $counter = 0; - - foreach($searchResults as $res) { - $count = intval($this->invokeLDAPMethod('countEntries', $this->connection->getConnectionResource(), $res)); - $counter += $count; - } - - return $counter; + private function countEntriesInSearchResults($sr): int { + return (int)$this->invokeLDAPMethod('countEntries', $sr); } /** * 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 + * DN values in the result set are escaped as per RFC 2253 + * * @throws ServerNotAvailableException */ - public function search($filter, $base, $attr = null, $limit = null, $offset = null, $skipHandling = false) { - $limitPerPage = intval($this->connection->ldapPagingSize); - if(!is_null($limit) && $limit < $limitPerPage && $limit > 0) { + 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; } @@ -1203,63 +1286,53 @@ class Access extends LDAPUtility implements IUserTools { * $findings['count'] < $limit */ $findings = []; + $offset = $offset ?? 0; $savedoffset = $offset; + $iFoundItems = 0; + do { $search = $this->executeSearch($filter, $base, $attr, $limitPerPage, $offset); - if($search === false) { + if ($search === false) { return []; } - list($sr, $pagedSearchOK) = $search; - $cr = $this->connection->getConnectionResource(); + [$sr, $pagedSearchOK] = $search; - if($skipHandling) { + 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, $limitPerPage, - $offset, $pagedSearchOK, - $skipHandling); - return array(); + $this->processPagedSearchStatus($sr, 1, $limitPerPage, $pagedSearchOK, $skipHandling); + return []; } - $iFoundItems = 0; - foreach($sr as $res) { - $findings = array_merge($findings, $this->invokeLDAPMethod('getEntries', $cr, $res)); - $iFoundItems = max($iFoundItems, $findings['count']); - unset($findings['count']); - } + $findings = array_merge($findings, $this->invokeLDAPMethod('getEntries', $sr)); + $iFoundItems = max($iFoundItems, $findings['count']); + unset($findings['count']); - $continue = $this->processPagedSearchStatus($sr, $filter, $base, $iFoundItems, - $limitPerPage, $offset, $pagedSearchOK, - $skipHandling); + $continue = $this->processPagedSearchStatus($sr, $iFoundItems, $limitPerPage, $pagedSearchOK, $skipHandling); $offset += $limitPerPage; } while ($continue && $pagedSearchOK && ($limit === null || count($findings) < $limit)); - // reseting offset - $offset = $savedoffset; - // if we're here, probably no connection resource is returned. - // to make Nextcloud behave nicely, we simply give back an empty array. - if(is_null($findings)) { - return array(); - } + // resetting offset + $offset = $savedoffset; - if(!is_null($attr)) { + if (!is_null($attr)) { $selection = []; $i = 0; - foreach($findings as $item) { - if(!is_array($item)) { + 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) { - if(isset($item[$key])) { - if(is_array($item[$key]) && isset($item[$key]['count'])) { + $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)) { + if ($key !== 'dn') { + if ($this->resemblesDN($key)) { $selection[$i][$key] = $this->helper->sanitizeDN($item[$key]); - } else if($key === 'objectguid' || $key === 'guid') { + } elseif ($key === 'objectguid' || $key === 'guid') { $selection[$i][$key] = [$this->convertObjectGUID2Str($item[$key][0])]; } else { $selection[$i][$key] = $item[$key]; @@ -1268,7 +1341,6 @@ class Access extends LDAPUtility implements IUserTools { $selection[$i][$key] = [$this->helper->sanitizeDN($item[$key])]; } } - } $i++; } @@ -1277,30 +1349,39 @@ class Access extends LDAPUtility implements IUserTools { //we slice the findings, when //a) paged search unsuccessful, though attempted //b) no paged search, but limit set - if((!$this->getPagedSearchResultState() - && $pagedSearchOK) + if ((!$this->getPagedSearchResultState() + && $pagedSearchOK) || ( !$pagedSearchOK && !is_null($limit) ) ) { - $findings = array_slice($findings, intval($offset), $limit); + $findings = array_slice($findings, $offset, $limit); } return $findings; } /** * @param string $name - * @return bool|mixed|string + * @return string + * @throws \InvalidArgumentException */ public function sanitizeUsername($name) { - if($this->connection->ldapIgnoreNamingRules) { - return trim($name); + $name = trim($name); + + if ($this->connection->ldapIgnoreNamingRules) { + return $name; } - // Transliteration - // latin characters to ASCII - $name = iconv('UTF-8', 'ASCII//TRANSLIT', $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); @@ -1308,40 +1389,61 @@ class Access extends LDAPUtility implements IUserTools { // 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) { + * 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] === '*') { + 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); + 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) { + 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 + * Combines Filter arguments with OR */ public function combineFilterWithOr($filters) { return $this->combineFilter($filters, '|'); @@ -1349,28 +1451,30 @@ class Access extends LDAPUtility implements IUserTools { /** * 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) { + private function combineFilter(array $filters, string $operator): string { + $combinedFilter = '(' . $operator; + foreach ($filters as $filter) { if ($filter !== '' && $filter[0] !== '(') { - $filter = '('.$filter.')'; + $filter = '(' . $filter . ')'; } - $combinedFilter.=$filter; + $combinedFilter .= $filter; } - $combinedFilter.=')'; + $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) { + public function getFilterPartForUserSearch($search): string { return $this->getFilterPartForSearch($search, $this->connection->ldapAttributesForUserSearch, $this->connection->ldapUserDisplayName); @@ -1378,10 +1482,11 @@ class Access extends LDAPUtility implements IUserTools { /** * 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) { + public function getFilterPartForGroupSearch($search): string { return $this->getFilterPartForSearch($search, $this->connection->ldapAttributesForGroupSearch, $this->connection->ldapGroupDisplayName); @@ -1390,23 +1495,24 @@ class Access extends LDAPUtility implements IUserTools { /** * 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 :) + * @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 \Exception + * @throws DomainException */ - 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'); + 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 = array(); - foreach($searchWords as $word) { + $wordFilters = []; + foreach ($searchWords as $word) { $word = $this->prepareSearchTerm($word); //every word needs to appear at least once - $wordMatchOneAttrFilters = array(); - foreach($searchAttributes as $attr) { + $wordMatchOneAttrFilters = []; + foreach ($searchAttributes as $attr) { $wordMatchOneAttrFilters[] = $attr . '=' . $word; } $wordFilters[] = $this->combineFilterWithOr($wordMatchOneAttrFilters); @@ -1416,40 +1522,46 @@ class Access extends LDAPUtility implements IUserTools { /** * creates a filter part for searches + * * @param string $search the search term - * @param string[]|null $searchAttributes + * @param string[]|null|'' $searchAttributes * @param string $fallbackAttribute a fallback attribute in case the user - * did not define search attributes. Typically the display name attribute. + * 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(); + private function getFilterPartForSearch(string $search, $searchAttributes, string $fallbackAttribute): string { + $filter = []; $haveMultiSearchAttributes = (is_array($searchAttributes) && count($searchAttributes) > 0); - if($haveMultiSearchAttributes && strpos(trim($search), ' ') !== false) { + if ($haveMultiSearchAttributes && str_contains(trim($search), ' ')) { 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 - ); + } 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 (!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) { + 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].')'; + if (count($filter) === 1) { + return '(' . $filter[0] . ')'; } return $this->combineFilterWithOr($filter); } @@ -1458,17 +1570,16 @@ class Access extends LDAPUtility implements IUserTools { * 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(); + 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 = '*'; - } else if ($allowEnum !== 'no') { + } elseif ($allowEnum !== 'no') { $result = $term . '*'; } return $result; @@ -1476,30 +1587,27 @@ class Access extends LDAPUtility implements IUserTools { /** * returns the filter used for counting users - * @return string */ - public function getFilterForUserCount() { - $filter = $this->combineFilterWithAnd(array( + public function getFilterForUserCount(): string { + $filter = $this->combineFilterWithAnd([ $this->connection->ldapUserFilter, $this->connection->ldapUserDisplayName . '=*' - )); + ]); return $filter; } - /** - * @param string $name - * @param string $password - * @return bool - */ - public function areCredentialsValid($name, $password) { + public function areCredentialsValid(string $name, string $password): bool { + if ($name === '' || $password === '') { + return false; + } $name = $this->helper->DNasBaseParameter($name); $testConnection = clone $this->connection; - $credentials = array( + $credentials = [ 'ldapAgentName' => $name, - 'ldapAgentPassword' => $password - ); - if(!$testConnection->setConfiguration($credentials)) { + 'ldapAgentPassword' => $password, + ]; + if (!$testConnection->setConfiguration($credentials)) { return false; } return $testConnection->bind(); @@ -1514,37 +1622,42 @@ class Access extends LDAPUtility implements IUserTools { */ public function getUserDnByUuid($uuid) { $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; - $filter = $this->connection->ldapUserFilter; - $base = $this->connection->ldapBaseUsers; + $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. - $result = $this->search($filter, $base, ['dn'], 1); - if(!isset($result[0]) || !isset($result[0]['dn'])) { - throw new \Exception('Cannot determine UUID attribute'); + 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; + } } - $dn = $result[0]['dn'][0]; - if(!$this->detectUuidAttribute($dn, true)) { + 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)) { + if (!$this->detectUuidAttribute('', true)) { throw new \Exception('Cannot determine UUID attribute'); } } $uuidAttr = $this->connection->ldapUuidUserAttribute; - if($uuidAttr === 'guid' || $uuidAttr === 'objectguid') { + 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) { + 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]; @@ -1561,80 +1674,87 @@ class Access extends LDAPUtility implements IUserTools { * @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($dn, $isUser = true, $force = false, array $ldapRecord = null) { - if($isUser) { - $uuidAttr = 'ldapUuidUserAttribute'; + 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'; + $uuidAttr = 'ldapUuidGroupAttribute'; $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; } - if(($this->connection->$uuidAttr !== 'auto') && !$force) { - return true; - } + if (!$force) { + if ($this->connection->$uuidAttr !== 'auto') { + return true; + } elseif (is_string($uuidOverride) && trim($uuidOverride) !== '') { + $this->connection->$uuidAttr = $uuidOverride; + return true; + } - if (is_string($uuidOverride) && trim($uuidOverride) !== '' && !$force) { - $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) { + 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])) { + if (isset($ldapRecord[$attribute])) { $this->connection->$uuidAttr = $attribute; return true; - } else { - continue; } } $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); + 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; } } - \OCP\Util::writeLog('user_ldap', - 'Could not autodetect the UUID attribute', - \OCP\Util::ERROR); + $this->logger->debug('Could not autodetect the UUID attribute', ['app' => 'user_ldap']); return false; } /** - * @param string $dn - * @param bool $isUser - * @param null $ldapRecord - * @return bool|string + * @param array|null $ldapRecord + * @return false|string + * @throws ServerNotAvailableException */ - public function getUUID($dn, $isUser = true, $ldapRecord = null) { - if($isUser) { - $uuidAttr = 'ldapUuidUserAttribute'; + public function getUUID(string $dn, bool $isUser = true, ?array $ldapRecord = null) { + if ($isUser) { + $uuidAttr = 'ldapUuidUserAttribute'; $uuidOverride = $this->connection->ldapExpertUUIDUserAttr; } else { - $uuidAttr = 'ldapUuidGroupAttribute'; + $uuidAttr = 'ldapUuidGroupAttribute'; $uuidOverride = $this->connection->ldapExpertUUIDGroupAttr; } $uuid = false; - if($this->detectUuidAttribute($dn, $isUser, false, $ldapRecord)) { + if ($this->detectUuidAttribute($dn, $isUser, false, $ldapRecord)) { $attr = $this->connection->$uuidAttr; - $uuid = isset($ldapRecord[$attr]) ? $ldapRecord[$attr] : $this->readAttribute($dn, $attr); - if( !is_array($uuid) + $uuid = $ldapRecord[$attr] ?? $this->readAttribute($dn, $attr); + if (!is_array($uuid) && $uuidOverride !== '' - && $this->detectUuidAttribute($dn, $isUser, true, $ldapRecord)) - { + && $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) && isset($uuid[0]) && !empty($uuid[0])) { + if (is_array($uuid) && !empty($uuid[0])) { $uuid = $uuid[0]; } } @@ -1644,22 +1764,22 @@ class Access extends LDAPUtility implements IUserTools { /** * 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 + * + * @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($oguid) { + private function convertObjectGUID2Str(string $oguid): string { $hex_guid = bin2hex($oguid); $hex_guid_to_guid_str = ''; - for($k = 1; $k <= 4; ++$k) { + 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) { + 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) { + 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); @@ -1671,20 +1791,14 @@ class Access extends LDAPUtility implements IUserTools { /** * 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. + * 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. - * - * @param string $guid - * @return string */ - public function formatGuid2ForFilterUser($guid) { - if(!is_string($guid)) { - throw new \InvalidArgumentException('String expected'); - } + public function formatGuid2ForFilterUser(string $guid): string { $blocks = explode('-', $guid); - if(count($blocks) !== 5) { + 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 @@ -1696,19 +1810,19 @@ class Access extends LDAPUtility implements IUserTools { * 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 ] + $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++) { + 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++) { + for ($i = 0; $i < 5; $i++) { $pairs = str_split($blocks[$i], 2); $blocks[$i] = '\\' . implode('\\', $pairs); } @@ -1717,19 +1831,21 @@ class Access extends LDAPUtility implements IUserTools { /** * 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; + $cacheKey = 'getSID-' . $domainDN; $sid = $this->connection->getFromCache($cacheKey); - if(!is_null($sid)) { + if (!is_null($sid)) { return $sid; } $objectSid = $this->readAttribute($domainDN, 'objectsid'); - if(!is_array($objectSid) || empty($objectSid)) { + if (!is_array($objectSid) || empty($objectSid)) { $this->connection->writeToCache($cacheKey, false); return false; } @@ -1741,6 +1857,7 @@ class Access extends LDAPUtility implements IUserTools { /** * converts a binary SID into a string representation + * * @param string $sid * @return string */ @@ -1767,7 +1884,7 @@ class Access extends LDAPUtility implements IUserTools { // precision (see https://gist.github.com/bantu/886ac680b0aef5812f71) $iav = number_format(hexdec(bin2hex(substr($sid, 2, 6))), 0, '', ''); - $subIDs = array(); + $subIDs = []; for ($i = 0; $i < $numberSubID; $i++) { $subID = unpack('V', substr($sid, $subIdStart + $subIdLength * $i, $subIdLength)); $subIDs[] = sprintf('%u', $subID[1]); @@ -1779,20 +1896,19 @@ class Access extends LDAPUtility implements IUserTools { /** * 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) { + public function isDNPartOfBase(string $dn, array $bases): bool { $belongsToBase = false; $bases = $this->helper->sanitizeDN($bases); - foreach($bases as $base) { + foreach ($bases as $base) { $belongsToBase = true; - if(mb_strripos($dn, $base, 0, 'UTF-8') !== (mb_strlen($dn, 'UTF-8')-mb_strlen($base, 'UTF-8'))) { + if (mb_strripos($dn, $base, 0, 'UTF-8') !== (mb_strlen($dn, 'UTF-8') - mb_strlen($base, 'UTF-8'))) { $belongsToBase = false; } - if($belongsToBase) { + if ($belongsToBase) { break; } } @@ -1801,40 +1917,15 @@ class Access extends LDAPUtility implements IUserTools { /** * resets a running Paged Search operation + * + * @throws ServerNotAvailableException */ - private function abandonPagedSearch() { - if($this->connection->hasPagedResultSupport) { - $cr = $this->connection->getConnectionResource(); - $this->invokeLDAPMethod('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 = ''; - } + private function abandonPagedSearch(): void { + if ($this->lastCookie === '') { + return; } - return $cookie; + $this->getPagedSearchResultState(); + $this->lastCookie = ''; } /** @@ -1845,14 +1936,11 @@ class Access extends LDAPUtility implements IUserTools { * 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') { + if ($this->lastCookie === '') { // as in RFC 2696, when all results are returned, the cookie will // be empty. return false; @@ -1862,25 +1950,8 @@ class Access extends LDAPUtility implements IUserTools { } /** - * 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() { @@ -1891,75 +1962,92 @@ class Access extends LDAPUtility implements IUserTools { /** * 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 $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 bool|true - */ - private function initPagedSearch($filter, $bases, $attr, $limit, $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($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 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 - $reOffset = ($offset - $limit) < 0 ? 0 : $offset - $limit; - $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. - // '0' is valid, because 389ds - //TODO: remember this, probably does not change in the next request... - if(empty($cookie) && $cookie !== '0') { - $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->invokeLDAPMethod('controlPagedResult', - $this->connection->getConnectionResource(), $limit, - false, $cookie); - if(!$pagedSearchOK) { - return false; - } - \OCP\Util::writeLog('user_ldap', 'Ready for a paged search', \OCP\Util::DEBUG); + 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 { - $e = new \Exception('No paged search possible, Limit '.$limit.' Offset '.$offset); - \OC::$server->getLogger()->logException($e, ['level' => Util::DEBUG]); + /* 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(); } - } - /* ++ 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)) { + 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 = intval($this->connection->ldapPagingSize) > 0 ? intval($this->connection->ldapPagingSize) : 500; - $pagedSearchOK = $this->invokeLDAPMethod('controlPagedResult', - $this->connection->getConnectionResource(), - $pageSize, false, ''); + $pageSize = (int)$this->connection->ldapPagingSize > 0 ? (int)$this->connection->ldapPagingSize : 500; + return [true, $pageSize, $this->lastCookie]; } - return $pagedSearchOK; + 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 index 45ff779bb01..da114c467a7 100644 --- a/apps/user_ldap/lib/AccessFactory.php +++ b/apps/user_ldap/lib/AccessFactory.php @@ -1,61 +1,44 @@ <?php + /** - * @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 { - /** @var ILDAPWrapper */ - protected $ldap; - /** @var Manager */ - protected $userManager; - /** @var Helper */ - protected $helper; - /** @var IConfig */ - protected $config; public function __construct( - ILDAPWrapper $ldap, - Manager $userManager, - Helper $helper, - IConfig $config) - { - $this->ldap = $ldap; - $this->userManager = $userManager; - $this->helper = $helper; - $this->config = $config; + 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) { + public function get(Connection $connection): Access { + /* Each Access instance gets its own Manager instance, see OCA\User_LDAP\AppInfo\Application::register() */ return new Access( - $connection, $this->ldap, - $this->userManager, + $connection, + Server::get(Manager::class), $this->helper, - $this->config + $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 index 2188605b5e8..70b7920f7ab 100644 --- a/apps/user_ldap/lib/AppInfo/Application.php +++ b/apps/user_ldap/lib/AppInfo/Application.php @@ -1,53 +1,152 @@ <?php + /** - * @copyright Copyright (c) 2017 Roger Szabo <roger.szabo@web.de> - * - * @author Roger Szabo <roger.szabo@web.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 { - public function __construct () { +class Application extends App implements IBootstrap { + public function __construct() { parent::__construct('user_ldap'); $container = $this->getContainer(); /** * Controller */ - $container->registerService('RenewPasswordController', function(IAppContainer $c) { - /** @var \OC\Server $server */ - $server = $c->query('ServerContainer'); + $container->registerService('RenewPasswordController', function (IAppContainer $appContainer) { + /** @var IServerContainer $server */ + $server = $appContainer->get(IServerContainer::class); return new RenewPasswordController( - $c->getAppName(), + $appContainer->get('AppName'), $server->getRequest(), - $c->query('UserManager'), + $appContainer->get('UserManager'), $server->getConfig(), - $c->query('OCP\IL10N'), - $c->query('Session'), + $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 index dc5a8a36ce4..88d7311cde0 100644 --- a/apps/user_ldap/lib/BackendUtility.php +++ b/apps/user_ldap/lib/BackendUtility.php @@ -1,39 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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: 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 { - 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; + 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 index af2806e8cc6..8bb26ce3d0e 100644 --- a/apps/user_ldap/lib/Command/CheckUser.php +++ b/apps/user_ldap/lib/Command/CheckUser.php @@ -1,127 +1,106 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\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; -use OCA\User_LDAP\User\DeletedUsersIndex; -use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\Helper as LDAPHelper; -use OCA\User_LDAP\User_Proxy; - class CheckUser extends Command { - /** @var \OCA\User_LDAP\User_Proxy */ - protected $backend; - - /** @var \OCA\User_LDAP\Helper */ - protected $helper; - - /** @var \OCA\User_LDAP\User\DeletedUsersIndex */ - protected $dui; - - /** @var \OCA\User_LDAP\Mapping\UserMapping */ - protected $mapping; - - /** - * @param User_Proxy $uBackend - * @param LDAPHelper $helper - * @param DeletedUsersIndex $dui - * @param UserMapping $mapping - */ - public function __construct(User_Proxy $uBackend, LDAPHelper $helper, DeletedUsersIndex $dui, UserMapping $mapping) { - $this->backend = $uBackend; - $this->helper = $helper; - $this->dui = $dui; - $this->mapping = $mapping; + public function __construct( + protected User_Proxy $backend, + protected Helper $helper, + protected DeletedUsersIndex $dui, + protected UserMapping $mapping, + ) { parent::__construct(); } - protected function configure() { + 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' - ) + 'ocName', + InputArgument::REQUIRED, + 'the user name as used in Nextcloud, or the LDAP DN' + ) ->addOption( - 'force', - null, - InputOption::VALUE_NONE, - 'ignores disabled LDAP configuration' - ) + '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) { + protected function execute(InputInterface $input, OutputInterface $output): int { try { + $this->assertAllowed($input->getOption('force')); $uid = $input->getArgument('ocName'); - $this->isAllowed($input->getOption('force')); - $this->confirmUserIsMapped($uid); - $exists = $this->backend->userExistsOnLDAP($uid); - if($exists === true) { + 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.'); - return; + 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; } - $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 . '"'); + throw new \Exception('The given user is not a recognized LDAP user.'); } catch (\Exception $e) { - $output->writeln('<error>' . $e->getMessage(). '</error>'); + $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 - * @throws \Exception - * @return true */ - protected function confirmUserIsMapped($ocName) { + protected function userWasMapped(string $ocName): bool { $dn = $this->mapping->getDNByName($ocName); - if ($dn === false) { - throw new \Exception('The given user is not a recognized LDAP user.'); - } - - return true; + return $dn !== false; } /** * checks whether the setup allows reliable checking of LDAP user existence * @throws \Exception - * @return true */ - protected function isAllowed($force) { - if($this->helper->haveDisabledConfigurations() && !$force) { + protected function assertAllowed(bool $force): void { + if ($this->helper->haveDisabledConfigurations() && !$force) { throw new \Exception('Cannot check user existence, because ' . 'disabled LDAP configurations are present.'); } @@ -129,8 +108,28 @@ class CheckUser extends Command { // we don't check ldapUserCleanupInterval from config.php because this // action is triggered manually, while the setting only controls the // background job. - - return true; } + 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 index 38d3192058c..7c381cf431f 100644 --- a/apps/user_ldap/lib/Command/CreateEmptyConfig.php +++ b/apps/user_ldap/lib/Command/CreateEmptyConfig.php @@ -1,28 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Martin Konrad <konrad@frib.msu.edu> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\Command; use OCA\User_LDAP\Configuration; @@ -33,18 +15,13 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class CreateEmptyConfig extends Command { - /** @var \OCA\User_LDAP\Helper */ - protected $helper; - - /** - * @param Helper $helper - */ - public function __construct(Helper $helper) { - $this->helper = $helper; + public function __construct( + protected Helper $helper, + ) { parent::__construct(); } - protected function configure() { + protected function configure(): void { $this ->setName('ldap:create-empty-config') ->setDescription('creates an empty LDAP configuration') @@ -57,15 +34,17 @@ class CreateEmptyConfig extends Command { ; } - protected function execute(InputInterface $input, OutputInterface $output) { + 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')) { + 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 index e39425f3faa..7604e229bed 100644 --- a/apps/user_ldap/lib/Command/DeleteConfig.php +++ b/apps/user_ldap/lib/Command/DeleteConfig.php @@ -1,31 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Martin Konrad <info@martin-konrad.net> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\Command; - use OCA\User_LDAP\Helper; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -33,39 +14,35 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class DeleteConfig extends Command { - /** @var \OCA\User_LDAP\Helper */ - protected $helper; - - /** - * @param Helper $helper - */ - public function __construct(Helper $helper) { - $this->helper = $helper; + public function __construct( + protected Helper $helper, + ) { parent::__construct(); } - protected function configure() { + protected function configure(): void { $this ->setName('ldap:delete-config') ->setDescription('deletes an existing LDAP configuration') ->addArgument( - 'configID', - InputArgument::REQUIRED, - 'the configuration ID' - ) + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) ; } - - protected function execute(InputInterface $input, OutputInterface $output) { + protected function execute(InputInterface $input, OutputInterface $output): int { $configPrefix = $input->getArgument('configID'); $success = $this->helper->deleteServerConfiguration($configPrefix); - if($success) { - $output->writeln("Deleted configuration with configID '{$configPrefix}'"); - } else { + 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 index ae61bfcd41c..85906b20e9a 100644 --- a/apps/user_ldap/lib/Command/Search.php +++ b/apps/user_ldap/lib/Command/Search.php @@ -1,120 +1,97 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Juan Pablo Villafáñez <jvillafanez@solidgear.es> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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\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; -use OCA\User_LDAP\User_Proxy; -use OCA\User_LDAP\Group_Proxy; -use OCA\User_LDAP\Helper; -use OCA\User_LDAP\LDAP; -use OCP\IConfig; - class Search extends Command { - /** @var \OCP\IConfig */ - protected $ocConfig; - - /** - * @param \OCP\IConfig $ocConfig - */ - public function __construct(IConfig $ocConfig) { - $this->ocConfig = $ocConfig; + public function __construct( + protected IConfig $ocConfig, + private User_Proxy $userProxy, + private Group_Proxy $groupProxy, + ) { parent::__construct(); } - protected function configure() { + 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)' - ) + 'search', + InputArgument::REQUIRED, + 'the search string (can be empty)' + ) ->addOption( - 'group', - null, - InputOption::VALUE_NONE, - 'searches groups instead of users' - ) + '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 - ) + '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 - ) + '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 - * @param int $offset - * @param int $limit + * * @throws \InvalidArgumentException */ - protected function validateOffsetAndLimit($offset, $limit) { - if($limit < 0) { + protected function validateOffsetAndLimit(int $offset, int $limit): void { + if ($limit < 0) { throw new \InvalidArgumentException('limit must be 0 or greater'); } - if($offset < 0) { + if ($offset < 0) { throw new \InvalidArgumentException('offset must be 0 or greater'); } - if($limit === 0 && $offset !== 0) { + 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)) { + if ($offset > 0 && ($offset % $limit !== 0)) { throw new \InvalidArgumentException('offset must be a multiple of limit'); } } - protected function execute(InputInterface $input, OutputInterface $output) { - $helper = new Helper($this->ocConfig); + protected function execute(InputInterface $input, OutputInterface $output): int { + $helper = Server::get(Helper::class); $configPrefixes = $helper->getServerConfigurationPrefixes(true); $ldapWrapper = new LDAP(); - $offset = intval($input->getOption('offset')); - $limit = intval($input->getOption('limit')); + $offset = (int)$input->getOption('offset'); + $limit = (int)$input->getOption('limit'); $this->validateOffsetAndLimit($offset, $limit); - if($input->getOption('group')) { - $proxy = new Group_Proxy($configPrefixes, $ldapWrapper, \OC::$server->query('LDAPGroupPluginManager')); + 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 @@ -123,22 +100,16 @@ class Search extends Command { $limit = null; } } else { - $proxy = new User_Proxy( - $configPrefixes, - $ldapWrapper, - $this->ocConfig, - \OC::$server->getNotificationManager(), - \OC::$server->getUserSession(), - \OC::$server->query('LDAPUserPluginManager') - ); + $proxy = $this->userProxy; $getMethod = 'getDisplayNames'; $printID = true; } $result = $proxy->$getMethod($input->getArgument('search'), $limit, $offset); - foreach($result as $id => $name) { - $line = $name . ($printID ? ' ('.$id.')' : ''); + 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 index db656558efc..7e9efcf34d0 100644 --- a/apps/user_ldap/lib/Command/SetConfig.php +++ b/apps/user_ldap/lib/Command/SetConfig.php @@ -1,69 +1,52 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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\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; -use OCA\User_LDAP\Helper; -use OCA\User_LDAP\Configuration; class SetConfig extends Command { - - protected function configure() { + protected function configure(): void { $this ->setName('ldap:set-config') ->setDescription('modifies an LDAP configuration') ->addArgument( - 'configID', - InputArgument::REQUIRED, - 'the configuration ID' - ) + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) ->addArgument( - 'configKey', - InputArgument::REQUIRED, - 'the configuration key' - ) + 'configKey', + InputArgument::REQUIRED, + 'the configuration key' + ) ->addArgument( - 'configValue', - InputArgument::REQUIRED, - 'the new configuration value' - ) + 'configValue', + InputArgument::REQUIRED, + 'the new configuration value' + ) ; } - protected function execute(InputInterface $input, OutputInterface $output) { - $helper = new Helper(\OC::$server->getConfig()); + 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; + if (!in_array($configID, $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; } $this->setValue( @@ -71,17 +54,18 @@ class SetConfig extends Command { $input->getArgument('configKey'), $input->getArgument('configValue') ); + return self::SUCCESS; } /** * save the configuration value as provided - * @param string $configID - * @param string $configKey - * @param string $configValue */ - protected function setValue($configID, $key, $value) { + 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 index 7a24889eb09..fa021192ac4 100644 --- a/apps/user_ldap/lib/Command/ShowConfig.php +++ b/apps/user_ldap/lib/Command/ShowConfig.php @@ -1,111 +1,119 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Laurens Post <Crote@users.noreply.github.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\Command; -use Symfony\Component\Console\Command\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; -use OCA\User_LDAP\Helper; -use OCA\User_LDAP\Configuration; - -class ShowConfig extends Command { - /** @var \OCA\User_LDAP\Helper */ - protected $helper; - /** - * @param Helper $helper - */ - public function __construct(Helper $helper) { - $this->helper = $helper; +class ShowConfig extends Base { + public function __construct( + protected Helper $helper, + ) { parent::__construct(); } - protected function configure() { + 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' - ) + 'configID', + InputArgument::OPTIONAL, + 'will show the configuration of the specified id' + ) ->addOption( - 'show-password', - null, - InputOption::VALUE_NONE, - 'show ldap bind password' - ) + '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) { + protected function execute(InputInterface $input, OutputInterface $output): int { $availableConfigs = $this->helper->getServerConfigurationPrefixes(); $configID = $input->getArgument('configID'); - if(!is_null($configID)) { + if (!is_null($configID)) { $configIDs[] = $configID; - if(!in_array($configIDs[0], $availableConfigs)) { - $output->writeln("Invalid configID"); - return; + if (!in_array($configIDs[0], $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; } } else { $configIDs = $availableConfigs; } - $this->renderConfigs($configIDs, $output, $input->getOption('show-password')); + $this->renderConfigs($configIDs, $input, $output); + return self::SUCCESS; } /** * prints the LDAP configuration(s) - * @param string[] configID(s) - * @param OutputInterface $output - * @param bool $withPassword Set to TRUE to show plaintext passwords in output + * + * @param string[] $configIDs */ - protected function renderConfigs($configIDs, $output, $withPassword) { - foreach($configIDs as $id) { + 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); - $table = new Table($output); - $table->setHeaders(array('Configuration', $id)); - $rows = array(); - foreach($configuration as $key => $value) { - if($key === 'ldapAgentPassword' && !$withPassword) { - $value = '***'; + $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]; + } } - if(is_array($value)) { - $value = implode(';', $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; } - $rows[] = array($key, $value); } - $table->setRows($rows); - $table->render($output); + $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 index 365c8967ee0..d255aac1368 100644 --- a/apps/user_ldap/lib/Command/ShowRemnants.php +++ b/apps/user_ldap/lib/Command/ShowRemnants.php @@ -1,95 +1,80 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author scolebrook <scolebrook@mac.com> - * - * @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\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; -use OCA\User_LDAP\User\DeletedUsersIndex; -use OCP\IDateTimeFormatter; - class ShowRemnants extends Command { - /** @var \OCA\User_LDAP\User\DeletedUsersIndex */ - protected $dui; - - /** @var \OCP\IDateTimeFormatter */ - protected $dateFormatter; - - /** - * @param DeletedUsersIndex $dui - * @param IDateTimeFormatter $dateFormatter - */ - public function __construct(DeletedUsersIndex $dui, IDateTimeFormatter $dateFormatter) { - $this->dui = $dui; - $this->dateFormatter = $dateFormatter; + public function __construct( + protected DeletedUsersIndex $dui, + protected IDateTimeFormatter $dateFormatter, + ) { parent::__construct(); } - protected function configure() { + 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('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. creeates and outputs a table of LDAP users marked as deleted + * executes the command, i.e. creates and outputs a table of LDAP users marked as deleted * * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) { + protected function execute(InputInterface $input, OutputInterface $output): int { /** @var \Symfony\Component\Console\Helper\Table $table */ $table = new Table($output); - $table->setHeaders(array( + $table->setHeaders([ 'Nextcloud name', 'Display Name', 'LDAP UID', 'LDAP DN', 'Last Login', - 'Dir', 'Sharer')); - $rows = array(); + 'Detected on', 'Dir', 'Sharer' + ]); + $rows = []; $resultSet = $this->dui->getUsers(); - foreach($resultSet as $user) { - $hAS = $user->getHasActiveShares() ? 'Y' : 'N'; - $lastLogin = ($user->getLastLogin() > 0) ? - $this->dateFormatter->formatDate($user->getLastLogin()) : '-'; - $rows[] = array('ocName' => $user->getOCName(), - 'displayName' => $user->getDisplayName(), - 'uid' => $user->getUID(), - 'dn' => $user->getDN(), - 'lastLogin' => $lastLogin, - 'homePath' => $user->getHomePath(), - 'sharer' => $hAS - ); + 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)); + $output->writeln(json_encode($rows)); } else { $table->setRows($rows); - $table->render($output); + $table->render(); } + return self::SUCCESS; } } diff --git a/apps/user_ldap/lib/Command/TestConfig.php b/apps/user_ldap/lib/Command/TestConfig.php index a385c892e1e..77eaac91d85 100644 --- a/apps/user_ldap/lib/Command/TestConfig.php +++ b/apps/user_ldap/lib/Command/TestConfig.php @@ -1,93 +1,94 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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\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; -use \OCA\User_LDAP\Helper; -use \OCA\User_LDAP\Connection; class TestConfig extends Command { + protected const ESTABLISHED = 0; + protected const CONF_INVALID = 1; + protected const BINDFAILURE = 2; + protected const SEARCHFAILURE = 3; - protected function configure() { + 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' - ) + 'configID', + InputArgument::REQUIRED, + 'the configuration ID' + ) ; } - protected function execute(InputInterface $input, OutputInterface $output) { - $helper = new Helper(\OC::$server->getConfig()); - $availableConfigs = $helper->getServerConfigurationPrefixes(); + 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; + if (!in_array($configID, $availableConfigs)) { + $output->writeln('Invalid configID'); + return self::FAILURE; } $result = $this->testConfig($configID); - if($result === 0) { - $output->writeln('The configuration is valid and the connection could be established!'); - } else if($result === 1) { - $output->writeln('The configuration is invalid. Please have a look at the logs for further details.'); - } else if($result === 2) { - $output->writeln('The configuration is valid, but the Bind failed. Please check the server settings and credentials.'); - } else { - $output->writeln('Your LDAP server was kidnapped by aliens.'); - } + + $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 - * @param string $configID - * @return int + * Tests the specified connection */ - protected function testConfig($configID) { - $lw = new \OCA\User_LDAP\LDAP(); - $connection = new Connection($lw, $configID); + protected function testConfig(string $configID): int { + $connection = new Connection($this->ldap, $configID); - //ensure validation is run before we attempt the bind + // Ensure validation is run before we attempt the bind $connection->getConfiguration(); - if(!$connection->setConfiguration(array( + if (!$connection->setConfiguration([ 'ldap_configuration_active' => 1, - ))) { - return 1; + ])) { + return static::CONF_INVALID; + } + if (!$connection->bind()) { + return static::BINDFAILURE; } - if($connection->bind()) { - return 0; + $access = $this->accessFactory->get($connection); + $result = $access->countObjects(1); + if (!is_int($result) || ($result <= 0)) { + return static::SEARCHFAILURE; } - return 2; + 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 index 7179d9aa39d..b4a5b847204 100644 --- a/apps/user_ldap/lib/Configuration.php +++ b/apps/user_ldap/lib/Configuration.php @@ -1,56 +1,117 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Alex Weirig <alex.weirig@technolink.lu> - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lennart Rosam <hello@takuto.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Xuanwo <xuanwo@yunify.com> - * - * @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; +use OCP\IConfig; +use OCP\Server; +use Psr\Log\LoggerInterface; + /** - * @property int ldapPagingSize holds an integer + * @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 { - protected $configPrefix = null; + 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[] pre-filled with one reference key so that at least one entry is written on save request and - * the config ID is registered + * @var string[] */ - protected $unsavedChanges = ['ldapConfigurationActive' => 'ldapConfigurationActive']; + protected array $unsavedChanges = []; - //settings - protected $config = array( + /** + * @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, @@ -61,6 +122,7 @@ class Configuration { 'ldapIgnoreNamingRules' => null, 'ldapUserDisplayName' => null, 'ldapUserDisplayName2' => null, + 'ldapUserAvatarRule' => null, 'ldapGidNumber' => null, 'ldapUserFilterObjectclass' => null, 'ldapUserFilterGroups' => null, @@ -89,27 +151,41 @@ class Configuration { 'ldapAttributesForGroupSearch' => null, 'ldapExperiencedAdmin' => false, 'homeFolderNamingRule' => null, - 'hasPagedResultSupport' => false, '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, + ]; - /** - * @param string $configPrefix - * @param bool $autoRead - */ - public function __construct($configPrefix, $autoRead = true) { - $this->configPrefix = $configPrefix; - if($autoRead) { + public function __construct( + protected string $configPrefix, + bool $autoRead = true, + ) { + if ($autoRead) { $this->readConfiguration(); } } @@ -119,7 +195,7 @@ class Configuration { * @return mixed|null */ public function __get($name) { - if(isset($this->config[$name])) { + if (isset($this->config[$name])) { return $this->config[$name]; } return null; @@ -130,13 +206,10 @@ class Configuration { * @param mixed $value */ public function __set($name, $value) { - $this->setConfiguration(array($name => $value)); + $this->setConfiguration([$name => $value]); } - /** - * @return array - */ - public function getConfiguration() { + public function getConfiguration(): array { return $this->config; } @@ -145,34 +218,29 @@ class Configuration { * 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 + * 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; - } - + public function setConfiguration(array $config, ?array &$applied = null): void { $cta = $this->getConfigTranslationArray(); - foreach($config as $inputKey => $val) { - if(strpos($inputKey, '_') !== false && array_key_exists($inputKey, $cta)) { + foreach ($config as $inputKey => $val) { + if (str_contains($inputKey, '_') && array_key_exists($inputKey, $cta)) { $key = $cta[$inputKey]; - } elseif(array_key_exists($inputKey, $this->config)) { + } elseif (array_key_exists($inputKey, $this->config)) { $key = $inputKey; } else { continue; } $setMethod = 'setValue'; - switch($key) { + switch ($key) { case 'ldapAgentPassword': $setMethod = 'setRawValue'; break; case 'homeFolderNamingRule': $trimmedVal = trim($val); - if ($trimmedVal !== '' && strpos($val, 'attr:') === false) { - $val = 'attr:'.$trimmedVal; + if ($trimmedVal !== '' && !str_contains($val, 'attr:')) { + $val = 'attr:' . $trimmedVal; } break; case 'ldapBase': @@ -189,25 +257,24 @@ class Configuration { break; } $this->$setMethod($key, $val); - if(is_array($applied)) { + if (is_array($applied)) { $applied[] = $inputKey; // storing key as index avoids duplication, and as value for simplicity } $this->unsavedChanges[$key] = $key; } - return null; } - public function readConfiguration() { - if(!$this->configRead && !is_null($this->configPrefix)) { + public function readConfiguration(): void { + if (!$this->configRead) { $cta = array_flip($this->getConfigTranslationArray()); - foreach($this->config as $key => $val) { - if(!isset($cta[$key])) { + foreach ($this->config as $key => $val) { + if (!isset($cta[$key])) { //some are determined continue; } $dbKey = $cta[$key]; - switch($key) { + switch ($key) { case 'ldapBase': case 'ldapBaseUsers': case 'ldapBaseGroups': @@ -229,6 +296,28 @@ class Configuration { 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': @@ -248,9 +337,10 @@ class Configuration { /** * saves the current config changes in the database */ - public function saveConfiguration() { + public function saveConfiguration(): void { $cta = array_flip($this->getConfigTranslationArray()); - foreach($this->unsavedChanges as $key) { + $changed = false; + foreach ($this->unsavedChanges as $key) { $value = $this->config[$key]; switch ($key) { case 'ldapAgentPassword': @@ -266,23 +356,25 @@ class Configuration { case 'ldapGroupFilterObjectclass': case 'ldapGroupFilterGroups': case 'ldapLoginFilterAttributes': - if(is_array($value)) { + if (is_array($value)) { $value = implode("\n", $value); } break; - //following options are not stored but detected, skip them + //following options are not stored but detected, skip them case 'ldapIgnoreNamingRules': - case 'hasPagedResultSupport': case 'ldapUuidUserAttribute': case 'ldapUuidGroupAttribute': continue 2; } - if(is_null($value)) { + if (is_null($value)) { $value = ''; } + $changed = true; $this->saveValue($cta[$key], $value); } - $this->saveValue('_lastChange', time()); + if ($changed) { + $this->saveValue('_lastChange', (string)time()); + } $this->unsavedChanges = []; } @@ -292,7 +384,7 @@ class Configuration { */ protected function getMultiLine($varName) { $value = $this->getValue($varName); - if(empty($value)) { + if (empty($value)) { $value = ''; } else { $value = preg_split('/\r\n|\r|\n/', $value); @@ -303,26 +395,26 @@ class Configuration { /** * 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)) { + protected function setMultiLine(string $varName, $value): void { + if (empty($value)) { $value = ''; - } else if (!is_array($value)) { + } elseif (!is_array($value)) { $value = preg_split('/\r\n|\r|\n|;/', $value); - if($value === false) { + if ($value === false) { $value = ''; } } - if(!is_array($value)) { + if (!is_array($value)) { $finalValue = trim($value); } else { $finalValue = []; - foreach($value as $key => $val) { - if(is_string($val)) { + foreach ($value as $key => $val) { + if (is_string($val)) { $val = trim($val); if ($val !== '') { //accidental line breaks are not wanted and can cause @@ -338,53 +430,37 @@ class Configuration { $this->setRawValue($varName, $finalValue); } - /** - * @param string $varName - * @return string - */ - protected function getPwd($varName) { + protected function getPwd(string $varName): string { return base64_decode($this->getValue($varName)); } - /** - * @param string $varName - * @return string - */ - protected function getLcValue($varName) { + protected function getLcValue(string $varName): string { return mb_strtolower($this->getValue($varName), 'UTF-8'); } - /** - * @param string $varName - * @return string - */ - protected function getSystemValue($varName) { + protected function getSystemValue(string $varName): string { //FIXME: if another system value is added, softcode the default value - return \OC::$server->getConfig()->getSystemValue($varName, false); + return Server::get(IConfig::class)->getSystemValue($varName, false); } - /** - * @param string $varName - * @return string - */ - protected function getValue($varName) { + protected function getValue(string $varName): string { static $defaults; - if(is_null($defaults)) { + if (is_null($defaults)) { $defaults = $this->getDefaults(); } - return \OC::$server->getConfig()->getAppValue('user_ldap', - $this->configPrefix.$varName, - $defaults[$varName]); + 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($varName, $value) { - if(is_string($value)) { + protected function setValue(string $varName, $value): void { + if (is_string($value)) { $value = trim($value); } $this->config[$varName] = $value; @@ -396,19 +472,14 @@ class Configuration { * @param string $varName name of config key * @param mixed $value to set */ - protected function setRawValue($varName, $value) { + protected function setRawValue(string $varName, $value): void { $this->config[$varName] = $value; } - /** - * @param string $varName - * @param string $value - * @return bool - */ - protected function saveValue($varName, $value) { - \OC::$server->getConfig()->setAppValue( + protected function saveValue(string $varName, string $value): bool { + Server::get(IConfig::class)->setAppValue( 'user_ldap', - $this->configPrefix.$varName, + $this->configPrefix . $varName, $value ); return true; @@ -416,123 +487,201 @@ class Configuration { /** * @return array an associative array with the default values. Keys are correspond - * to config-value entries in the database table + * 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_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' => '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, + 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, - '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_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() { + public function getConfigTranslationArray(): array { //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_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', + 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', + '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_turn_on_pwd_change' => 'turnOnPasswordChange', - 'ldap_experienced_admin' => 'ldapExperiencedAdmin', - 'ldap_dynamic_group_member_url' => 'ldapDynamicGroupMemberURL', - 'ldap_default_ppolicy_dn' => 'ldapDefaultPPolicyDN', - ); + '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 index c73a35e6bf1..336179ac341 100644 --- a/apps/user_ldap/lib/Connection.php +++ b/apps/user_ldap/lib/Connection.php @@ -1,70 +1,106 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Brent Bloxam <brent.bloxam@gmail.com> - * @author Jarkko Lehtoranta <devel@jlranta.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Lyonel Vincent <lyonel@ezix.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author root <root@localhost.localdomain> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Xuanwo <xuanwo@yunify.com> - * - * @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; 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 (incomplete) + * magic properties * 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 turnOnPasswordChange - * @property boolean hasPagedResultSupport - * @property string[] ldapBaseUsers - * @property int|null ldapPagingSize holds an integer - * @property bool|mixed|void ldapGroupMemberAssocAttr - * @property string ldapUuidUserAttribute - * @property string ldapUuidGroupAttribute - * @property string ldapExpertUUIDUserAttr - * @property string ldapExpertUUIDGroupAttr + * @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 $ldapConnectionRes = null; - private $configPrefix; - private $configID; - private $configured = false; - private $hasPagedResultSupport = true; - //whether connection should be kept on __destruct - private $dontDestruct = false; + 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 @@ -76,44 +112,60 @@ class Connection extends LDAPUtility { */ public $hasGidNumber = true; - //cache handler - protected $cache; + /** + * @var ICache|null + */ + protected $cache = null; - /** @var Configuration settings handler **/ + /** @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 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') { + public function __construct( + ILDAPWrapper $ldap, + private string $configPrefix = '', + private ?string $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->configuration = new Configuration($this->configPrefix, !is_null($this->configID)); + $memcache = Server::get(ICacheFactory::class); + if ($memcache->isAvailable()) { $this->cache = $memcache->createDistributed(); } - $helper = new Helper(\OC::$server->getConfig()); + $helper = Server::get(Helper::class); $this->doNotValidate = !in_array($this->configPrefix, $helper->getServerConfigurationPrefixes()); - $this->hasPagedResultSupport = - intval($this->configuration->ldapPagingSize) !== 0 - || $this->ldap->hasPagedResultSupport(); + $this->logger = Server::get(LoggerInterface::class); + $this->l10n = Util::getL10N('user_ldap'); } public function __destruct() { - if(!$this->dontDestruct && $this->ldap->isResource($this->ldapConnectionRes)) { + if (!$this->dontDestruct && $this->ldap->isResource($this->ldapConnectionRes)) { @$this->ldap->unbind($this->ldapConnectionRes); - }; + $this->bindResult = []; + } } /** @@ -121,24 +173,19 @@ class Connection extends LDAPUtility { */ public function __clone() { $this->configuration = new Configuration($this->configPrefix, - !is_null($this->configID)); + !is_null($this->configID)); + if (count($this->bindResult) !== 0 && $this->bindResult['result'] === true) { + $this->bindResult = []; + } $this->ldapConnectionRes = null; $this->dontDestruct = true; } - /** - * @param string $name - * @return bool|mixed - */ - public function __get($name) { - if(!$this->configured) { + public function __get(string $name) { + if (!$this->configured) { $this->readConfiguration(); } - if($name === 'hasPagedResultSupport') { - return $this->hasPagedResultSupport; - } - return $this->configuration->$name; } @@ -151,7 +198,7 @@ class Connection extends LDAPUtility { $before = $this->configuration->$name; $this->configuration->$name = $value; $after = $this->configuration->$name; - if($before !== $after) { + if ($before !== $after) { if ($this->configID !== '' && $this->configID !== null) { $this->configuration->saveConfiguration(); } @@ -160,6 +207,15 @@ class Connection extends LDAPUtility { } /** + * @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. @@ -179,17 +235,17 @@ class Connection extends LDAPUtility { } /** - * Returns the LDAP handler + * @return \LDAP\Connection The LDAP resource */ - public function getConnectionResource() { - if(!$this->ldapConnectionRes) { + public function getConnectionResource(): \LDAP\Connection { + 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); + 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; @@ -198,23 +254,23 @@ class Connection extends LDAPUtility { /** * resets the connection resource */ - public function resetConnectionResource() { - if(!is_null($this->ldapConnectionRes)) { + public function resetConnectionResource(): void { + if (!is_null($this->ldapConnectionRes)) { @$this->ldap->unbind($this->ldapConnectionRes); $this->ldapConnectionRes = null; + $this->bindResult = []; } } /** * @param string|null $key - * @return string */ - private function getCacheKey($key) { - $prefix = 'LDAP-'.$this->configID.'-'.$this->configPrefix.'-'; - if(is_null($key)) { + private function getCacheKey($key): string { + $prefix = 'LDAP-' . $this->configID . '-' . $this->configPrefix . '-'; + if (is_null($key)) { return $prefix; } - return $prefix.md5($key); + return $prefix . hash('sha256', $key); } /** @@ -222,39 +278,42 @@ class Connection extends LDAPUtility { * @return mixed|null */ public function getFromCache($key) { - if(!$this->configured) { + if (!$this->configured) { $this->readConfiguration(); } - if(is_null($this->cache) || !$this->configuration->ldapCacheTTL) { + if (is_null($this->cache) || !$this->configuration->ldapCacheTTL) { return null; } $key = $this->getCacheKey($key); - return json_decode(base64_decode($this->cache->get($key)), true); + return json_decode(base64_decode($this->cache->get($key) ?? ''), true); + } + + public function getConfigPrefix(): string { + return $this->configPrefix; } /** * @param string $key * @param mixed $value - * - * @return string */ - public function writeToCache($key, $value) { - if(!$this->configured) { + public function writeToCache($key, $value, ?int $ttlOverride = null): void { + if (!$this->configured) { $this->readConfiguration(); } - if(is_null($this->cache) + if (is_null($this->cache) || !$this->configuration->ldapCacheTTL || !$this->configuration->ldapConfigurationActive) { - return null; + return; } - $key = $this->getCacheKey($key); + $key = $this->getCacheKey($key); $value = base64_encode(json_encode($value)); - $this->cache->set($key, $value, $this->configuration->ldapCacheTTL); + $ttl = $ttlOverride ?? $this->configuration->ldapCacheTTL; + $this->cache->set($key, $value, $ttl); } public function clearCache() { - if(!is_null($this->cache)) { + if (!is_null($this->cache)) { $this->cache->clear($this->getCacheKey(null)); } } @@ -262,11 +321,10 @@ class Connection extends LDAPUtility { /** * Caches the general LDAP configuration. * @param bool $force optional. true, if the re-read should be forced. defaults - * to false. - * @return null + * to false. */ - private function readConfiguration($force = false) { - if((!$this->configured || $force) && !is_null($this->configID)) { + private function readConfiguration(bool $force = false): void { + if ((!$this->configured || $force) && !is_null($this->configID)) { $this->configuration->readConfiguration(); $this->configured = $this->validateConfiguration(); } @@ -276,16 +334,17 @@ class Connection extends LDAPUtility { * 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 + * @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($config, &$setParameters = null) { - if(is_null($setParameters)) { - $setParameters = array(); + 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(); + if (count($setParameters) > 0) { + $this->configured = $this->validateConfiguration($throw); } @@ -310,11 +369,11 @@ class Connection extends LDAPUtility { $this->readConfiguration(); $config = $this->configuration->getConfiguration(); $cta = $this->configuration->getConfigTranslationArray(); - $result = array(); - foreach($cta as $dbkey => $configkey) { - switch($configkey) { + $result = []; + foreach ($cta as $dbkey => $configkey) { + switch ($configkey) { case 'homeFolderNamingRule': - if(strpos($config[$configkey], 'attr:') === 0) { + if (str_starts_with($config[$configkey], 'attr:')) { $result[$dbkey] = substr($config[$configkey], 5); } else { $result[$dbkey] = ''; @@ -325,10 +384,11 @@ class Connection extends LDAPUtility { case 'ldapBaseGroups': case 'ldapAttributesForUserSearch': case 'ldapAttributesForGroupSearch': - if(is_array($config[$configkey])) { + if (is_array($config[$configkey])) { $result[$dbkey] = implode("\n", $config[$configkey]); break; } //else follows default + // no break default: $result[$dbkey] = $config[$configkey]; } @@ -336,78 +396,77 @@ class Connection extends LDAPUtility { return $result; } - private function doSoftValidation() { + private function doSoftValidation(): void { //if User or Group Base are not set, take over Base DN setting - foreach(array('ldapBaseUsers', 'ldapBaseGroups') as $keyBase) { + foreach (['ldapBaseUsers', 'ldapBaseGroups'] as $keyBase) { $val = $this->configuration->$keyBase; - if(empty($val)) { + if (empty($val)) { $this->configuration->$keyBase = $this->configuration->ldapBase; } } - foreach(array('ldapExpertUUIDUserAttr' => 'ldapUuidUserAttribute', - 'ldapExpertUUIDGroupAttr' => 'ldapUuidGroupAttribute') - as $expertSetting => $effectiveSetting) { + foreach (['ldapExpertUUIDUserAttr' => 'ldapUuidUserAttribute', + 'ldapExpertUUIDGroupAttr' => 'ldapUuidGroupAttribute'] as $expertSetting => $effectiveSetting) { $uuidOverride = $this->configuration->$expertSetting; - if(!empty($uuidOverride)) { + 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))) { + 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); + $this->logger->info( + 'Illegal value for the ' . $effectiveSetting . ', reset to autodetect.', + ['app' => 'user_ldap'] + ); } - } } - $backupPort = intval($this->configuration->ldapBackupPort); + $backupPort = (int)$this->configuration->ldapBackupPort; if ($backupPort <= 0) { - $this->configuration->backupPort = $this->configuration->ldapPort; + $this->configuration->ldapBackupPort = $this->configuration->ldapPort; } //make sure empty search attributes are saved as simple, empty array - $saKeys = array('ldapAttributesForUserSearch', - 'ldapAttributesForGroupSearch'); - foreach($saKeys as $key) { + $saKeys = ['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 (is_array($val) && count($val) === 1 && empty($val[0])) { + $this->configuration->$key = []; } } - if((stripos($this->configuration->ldapHost, 'ldaps://') === 0) + if ((stripos((string)$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); + $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'] + ); } } /** - * @return bool + * @throws ConfigurationIssueException */ - private function doCriticalValidation() { - $configurationOK = true; - $errorStr = 'Configuration Error (prefix '. - strval($this->configPrefix).'): '; - + private function doCriticalValidation(): void { //options that shall not be empty - $options = array('ldapHost', 'ldapPort', 'ldapUserDisplayName', - 'ldapGroupDisplayName', 'ldapLoginFilter'); - foreach($options as $key) { + $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) { + if (empty($val)) { + switch ($key) { case 'ldapHost': $subj = 'LDAP Host'; break; @@ -427,58 +486,87 @@ class Connection extends LDAPUtility { $subj = $key; break; } - $configurationOK = false; - \OCP\Util::writeLog('user_ldap', - $errorStr.'No '.$subj.' given!', - \OCP\Util::WARN); + 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 !== '') - || ($agent !== '' && $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; + 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) && empty($baseUsers) && empty($baseGroups)) { - \OCP\Util::writeLog('user_ldap', - $errorStr.'Not a single Base DN given.', - \OCP\Util::WARN); - $configurationOK = false; + 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(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; + 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'), + ); } - return $configurationOK; + 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() { - - if($this->doNotValidate) { + 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 @@ -491,83 +579,94 @@ class Connection extends LDAPUtility { //second step: critical checks. If left empty or filled wrong, mark as //not configured and give a warning. - return $this->doCriticalValidation(); + 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() { - if(!$this->configuration->ldapConfigurationActive) { + private function establishConnection(): ?bool { + if (!$this->configuration->ldapConfigurationActive) { return null; } static $phpLDAPinstalled = true; - if(!$phpLDAPinstalled) { + if (!$phpLDAPinstalled) { return false; } - if(!$this->ignoreValidation && !$this->configured) { - \OCP\Util::writeLog('user_ldap', - 'Configuration is invalid, cannot connect', - \OCP\Util::WARN); + 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()) { + 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); + $this->logger->error( + 'function ldap_connect is not available. Make sure that the PHP ldap module is installed.', + ['app' => 'user_ldap'] + ); 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); - } - } - $isOverrideMainServer = ($this->configuration->ldapOverrideMainServer - || $this->getFromCache('overrideMainServer')); - $isBackupHost = (trim($this->configuration->ldapBackupHost) !== ""); + $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; - $error = -1; - try { - if (!$isOverrideMainServer) { - $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 (ServerNotAvailableException $e) { - if(!$isBackupHost) { - throw $e; + 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 - if($isBackupHost && ($error !== 0 || $isOverrideMainServer)) { - $this->doConnect($this->configuration->ldapBackupHost, - $this->configuration->ldapBackupPort); - $bindStatus = $this->bind(); - $error = $this->ldap->isResource($this->ldapConnectionRes) ? - $this->ldap->errno($this->ldapConnectionRes) : -1; - if($bindStatus && $error === 0 && !$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); - } + // 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; @@ -578,26 +677,47 @@ class Connection extends LDAPUtility { /** * @param string $host * @param string $port - * @return bool * @throws \OC\ServerNotAvailableException */ - private function doConnect($host, $port) { + private function doConnect($host, $port): bool { if ($host === '') { return false; } - $this->ldapConnectionRes = $this->ldap->connect($host, $port); + $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)) { + 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)) { + if (!$this->ldap->setOption($this->ldapConnectionRes, LDAP_OPT_REFERRALS, 0)) { throw new ServerNotAvailableException('Could not disable LDAP referrals.'); } - if($this->configuration->ldapTLS) { - if(!$this->ldap->startTls($this->ldapConnectionRes)) { + 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 . '.'); } } @@ -609,25 +729,47 @@ class Connection extends LDAPUtility { * Binds to LDAP */ public function bind() { - if(!$this->configuration->ldapConfigurationActive) { + if (!$this->configuration->ldapConfigurationActive) { return false; } - $cr = $this->getConnectionResource(); - if(!$this->ldap->isResource($cr)) { - 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); - if(!$ldapLogin) { + $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); - \OCP\Util::writeLog('user_ldap', + $this->logger->warning( 'Bind failed: ' . $errno . ': ' . $this->ldap->error($cr), - \OCP\Util::WARN); - - // Set to failure mode, if LDAP error code is not LDAP_SUCCESS or LDAP_INVALID_CREDENTIALS - if($errno !== 0x00 && $errno !== 0x31) { + ['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; } @@ -635,5 +777,4 @@ class Connection extends LDAPUtility { } return true; } - } diff --git a/apps/user_ldap/lib/ConnectionFactory.php b/apps/user_ldap/lib/ConnectionFactory.php index 0857afdcc8d..dd0ad31920a 100644 --- a/apps/user_ldap/lib/ConnectionFactory.php +++ b/apps/user_ldap/lib/ConnectionFactory.php @@ -1,35 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP; - class ConnectionFactory { - /** @var ILDAPWrapper */ - private $ldap; - - public function __construct(ILDAPWrapper $ldap) { - $this->ldap = $ldap; + public function __construct( + private ILDAPWrapper $ldap, + ) { } public function get($prefix) { diff --git a/apps/user_ldap/lib/Controller/ConfigAPIController.php b/apps/user_ldap/lib/Controller/ConfigAPIController.php index 54800ef24eb..d98e6d41b52 100644 --- a/apps/user_ldap/lib/Controller/ConfigAPIController.php +++ b/apps/user_ldap/lib/Controller/ConfigAPIController.php @@ -1,59 +1,42 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\ILogger; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; +use OCP\ServerVersion; +use Psr\Log\LoggerInterface; class ConfigAPIController extends OCSController { - - /** @var Helper */ - private $ldapHelper; - - /** @var ILogger */ - private $logger; - public function __construct( - $appName, + string $appName, IRequest $request, CapabilitiesManager $capabilitiesManager, IUserSession $userSession, IUserManager $userManager, Manager $keyManager, - Helper $ldapHelper, - ILogger $logger + ServerVersion $serverVersion, + private Helper $ldapHelper, + private LoggerInterface $logger, + private ConnectionFactory $connectionFactory, ) { parent::__construct( $appName, @@ -61,96 +44,54 @@ class ConfigAPIController extends OCSController { $capabilitiesManager, $userSession, $userManager, - $keyManager + $keyManager, + $serverVersion, ); - - - $this->ldapHelper = $ldapHelper; - $this->logger = $logger; } /** - * creates a new (empty) configuration and returns the resulting prefix - * - * Example: curl -X POST -H "OCS-APIREQUEST: true" -u $admin:$password \ - * https://nextcloud.server/ocs/v2.php/apps/user_ldap/api/v1/config - * - * results in: - * - * <?xml version="1.0"?> - * <ocs> - * <meta> - * <status>ok</status> - * <statuscode>200</statuscode> - * <message>OK</message> - * </meta> - * <data> - * <configID>s40</configID> - * </data> - * </ocs> - * - * Failing example: if an exception is thrown (e.g. Database connection lost) - * the detailed error will be logged. The output will then look like: - * - * <?xml version="1.0"?> - * <ocs> - * <meta> - * <status>failure</status> - * <statuscode>999</statuscode> - * <message>An issue occurred when creating the new config.</message> - * </meta> - * <data/> - * </ocs> - * - * For JSON output provide the format=json parameter + * Create a new (empty) configuration and return the resulting prefix * - * @return DataResponse + * @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->logException($e); + $this->logger->error($e->getMessage(), ['exception' => $e]); throw new OCSException('An issue occurred when creating the new config.'); } return new DataResponse(['configID' => $configPrefix]); } /** - * Deletes a LDAP configuration, if present. - * - * Example: - * curl -X DELETE -H "OCS-APIREQUEST: true" -u $admin:$password \ - * https://nextcloud.server/ocs/v2.php/apps/user_ldap/api/v1/config/s60 - * - * <?xml version="1.0"?> - * <ocs> - * <meta> - * <status>ok</status> - * <statuscode>200</statuscode> - * <message>OK</message> - * </meta> - * <data/> - * </ocs> + * Delete a LDAP configuration * - * @param string $configID - * @return DataResponse - * @throws OCSBadRequestException + * @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)) { + if (!$this->ldapHelper->deleteServerConfiguration($configID)) { throw new OCSException('Could not delete configuration'); } - } catch(OCSException $e) { + } catch (OCSException $e) { throw $e; - } catch(\Exception $e) { - $this->logger->logException($e); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); throw new OCSException('An issue occurred when deleting the config.'); } @@ -158,33 +99,23 @@ class ConfigAPIController extends OCSController { } /** - * modifies a configuration + * Modify a configuration * - * Example: - * curl -X PUT -d "configData[ldapHost]=ldaps://my.ldap.server&configData[ldapPort]=636" \ - * -H "OCS-APIREQUEST: true" -u $admin:$password \ - * https://nextcloud.server/ocs/v2.php/apps/user_ldap/api/v1/config/s60 - * - * <?xml version="1.0"?> - * <ocs> - * <meta> - * <status>ok</status> - * <statuscode>200</statuscode> - * <message>OK</message> - * </meta> - * <data/> - * </ocs> - * - * @param string $configID - * @param array $configData - * @return DataResponse + * @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)) { + if (!is_array($configData)) { throw new OCSBadRequestException('configData is not properly set'); } @@ -192,16 +123,17 @@ class ConfigAPIController extends OCSController { $configKeys = $configuration->getConfigTranslationArray(); foreach ($configKeys as $i => $key) { - if(isset($configData[$key])) { + if (isset($configData[$key])) { $configuration->$key = $configData[$key]; } } $configuration->saveConfiguration(); - } catch(OCSException $e) { + $this->connectionFactory->get($configID)->clearCache(); + } catch (OCSException $e) { throw $e; } catch (\Exception $e) { - $this->logger->logException($e); + $this->logger->error($e->getMessage(), ['exception' => $e]); throw new OCSException('An issue occurred when modifying the config.'); } @@ -209,8 +141,9 @@ class ConfigAPIController extends OCSController { } /** - * retrieves a configuration + * Get a configuration * + * Output can look like this: * <?xml version="1.0"?> * <ocs> * <meta> @@ -260,7 +193,6 @@ class ConfigAPIController extends OCSController { * <ldapAttributesForGroupSearch></ldapAttributesForGroupSearch> * <ldapExperiencedAdmin>0</ldapExperiencedAdmin> * <homeFolderNamingRule></homeFolderNamingRule> - * <hasPagedResultSupport></hasPagedResultSupport> * <hasMemberOfFilterSupport></hasMemberOfFilterSupport> * <useMemberOfToDetectMembership>1</useMemberOfToDetectMembership> * <ldapExpertUsernameAttr>uid</ldapExpertUsernameAttr> @@ -274,30 +206,34 @@ class ConfigAPIController extends OCSController { * </data> * </ocs> * - * @param string $configID - * @param bool|string $showPassword - * @return DataResponse + * @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(!boolval(intval($showPassword))) { + if (!$showPassword) { $data['ldapAgentPassword'] = '***'; } foreach ($data as $key => $value) { - if(is_array($value)) { + if (is_array($value)) { $value = implode(';', $value); $data[$key] = $value; } } - } catch(OCSException $e) { + } catch (OCSException $e) { throw $e; } catch (\Exception $e) { - $this->logger->logException($e); + $this->logger->error($e->getMessage(), ['exception' => $e]); throw new OCSException('An issue occurred when modifying the config.'); } @@ -305,14 +241,15 @@ class ConfigAPIController extends OCSController { } /** - * if the given config ID is not available, an exception is thrown + * If the given config ID is not available, an exception is thrown * * @param string $configID * @throws OCSNotFoundException */ - private function ensureConfigIDExists($configID) { + #[AuthorizedAdminSetting(settings: Admin::class)] + private function ensureConfigIDExists($configID): void { $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(); - if(!in_array($configID, $prefixes, true)) { + 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 index 9cdcdddb141..8389a362b8f 100644 --- a/apps/user_ldap/lib/Controller/RenewPasswordController.php +++ b/apps/user_ldap/lib/Controller/RenewPasswordController.php @@ -1,33 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2017 Roger Szabo <roger.szabo@web.de> - * - * @author Roger Szabo <roger.szabo@web.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP\Controller; -use OC\HintException; -use OC_Util; 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; @@ -36,18 +22,8 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class RenewPasswordController extends Controller { - /** @var IUserManager */ - private $userManager; - /** @var IConfig */ - private $config; - /** @var IL10N */ - protected $l10n; - /** @var ISession */ - private $session; - /** @var IURLGenerator */ - private $urlGenerator; - /** * @param string $appName * @param IRequest $request @@ -55,37 +31,37 @@ class RenewPasswordController extends Controller { * @param IConfig $config * @param IURLGenerator $urlGenerator */ - function __construct($appName, IRequest $request, IUserManager $userManager, - IConfig $config, IL10N $l10n, ISession $session, 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); - $this->userManager = $userManager; - $this->config = $config; - $this->l10n = $l10n; - $this->session = $session; - $this->urlGenerator = $urlGenerator; } /** - * @PublicPage - * @NoCSRFRequired - * * @return RedirectResponse */ + #[PublicPage] + #[NoCSRFRequired] public function cancel() { return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); } /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * * @param string $user * * @return TemplateResponse|RedirectResponse */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] public function showRenewPasswordForm($user) { - if($this->config->getUserValue($user, 'user_ldap', 'needsPasswordReset') !== 'true') { + if ($this->config->getUserValue($user, 'user_ldap', 'needsPasswordReset') !== 'true') { return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); } $parameters = []; @@ -93,7 +69,7 @@ class RenewPasswordController extends Controller { $errors = []; $messages = []; if (is_array($renewPasswordMessages)) { - list($errors, $messages) = $renewPasswordMessages; + [$errors, $messages] = $renewPasswordMessages; } $this->session->remove('renewPasswordMessages'); foreach ($errors as $value) { @@ -119,17 +95,16 @@ class RenewPasswordController extends Controller { } /** - * @PublicPage - * @UseSession - * * @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') { + if ($this->config->getUserValue($user, 'user_ldap', 'needsPasswordReset') !== 'true') { return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.login.showLoginForm')); } $args = !is_null($user) ? ['user' => $user] : []; @@ -140,11 +115,11 @@ class RenewPasswordController extends Controller { ]); 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->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)); @@ -163,12 +138,11 @@ class RenewPasswordController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * * @return RedirectResponse */ + #[PublicPage] + #[NoCSRFRequired] + #[UseSession] public function showLoginFormInvalidPassword($user) { $args = !is_null($user) ? ['user' => $user] : []; $this->session->set('loginMessages', [ @@ -176,5 +150,4 @@ class RenewPasswordController extends Controller { ]); 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 index 586a80b9ebe..d0d384c31de 100644 --- a/apps/user_ldap/lib/Exceptions/ConstraintViolationException.php +++ b/apps/user_ldap/lib/Exceptions/ConstraintViolationException.php @@ -1,26 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2017 Roger Szabo <roger.szabo@web.de> - * - * @author Roger Szabo <roger.szabo@web.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 {} +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 index 41fb6c9e713..cd74e918829 100644 --- a/apps/user_ldap/lib/Exceptions/NotOnLDAP.php +++ b/apps/user_ldap/lib/Exceptions/NotOnLDAP.php @@ -1,26 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 {} +class NotOnLDAP extends \Exception { +} diff --git a/apps/user_ldap/lib/FilesystemHelper.php b/apps/user_ldap/lib/FilesystemHelper.php deleted file mode 100644 index e5b0a9ecef3..00000000000 --- a/apps/user_ldap/lib/FilesystemHelper.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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; - -/** - * @brief wraps around static Nextcloud 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 Nextcloud username of the user - */ - public function setup($uid) { - \OC_Util::setupFS($uid); - } -} diff --git a/apps/user_ldap/lib/GroupPluginManager.php b/apps/user_ldap/lib/GroupPluginManager.php index b3064f61a76..9e8ae6805a4 100644 --- a/apps/user_ldap/lib/GroupPluginManager.php +++ b/apps/user_ldap/lib/GroupPluginManager.php @@ -1,42 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2017 EITA Cooperative (eita.org.br) - * - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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; - private $respondToActions = 0; - - private $which = array( + /** @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 @@ -53,10 +40,10 @@ class GroupPluginManager { $respondToActions = $plugin->respondToActions(); $this->respondToActions |= $respondToActions; - foreach($this->which as $action => $v) { + foreach ($this->which as $action => $v) { if ((bool)($respondToActions & $action)) { $this->which[$action] = $plugin; - \OC::$server->getLogger()->debug("Registered action ".$action." to plugin ".get_class($plugin), ['app' => 'user_ldap']); + Server::get(LoggerInterface::class)->debug('Registered action ' . $action . ' to plugin ' . get_class($plugin), ['app' => 'user_ldap']); } } } @@ -85,16 +72,31 @@ class GroupPluginManager { 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 - * @param string $gid Group Id of the group to delete - * @return bool + * * @throws \Exception */ - public function deleteGroup($gid) { + 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.'); @@ -147,7 +149,7 @@ class GroupPluginManager { $plugin = $this->which[GroupInterface::COUNT_USERS]; if ($plugin) { - return $plugin->countUsersInGroup($gid,$search); + return $plugin->countUsersInGroup($gid, $search); } throw new \Exception('No plugin implements countUsersInGroup in this LDAP Backend.'); } diff --git a/apps/user_ldap/lib/Group_LDAP.php b/apps/user_ldap/lib/Group_LDAP.php index 3faa96bc2b8..271cc96afbd 100644 --- a/apps/user_ldap/lib/Group_LDAP.php +++ b/apps/user_ldap/lib/Group_LDAP.php @@ -1,158 +1,159 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Alex Weirig <alex.weirig@technolink.lu> - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author alexweirig <alex.weirig@technolink.lu> - * @author Andreas Fischer <bantu@owncloud.com> - * @author Andreas Pflug <dev@admin4.org> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Frédéric Fortier <frederic.fortier@oronospolytechnique.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nicolas Grekas <nicolas.grekas@gmail.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * @author Xuanwo <xuanwo@yunify.com> - * - * @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; -use OC\Cache\CappedMemoryCache; +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; - -class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLDAP { - protected $enabled = false; - - /** - * @var string[] $cachedGroupMembers array of users with gid as key - */ - protected $cachedGroupMembers; +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[] $cachedGroupsByMember array of groups with uid as key + * @var string $ldapGroupMemberAssocAttr contains the LDAP setting (in lower case) with the same name */ - protected $cachedGroupsByMember; - - /** @var GroupPluginManager */ - protected $groupPluginManager; - - public function __construct(Access $access, GroupPluginManager $groupPluginManager) { - parent::__construct($access); + 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)) { + $gAssoc = $this->access->connection->ldapGroupMemberAssocAttr; + if (!empty($filter) && !empty($gAssoc)) { $this->enabled = true; } $this->cachedGroupMembers = new CappedMemoryCache(); $this->cachedGroupsByMember = new CappedMemoryCache(); - $this->groupPluginManager = $groupPluginManager; + $this->cachedNestedGroups = new CappedMemoryCache(); + $this->logger = Server::get(LoggerInterface::class); + $this->ldapGroupMemberAssocAttr = strtolower((string)$gAssoc); } /** - * is user in group? + * Check if user is 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. + * @throws Exception + * @throws ServerNotAvailableException */ - public function inGroup($uid, $gid) { - if(!$this->enabled) { + public function inGroup($uid, $gid): bool { + if (!$this->enabled) { return false; } - $cacheKey = 'inGroup'.$uid.':'.$gid; + $cacheKey = 'inGroup' . $uid . ':' . $gid; $inGroup = $this->access->connection->getFromCache($cacheKey); - if(!is_null($inGroup)) { + if (!is_null($inGroup)) { return (bool)$inGroup; } $userDN = $this->access->username2dn($uid); - if(isset($this->cachedGroupMembers[$gid])) { - $isInGroup = in_array($userDN, $this->cachedGroupMembers[$gid]); - return $isInGroup; + if (isset($this->cachedGroupMembers[$gid])) { + return in_array($userDN, $this->cachedGroupMembers[$gid]); } - $cacheKeyMembers = 'inGroup-members:'.$gid; + $cacheKeyMembers = 'inGroup-members:' . $gid; $members = $this->access->connection->getFromCache($cacheKeyMembers); - if(!is_null($members)) { + if (!is_null($members)) { $this->cachedGroupMembers[$gid] = $members; - $isInGroup = in_array($userDN, $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) { + if (!$groupDN || !$userDN) { $this->access->connection->writeToCache($cacheKey, false); return false; } //check primary group first - if($gid === $this->getUserPrimaryGroup($userDN)) { + 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); - $members = array_keys($members); // uids are returned as keys - if(!is_array($members) || count($members) === 0) { - $this->access->connection->writeToCache($cacheKey, false); - return false; - } //extra work if we don't get back user DNs - if(strtolower($this->access->connection->ldapGroupMemberAssocAttr) === 'memberuid') { - $dns = array(); - $filterParts = array(); - $bytes = 0; - foreach($members as $mid) { - $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 + 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); - $bytes = 0; - $filterParts = array(); - $users = $this->access->fetchListOfUsers($filter, 'dn', count($filterParts)); - $dns = array_merge($dns, $users); + $search = $this->access->fetchListOfUsers($filter, $requestAttributes, count($filterParts)); + $users = array_merge($users, $search); } - } - if(count($filterParts) > 0) { - $filter = $this->access->combineFilterWithOr($filterParts); - $users = $this->access->fetchListOfUsers($filter, 'dn', count($filterParts)); - $dns = array_merge($dns, $users); - } - $members = $dns; + + // 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); @@ -164,20 +165,20 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * @param string $dnGroup - * @return array + * 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. * - * 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($dnGroup) { - $dynamicGroupMemberURL = strtolower($this->access->connection->ldapDynamicGroupMemberURL); + public function getDynamicGroupMembers(string $dnGroup): array { + $dynamicGroupMemberURL = strtolower((string)$this->access->connection->ldapDynamicGroupMemberURL); if (empty($dynamicGroupMemberURL)) { - return array(); + return []; } - $dynamicMembers = array(); + $dynamicMembers = []; $memberURLs = $this->access->readAttribute( $dnGroup, $dynamicGroupMemberURL, @@ -190,101 +191,162 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD $pos = strpos($memberURLs[0], '('); if ($pos !== false) { $memberUrlFilter = substr($memberURLs[0], $pos); - $foundMembers = $this->access->searchUsers($memberUrlFilter,'dn'); - $dynamicMembers = array(); - foreach($foundMembers as $value) { + $foundMembers = $this->access->searchUsers($memberUrlFilter, ['dn']); + $dynamicMembers = []; + foreach ($foundMembers as $value) { $dynamicMembers[$value['dn'][0]] = 1; } } else { - \OCP\Util::writeLog('user_ldap', 'No search filter found on member url '. - 'of group ' . $dnGroup, \OCP\Util::DEBUG); + $this->logger->debug('No search filter found on member url of group {dn}', + [ + 'app' => 'user_ldap', + 'dn' => $dnGroup, + ] + ); } } return $dynamicMembers; } /** - * @param string $dnGroup - * @param array|null &$seen - * @return array|mixed|null + * Get group members from dn. + * @psalm-param array<string, bool> $seen List of DN that have already been processed. + * @throws ServerNotAvailableException */ - private function _groupMembers($dnGroup, &$seen = null) { - if ($seen === null) { - $seen = array(); - } - $allMembers = array(); - if (array_key_exists($dnGroup, $seen)) { - // avoid loops - return array(); + 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; + $cacheKey = '_groupMembers' . $dnGroup; $groupMembers = $this->access->connection->getFromCache($cacheKey); - if(!is_null($groupMembers)) { + if ($groupMembers !== null) { return $groupMembers; } - $seen[$dnGroup] = 1; - $members = $this->access->readAttribute($dnGroup, $this->access->connection->ldapGroupMemberAssocAttr, - $this->access->connection->ldapGroupFilter); + + 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)) { - foreach ($members as $memberDN) { - $allMembers[$memberDN] = 1; - $nestedGroups = $this->access->connection->ldapNestedGroups; - if (!empty($nestedGroups)) { - $subMembers = $this->_groupMembers($memberDN, $seen); - if ($subMembers) { - $allMembers = array_merge($allMembers, $subMembers); + 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 = array_merge($allMembers, $this->getDynamicGroupMembers($dnGroup)); + $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(); + } - $this->access->connection->writeToCache($cacheKey, $allMembers); return $allMembers; } /** - * @param string $DN - * @param array|null &$seen - * @return array + * @return string[] + * @throws ServerNotAvailableException */ - private function _getGroupDNsFromMemberOf($DN, &$seen = null) { - if ($seen === null) { - $seen = array(); - } - if (array_key_exists($DN, $seen)) { - // avoid loops - return array(); - } - $seen[$DN] = 1; - $groups = $this->access->readAttribute($DN, 'memberOf'); - if (!is_array($groups)) { - return array(); - } - $groups = $this->access->groupsMatchFilter($groups); - $allGroups = $groups; - $nestedGroups = $this->access->connection->ldapNestedGroups; - if (intval($nestedGroups) === 1) { - foreach ($groups as $group) { - $subGroups = $this->_getGroupDNsFromMemberOf($group, $seen); - $allGroups = array_merge($allGroups, $subGroups); + 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; } } - return $allGroups; + + // 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 an ownCloud internal name - * @param string $gid as given by gidNumber on POSIX LDAP - * @param string $dn a DN that belongs to the same domain as the group - * @return string|bool + * Translates a gidNumber into the Nextcloud internal name. + * + * @return string|false The nextcloud internal name. + * @throws Exception + * @throws ServerNotAvailableException */ - public function gidNumber2Name($gid, $dn) { + public function gidNumber2Name(string $gid, string $dn) { $cacheKey = 'gidNumberToName' . $gid; $groupName = $this->access->connection->getFromCache($cacheKey); - if(!is_null($groupName) && isset($groupName)) { + if (!is_null($groupName) && isset($groupName)) { return $groupName; } @@ -294,14 +356,24 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD 'objectClass=posixGroup', $this->access->connection->ldapGidNumber . '=' . $gid ]); - $result = $this->access->searchGroups($filter, array('dn'), 1); - if(empty($result)) { - return false; + 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 ownCloud group IDs and group names we can + //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); @@ -311,38 +383,35 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * returns the entry's gidNumber - * @param string $dn - * @param string $attribute - * @return string|bool + * @return string|bool The entry's gidNumber + * @throws ServerNotAvailableException */ - private function getEntryGidNumber($dn, $attribute) { + private function getEntryGidNumber(string $dn, string $attribute) { $value = $this->access->readAttribute($dn, $attribute); - if(is_array($value) && !empty($value)) { + if (is_array($value) && !empty($value)) { return $value[0]; } return false; } /** - * returns the group's primary ID - * @param string $dn - * @return string|bool + * @return string|bool The group's gidNumber + * @throws ServerNotAvailableException */ - public function getGroupGidNumber($dn) { + public function getGroupGidNumber(string $dn) { return $this->getEntryGidNumber($dn, 'gidNumber'); } /** - * returns the user's gidNumber - * @param string $dn - * @return string|bool + * @return string|bool The user's gidNumber + * @throws ServerNotAvailableException */ - public function getUserGidNumber($dn) { + public function getUserGidNumber(string $dn) { $gidNumber = false; - if($this->access->connection->hasGidNumber) { + 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) { + if ($gidNumber === false) { $this->access->connection->hasGidNumber = false; } } @@ -350,17 +419,13 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * returns a filter for a "users has specific gid" search or count operation - * - * @param string $groupDN - * @param string $search - * @return string - * @throws \Exception + * @throws ServerNotAvailableException + * @throws Exception */ - private function prepareFilterForUsersHasGidNumber($groupDN, $search = '') { + private function prepareFilterForUsersHasGidNumber(string $groupDN, string $search = ''): string { $groupID = $this->getGroupGidNumber($groupDN); - if($groupID === false) { - throw new \Exception('Not a valid group'); + if ($groupID === false) { + throw new Exception('Not a valid group'); } $filterParts = []; @@ -368,66 +433,46 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD if ($search !== '') { $filterParts[] = $this->access->getFilterPartForUserSearch($search); } - $filterParts[] = $this->access->connection->ldapGidNumber .'=' . $groupID; - - $filter = $this->access->combineFilterWithAnd($filterParts); + $filterParts[] = $this->access->connection->ldapGidNumber . '=' . $groupID; - return $filter; + return $this->access->combineFilterWithAnd($filterParts); } /** - * returns a list of users that have the given group as gid number - * - * @param string $groupDN - * @param string $search - * @param int $limit - * @param int $offset - * @return string[] + * @return array<int,string> A list of users that have the given group as gid number + * @throws ServerNotAvailableException */ - public function getUsersInGidNumber($groupDN, $search = '', $limit = -1, $offset = 0) { + 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->connection->ldapUserDisplayName, 'dn'], + $this->access->userManager->getAttributes(true), $limit, $offset ); return $this->access->nextcloudUserNames($users); - } catch (\Exception $e) { + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { return []; } } /** - * returns the number of users that have the given group as gid number - * - * @param string $groupDN - * @param string $search - * @param int $limit - * @param int $offset - * @return int - */ - public function countUsersInGidNumber($groupDN, $search = '', $limit = -1, $offset = 0) { - try { - $filter = $this->prepareFilterForUsersHasGidNumber($groupDN, $search); - $users = $this->access->countUsers($filter, ['dn'], $limit, $offset); - return (int)$users; - } catch (\Exception $e) { - return 0; - } - } - - /** - * gets the gidNumber of a user - * @param string $dn - * @return string + * @throws ServerNotAvailableException + * @return false|string */ - public function getUserGroupByGid($dn) { + public function getUserGroupByGid(string $dn) { $groupID = $this->getUserGidNumber($dn); - if($groupID !== false) { + if ($groupID !== false) { $groupName = $this->gidNumber2Name($groupID, $dn); - if($groupName !== false) { + if ($groupName !== false) { return $groupName; } } @@ -436,77 +481,61 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * translates a primary group ID into an Nextcloud internal name - * @param string $gid as given by primaryGroupID on AD - * @param string $dn a DN that belongs to the same domain as the group - * @return string|bool + * Translates a primary group ID into an Nextcloud internal name + * + * @return string|false + * @throws Exception + * @throws ServerNotAvailableException */ - public function primaryGroupID2Name($gid, $dn) { - $cacheKey = 'primaryGroupIDtoName'; - $groupNames = $this->access->connection->getFromCache($cacheKey); - if(!is_null($groupNames) && isset($groupNames[$gid])) { - return $groupNames[$gid]; + 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) { + if ($domainObjectSid === false) { return false; } //we need to get the DN from LDAP - $filter = $this->access->combineFilterWithAnd(array( + $filter = $this->access->combineFilterWithAnd([ $this->access->connection->ldapGroupFilter, 'objectsid=' . $domainObjectSid . '-' . $gid - )); - $result = $this->access->searchGroups($filter, array('dn'), 1); - if(empty($result)) { - return false; - } - $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 $this->getNameOfGroup($filter, $cacheKey) ?? false; } /** - * returns the entry's primary group ID - * @param string $dn - * @param string $attribute - * @return string|bool + * @return string|false The entry's group Id + * @throws ServerNotAvailableException */ - private function getEntryGroupID($dn, $attribute) { + private function getEntryGroupID(string $dn, string $attribute) { $value = $this->access->readAttribute($dn, $attribute); - if(is_array($value) && !empty($value)) { + if (is_array($value) && !empty($value)) { return $value[0]; } return false; } /** - * returns the group's primary ID - * @param string $dn - * @return string|bool + * @return string|false The entry's primary group Id + * @throws ServerNotAvailableException */ - public function getGroupPrimaryGroupID($dn) { + public function getGroupPrimaryGroupID(string $dn) { return $this->getEntryGroupID($dn, 'primaryGroupToken'); } /** - * returns the user's primary group ID - * @param string $dn - * @return string|bool + * @return string|false + * @throws ServerNotAvailableException */ - public function getUserPrimaryGroupIDs($dn) { + public function getUserPrimaryGroupIDs(string $dn) { $primaryGroupID = false; - if($this->access->connection->hasPrimaryGroups) { + if ($this->access->connection->hasPrimaryGroups) { $primaryGroupID = $this->getEntryGroupID($dn, 'primaryGroupID'); - if($primaryGroupID === false) { + if ($primaryGroupID === false) { $this->access->connection->hasPrimaryGroups = false; } } @@ -514,17 +543,13 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * returns a filter for a "users in primary group" search or count operation - * - * @param string $groupDN - * @param string $search - * @return string - * @throws \Exception + * @throws Exception + * @throws ServerNotAvailableException */ - private function prepareFilterForUsersInPrimaryGroup($groupDN, $search = '') { + private function prepareFilterForUsersInPrimaryGroup(string $groupDN, string $search = ''): string { $groupID = $this->getGroupPrimaryGroupID($groupDN); - if($groupID === false) { - throw new \Exception('Not a valid group'); + if ($groupID === false) { + throw new Exception('Not a valid group'); } $filterParts = []; @@ -534,64 +559,64 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } $filterParts[] = 'primaryGroupID=' . $groupID; - $filter = $this->access->combineFilterWithAnd($filterParts); - - return $filter; + return $this->access->combineFilterWithAnd($filterParts); } /** - * returns a list of users that have the given group as primary group - * - * @param string $groupDN - * @param string $search - * @param int $limit - * @param int $offset - * @return string[] + * @throws ServerNotAvailableException + * @return array<int,string> */ - public function getUsersInPrimaryGroup($groupDN, $search = '', $limit = -1, $offset = 0) { + 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, - array($this->access->connection->ldapUserDisplayName, 'dn'), + $this->access->userManager->getAttributes(true), $limit, $offset ); return $this->access->nextcloudUserNames($users); - } catch (\Exception $e) { - return array(); + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { + return []; } } /** - * returns the number of users that have the given group as primary group - * - * @param string $groupDN - * @param string $search - * @param int $limit - * @param int $offset - * @return int + * @throws ServerNotAvailableException */ - public function countUsersInPrimaryGroup($groupDN, $search = '', $limit = -1, $offset = 0) { + 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, array('dn'), $limit, $offset); + $users = $this->access->countUsers($filter, ['dn'], $limit, $offset); return (int)$users; - } catch (\Exception $e) { + } catch (ServerNotAvailableException $e) { + throw $e; + } catch (Exception $e) { return 0; } } /** - * gets the primary group of a user - * @param string $dn - * @return string + * @return string|false + * @throws ServerNotAvailableException */ - public function getUserPrimaryGroup($dn) { + public function getUserPrimaryGroup(string $dn) { $groupID = $this->getUserPrimaryGroupIDs($dn); - if($groupID !== false) { + if ($groupID !== false) { $groupName = $this->primaryGroupID2Name($groupID, $dn); - if($groupName !== false) { + if ($groupName !== false) { return $groupName; } } @@ -599,29 +624,63 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD 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) ?? []; + } + /** - * Get all groups a user belongs to - * @param string $uid Name of the user - * @return array with group names - * * 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) { - if(!$this->enabled) { - return array(); + public function getUserGroups($uid): array { + if (!$this->enabled) { + return []; } - $cacheKey = 'getUserGroups'.$uid; + $ncUid = $uid; + + $cacheKey = 'getUserGroups' . $uid; $userGroups = $this->access->connection->getFromCache($cacheKey); - if(!is_null($userGroups)) { + 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, array()); - return array(); + if (!$userDN) { + $this->access->connection->writeToCache($cacheKey, []); + return []; } $groups = []; @@ -633,14 +692,14 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD if (!empty($dynamicGroupMemberURL)) { // look through dynamic groups to add them to the result array if needed $groupsToMatch = $this->access->fetchListOfGroups( - $this->access->connection->ldapGroupFilter,array('dn',$dynamicGroupMemberURL)); - foreach($groupsToMatch as $dynamicGroup) { - if (!array_key_exists($dynamicGroupMemberURL, $dynamicGroup)) { + $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); + $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( @@ -651,15 +710,19 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD if ($userMatch !== false) { // match found so this user is in this group $groupName = $this->access->dn2groupname($dynamicGroup['dn'][0]); - if(is_string($groupName)) { + 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 { - \OCP\Util::writeLog('user_ldap', 'No search filter found on member url '. - 'of group ' . print_r($dynamicGroup, true), \OCP\Util::DEBUG); + $this->logger->debug('No search filter found on member url of group {dn}', + [ + 'app' => 'user_ldap', + 'dn' => $dynamicGroup, + ] + ); } } } @@ -667,106 +730,126 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD // 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(intval($this->access->connection->hasMemberOfFilterSupport) === 1 - && intval($this->access->connection->useMemberOfToDetectMembership) === 1 - && strtolower($this->access->connection->ldapGroupMemberAssocAttr) !== 'memberuid' - ) { + if ((int)$this->access->connection->hasMemberOfFilterSupport === 1 + && (int)$this->access->connection->useMemberOfToDetectMembership === 1 + && $this->ldapGroupMemberAssocAttr !== 'memberuid' + && $this->ldapGroupMemberAssocAttr !== 'zimbramailforwardingaddress') { $groupDNs = $this->_getGroupDNsFromMemberOf($userDN); - if (is_array($groupDNs)) { - 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; - } + 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; - if($primaryGroup !== false) { - $groups[] = $primaryGroup; - } - if($gidGroupName !== false) { - $groups[] = $gidGroupName; + default: + // just in case + $uid = $userDN; + break; } - $this->access->connection->writeToCache($cacheKey, $groups); - return $groups; - } - //uniqueMember takes DN, memberuid the uid, so we need to distinguish - if((strtolower($this->access->connection->ldapGroupMemberAssocAttr) === 'uniquemember') - || (strtolower($this->access->connection->ldapGroupMemberAssocAttr) === 'member') - ) { - $uid = $userDN; - } else if(strtolower($this->access->connection->ldapGroupMemberAssocAttr) === 'memberuid') { - $result = $this->access->readAttribute($userDN, 'uid'); - if ($result === false) { - \OCP\Util::writeLog('user_ldap', 'No uid attribute found for DN ' . $userDN . ' on '. - $this->access->connection->ldapHost, \OCP\Util::DEBUG); + if ($uid !== false) { + $groupsByMember = array_values($this->getGroupsByMember($uid)); + $groupsByMember = $this->access->nextcloudGroupNames($groupsByMember); + $groups = array_merge($groups, $groupsByMember); } - $uid = $result[0]; - } else { - // just in case - $uid = $userDN; } - if(isset($this->cachedGroupsByMember[$uid])) { - $groups = array_merge($groups, $this->cachedGroupsByMember[$uid]); - } else { - $groupsByMember = array_values($this->getGroupsByMember($uid)); - $groupsByMember = $this->access->nextcloudGroupNames($groupsByMember); - $this->cachedGroupsByMember[$uid] = $groupsByMember; - $groups = array_merge($groups, $groupsByMember); - } - - if($primaryGroup !== false) { + if ($primaryGroup !== false) { $groups[] = $primaryGroup; } - if($gidGroupName !== false) { + if ($gidGroupName !== false) { $groups[] = $gidGroupName; } - $groups = array_unique($groups, SORT_LOCALE_STRING); + 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; } /** - * @param string $dn - * @param array|null &$seen - * @return array + * @return array[] + * @throws ServerNotAvailableException */ - private function getGroupsByMember($dn, &$seen = null) { - if ($seen === null) { - $seen = array(); - } - $allGroups = array(); - if (array_key_exists($dn, $seen)) { - // avoid loops - return array(); + private function getGroupsByMember(string $dn, array &$seen = []): array { + if (isset($seen[$dn])) { + return []; } $seen[$dn] = true; - $filter = $this->access->combineFilterWithAnd(array( - $this->access->connection->ldapGroupFilter, - $this->access->connection->ldapGroupMemberAssocAttr.'='.$dn - )); + + 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, - array($this->access->connection->ldapGroupDisplayName, 'dn')); - if (is_array($groups)) { - foreach ($groups as $groupobj) { - $groupDN = $groupobj['dn'][0]; - $allGroups[$groupDN] = $groupobj; - $nestedGroups = $this->access->connection->ldapNestedGroups; - if (!empty($nestedGroups)) { - $supergroups = $this->getGroupsByMember($groupDN, $seen); - if (is_array($supergroups) && (count($supergroups)>0)) { - $allGroups = array_merge($allGroups, $supergroups); - } - } + [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; } - return $allGroups; + + $visibleGroups = $this->filterValidGroups($allGroups); + $this->cachedGroupsByMember[$dn] = $visibleGroups; + return $visibleGroups; } /** @@ -776,84 +859,115 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD * @param string $search * @param int $limit * @param int $offset - * @return array with user ids + * @return array<int,string> user ids + * @throws Exception + * @throws ServerNotAvailableException */ public function usersInGroup($gid, $search = '', $limit = -1, $offset = 0) { - if(!$this->enabled) { - return array(); + if (!$this->enabled) { + return []; } - if(!$this->groupExists($gid)) { - return array(); + if (!$this->groupExists($gid)) { + return []; } $search = $this->access->escapeFilterPart($search, true); - $cacheKey = 'usersInGroup-'.$gid.'-'.$search.'-'.$limit.'-'.$offset; + $cacheKey = 'usersInGroup-' . $gid . '-' . $search . '-' . $limit . '-' . $offset; // check for cache of the exact query $groupUsers = $this->access->connection->getFromCache($cacheKey); - if(!is_null($groupUsers)) { + 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 = $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; } - if($limit === -1) { - $limit = null; - } $groupDN = $this->access->groupname2dn($gid); - if(!$groupDN) { - // group couldn't be found, return empty resultset - $this->access->connection->writeToCache($cacheKey, array()); - return array(); + 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 = array_keys($this->_groupMembers($groupDN)); - if(!$members && empty($posixGroupUsers) && empty($primaryUsers)) { + $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 = array(); - $isMemberUid = (strtolower($this->access->connection->ldapGroupMemberAssocAttr) === 'memberuid'); + $groupUsers = []; $attrs = $this->access->userManager->getAttributes(true); - foreach($members as $member) { - if($isMemberUid) { - //we got uids, need to get their DNs to 'translate' them to user names - $filter = $this->access->combineFilterWithAnd(array( - str_replace('%uid', $member, $this->access->connection->ldapLoginFilter), - $this->access->getFilterPartForUserSearch($search) - )); - $ldap_users = $this->access->fetchListOfUsers($filter, $attrs, 1); - if(count($ldap_users) < 1) { - continue; - } - $groupUsers[] = $this->access->dn2username($ldap_users[0]['dn'][0]); - } else { - //we got DNs, check if we need to filter by search or we can give back all of them - if ($search !== '') { - if(!$this->access->readAttribute($member, - $this->access->connection->ldapUserDisplayName, - $this->access->getFilterPartForUserSearch($search))) { - continue; + 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; } - } - // dn2username will also check if the users belong to the allowed base - if($ocname = $this->access->dn2username($member)) { - $groupUsers[] = $ocname; - } + $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); + $this->access->connection->writeToCache('usersInGroup-' . $gid . '-' . $search, $groupUsers); $groupUsers = array_slice($groupUsers, $offset, $limit); $this->access->connection->writeToCache($cacheKey, $groupUsers); @@ -863,34 +977,37 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD /** * 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)) { + $cacheKey = 'countUsersInGroup-' . $gid . '-' . $search; + if (!$this->enabled || !$this->groupExists($gid)) { return false; } $groupUsers = $this->access->connection->getFromCache($cacheKey); - if(!is_null($groupUsers)) { + if (!is_null($groupUsers)) { return $groupUsers; } $groupDN = $this->access->groupname2dn($gid); - if(!$groupDN) { + if (!$groupDN) { // group couldn't be found, return empty result set $this->access->connection->writeToCache($cacheKey, false); return false; } - $members = array_keys($this->_groupMembers($groupDN)); + $members = $this->_groupMembers($groupDN); $primaryUserCount = $this->countUsersInPrimaryGroup($groupDN, ''); - if(!$members && $primaryUserCount === 0) { + if (!$members && $primaryUserCount === 0) { //in case users could not be retrieved, return empty result set $this->access->connection->writeToCache($cacheKey, false); return false; @@ -902,9 +1019,9 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD return $groupUsers; } $search = $this->access->escapeFilterPart($search, true); - $isMemberUid = - (strtolower($this->access->connection->ldapGroupMemberAssocAttr) - === 'memberuid'); + $isMemberUid + = ($this->ldapGroupMemberAssocAttr === 'memberuid' + || $this->ldapGroupMemberAssocAttr === 'zimbramailforwardingaddress'); //we need to apply the search filter //alternatives that need to be checked: @@ -914,29 +1031,34 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD // and let it count. //For now this is not important, because the only use of this method //does not supply a search string - $groupUsers = array(); - foreach($members as $member) { - if($isMemberUid) { + $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(array( + $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) { + ]); + $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, + 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($ocname = $this->access->dn2username($member)) { - $groupUsers[] = $ocname; + if ($ncGroupId = $this->access->dn2username($member)) { + $groupUsers[] = $ncGroupId; } } } @@ -948,42 +1070,45 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * get a list of all groups + * get a list of all groups using a paged search * * @param string $search - * @param $limit + * @param int $limit * @param int $offset * @return array with group names * - * Returns a list with all groups (used by getGroups) + * 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 */ - protected function getGroupsChunk($search = '', $limit = -1, $offset = 0) { - if(!$this->enabled) { - return array(); + public function getGroups($search = '', $limit = -1, $offset = 0) { + if (!$this->enabled) { + return []; } - $cacheKey = 'getGroups-'.$search.'-'.$limit.'-'.$offset; + $search = $this->access->escapeFilterPart($search, true); + $cacheKey = 'getGroups-' . $search . '-' . $limit . '-' . $offset; //Check cache before driving unnecessary searches - \OCP\Util::writeLog('user_ldap', 'getGroups '.$cacheKey, \OCP\Util::DEBUG); $ldap_groups = $this->access->connection->getFromCache($cacheKey); - if(!is_null($ldap_groups)) { + 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) { + if ($limit <= 0) { $limit = null; } - $filter = $this->access->combineFilterWithAnd(array( + $filter = $this->access->combineFilterWithAnd([ $this->access->connection->ldapGroupFilter, $this->access->getFilterPartForGroupSearch($search) - )); - \OCP\Util::writeLog('user_ldap', 'getGroups Filter '.$filter, \OCP\Util::DEBUG); + ]); $ldap_groups = $this->access->fetchListOfGroups($filter, - array($this->access->connection->ldapGroupDisplayName, 'dn'), - $limit, - $offset); + [$this->access->connection->ldapGroupDisplayName, 'dn'], + $limit, + $offset); $ldap_groups = $this->access->nextcloudGroupNames($ldap_groups); $this->access->connection->writeToCache($cacheKey, $ldap_groups); @@ -991,103 +1116,98 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD } /** - * get a list of all groups using a paged search - * - * @param string $search - * @param int $limit - * @param int $offset - * @return array with group names + * check if a group exists * - * 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) - */ - public function getGroups($search = '', $limit = -1, $offset = 0) { - if(!$this->enabled) { - return array(); - } - $search = $this->access->escapeFilterPart($search, true); - $pagingSize = intval($this->access->connection->ldapPagingSize); - if (!$this->access->connection->hasPagedResultSupport || $pagingSize <= 0) { - return $this->getGroupsChunk($search, $limit, $offset); - } - $maxGroups = 100000; // limit max results (just for safety reasons) - if ($limit > -1) { - $overallLimit = min($limit + $offset, $maxGroups); - } else { - $overallLimit = $maxGroups; - } - $chunkOffset = $offset; - $allGroups = array(); - while ($chunkOffset < $overallLimit) { - $chunkLimit = min($pagingSize, $overallLimit - $chunkOffset); - $ldapGroups = $this->getGroupsChunk($search, $chunkLimit, $chunkOffset); - $nread = count($ldapGroups); - \OCP\Util::writeLog('user_ldap', 'getGroups('.$search.'): read '.$nread.' at offset '.$chunkOffset.' (limit: '.$chunkLimit.')', \OCP\Util::DEBUG); - if ($nread) { - $allGroups = array_merge($allGroups, $ldapGroups); - $chunkOffset += $nread; - } - if ($nread < $chunkLimit) { - break; - } - } - return $allGroups; - } - - /** - * @param string $group + * @param string $gid * @return bool + * @throws ServerNotAvailableException */ - public function groupMatchesFilter($group) { - return (strripos($group, $this->groupSearch) !== false); + public function groupExists($gid) { + return $this->groupExistsOnLDAP($gid, false); } /** - * check if a group exists - * @param string $gid - * @return bool + * Check if a group exists + * + * @throws ServerNotAvailableException */ - public function groupExists($gid) { - $groupExists = $this->access->connection->getFromCache('groupExists'.$gid); - if(!is_null($groupExists)) { - return (bool)$groupExists; + 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('groupExists'.$gid, false); + if (!$dn) { + $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->writeToCache('groupExists'.$gid, false); + if (!$this->access->isDNPartOfBase($dn, $this->access->connection->ldapBaseGroups)) { + $this->access->connection->writeToCache($cacheKey, false); return false; } - $this->access->connection->writeToCache('groupExists'.$gid, true); + //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; } /** - * 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) { - return (bool)((GroupInterface::COUNT_USERS | - $this->groupPluginManager->getImplementedActions()) & $actions); + * @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) { @@ -1096,97 +1216,207 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD /** * create a group + * * @param string $gid * @return bool - * @throws \Exception + * @throws Exception + * @throws ServerNotAvailableException */ public function createGroup($gid) { if ($this->groupPluginManager->implementsActions(GroupInterface::CREATE_GROUP)) { if ($dn = $this->groupPluginManager->createGroup($gid)) { //updates group mapping - $this->access->dn2ocname($dn, $gid, false); - $this->access->connection->writeToCache("groupExists".$gid, true); + $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.'); + throw new Exception('Could not create group in LDAP backend.'); } /** * delete a group + * * @param string $gid gid of the group to delete - * @return bool - * @throws \Exception + * @throws Exception */ - public function deleteGroup($gid) { - if ($this->groupPluginManager->implementsActions(GroupInterface::DELETE_GROUP)) { + public function deleteGroup(string $gid): bool { + if ($this->groupPluginManager->canDeleteGroup()) { if ($ret = $this->groupPluginManager->deleteGroup($gid)) { - #delete group in nextcloud internal db + // Delete group in nextcloud internal db $this->access->getGroupMapper()->unmap($gid); - $this->access->connection->writeToCache("groupExists".$gid, false); + $this->access->connection->writeToCache('groupExists' . $gid, false); } return $ret; } - throw new \Exception('Could not delete group in LDAP backend.'); + + // 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 + * @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.'); + 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 + * @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.'); + 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 + * @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.'); + 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 resource of the LDAP connection + * @return \LDAP\Connection The LDAP connection + * @throws ServerNotAvailableException */ - public function getNewLDAPConnection($gid) { + 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 index ad3fba4092f..f0cdc7a465d 100644 --- a/apps/user_ldap/lib/Group_Proxy.php +++ b/apps/user_ldap/lib/Group_Proxy.php @@ -1,63 +1,61 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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; -class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { - private $backends = array(); - private $refBackend = null; +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; - /** - * Constructor - * @param string[] $serverConfigPrefixes array containing the config Prefixes - */ - public function __construct($serverConfigPrefixes, ILDAPWrapper $ldap, GroupPluginManager $groupPluginManager) { - parent::__construct($ldap); - foreach($serverConfigPrefixes as $configPrefix) { - $this->backends[$configPrefix] = - new \OCA\User_LDAP\Group_LDAP($this->getAccess($configPrefix), $groupPluginManager); - if(is_null($this->refBackend)) { - $this->refBackend = &$this->backends[$configPrefix]; - } - } +/** + * @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 $gid the gid connected to 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 - * @return mixed, the result of the method or false + * @return mixed the result of the method or false */ - protected function walkBackends($gid, $method, $parameters) { + 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(array($backend, $method), $parameters)) { - $this->writeToCache($cacheKey, $configPrefix); + foreach ($this->backends as $configPrefix => $backend) { + if ($result = call_user_func_array([$backend, $method], $parameters)) { + if (!$this->isSingleBackend()) { + $this->writeToCache($cacheKey, $configPrefix); + } return $result; } } @@ -66,27 +64,31 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { /** * Asks the backend connected to the server that supposely takes care of the gid from the request. - * @param string $gid the gid connected to 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 + * @return mixed the result of the method or false */ - protected function callOnLastSeenOn($gid, $method, $parameters, $passOnWhen) { - $cacheKey = $this->getGroupCacheKey($gid);; + 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(array($this->backends[$prefix], $method), $parameters); - if($result === $passOnWhen) { + 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( - array($this->backends[$prefix], 'groupExists'), - array($gid) + [$this->backends[$prefix], 'groupExists'], + [$gid] ); - if(!$groupExists) { + if (!$groupExists) { $this->writeToCache($cacheKey, null); } } @@ -96,8 +98,14 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { 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 @@ -105,38 +113,40 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { * Checks whether the user is member of a group or not. */ public function inGroup($uid, $gid) { - return $this->handleRequest($gid, 'inGroup', array($uid, $gid)); + return $this->handleRequest($gid, 'inGroup', [$uid, $gid]); } /** * Get all groups a user belongs to + * * @param string $uid Name of the user - * @return string[] with group names + * @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) { - $groups = array(); + $this->setup(); - foreach($this->backends as $backend) { + $groups = []; + foreach ($this->backends as $backend) { $backendGroups = $backend->getUserGroups($uid); - if (is_array($backendGroups)) { - $groups = array_merge($groups, $backendGroups); - } + $groups = array_merge($groups, $backendGroups); } - return $groups; + return array_values(array_unique($groups)); } /** * get a list of all users in a group - * @return string[] with user ids + * + * @return array<int,string> user ids */ public function usersInGroup($gid, $search = '', $limit = -1, $offset = 0) { - $users = array(); + $this->setup(); - foreach($this->backends as $backend) { + $users = []; + foreach ($this->backends as $backend) { $backendUsers = $backend->usersInGroup($gid, $search, $limit, $offset); if (is_array($backendUsers)) { $users = array_merge($users, $backendUsers); @@ -152,21 +162,20 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { */ public function createGroup($gid) { return $this->handleRequest( - $gid, 'createGroup', array($gid)); + $gid, 'createGroup', [$gid]); } /** * delete a group - * @param string $gid gid of the group to delete - * @return bool */ - public function deleteGroup($gid) { + public function deleteGroup(string $gid): bool { return $this->handleRequest( - $gid, 'deleteGroup', array($gid)); + $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 @@ -175,11 +184,12 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { */ public function addToGroup($uid, $gid) { return $this->handleRequest( - $gid, 'addToGroup', array($uid, $gid)); + $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 @@ -188,40 +198,59 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { */ public function removeFromGroup($uid, $gid) { return $this->handleRequest( - $gid, 'removeFromGroup', array($uid, $gid)); + $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', array($gid, $search)); + $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', array($gid)); + $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) { - $groups = array(); + $this->setup(); - foreach($this->backends as $backend) { + $groups = []; + foreach ($this->backends as $backend) { $backendGroups = $backend->getGroups($search, $limit, $offset); if (is_array($backendGroups)) { $groups = array_merge($groups, $backendGroups); @@ -233,15 +262,44 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { /** * check if a group exists + * * @param string $gid * @return bool */ public function groupExists($gid) { - return $this->handleRequest($gid, 'groupExists', array($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 * @@ -249,12 +307,14 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { * 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 */ @@ -265,11 +325,36 @@ class Group_Proxy extends Proxy implements \OCP\GroupInterface, IGroupLDAP { /** * Return a new LDAP connection for the specified group. * The connection needs to be closed manually. + * * @param string $gid - * @return resource of the LDAP connection + * @return \LDAP\Connection The LDAP connection */ - public function getNewLDAPConnection($gid) { - return $this->handleRequest($gid, 'getNewLDAPConnection', array($gid)); + 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 index 3157a7ab09d..d3abf04fd1e 100644 --- a/apps/user_ldap/lib/Helper.php +++ b/apps/user_ldap/lib/Helper.php @@ -1,58 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Brice Maron <brice@bmaron.net> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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; -use OCP\IConfig; +use OCP\Cache\CappedMemoryCache; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Server; class Helper { - - /** @var IConfig */ - private $config; - - /** - * Helper constructor. - * - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; + /** @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 + * 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 @@ -69,20 +45,37 @@ class Helper { * except the default (first) server shall be connected to. * */ - public function getServerConfigurationPrefixes($activeConfigurations = false) { + 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) { - if ($activeConfigurations && $this->config->getAppValue('user_ldap', $key, '0') !== '1') { - continue; - } - $len = strlen($key) - strlen($referenceConfigkey); $prefixes[] = substr($key, 0, $len); } + sort($prefixes); + + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes); return $prefixes; } @@ -90,47 +83,46 @@ class Helper { /** * * determines the host for every configured connection - * @return array an array with configprefix as keys + * + * @return array<string,string> an array with configprefix as keys * */ - public function getServerConfigurationHosts() { - $referenceConfigkey = 'ldap_host'; + public function getServerConfigurationHosts(): array { + $prefixes = $this->getServerConfigurationPrefixes(); - $keys = $this->getServersConfig($referenceConfigkey); - - $result = array(); - foreach($keys as $key) { - $len = strlen($key) - strlen($referenceConfigkey); - $prefix = substr($key, 0, $len); - $result[$prefix] = $this->config->getAppValue('user_ldap', $key); + $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 - * - * @return string + * return the next available configuration prefix and register it as used */ - public function getNextServerConfigurationPrefix() { - $serverConnections = $this->getServerConfigurationPrefixes(); - - if(count($serverConnections) === 0) { - return 's01'; + 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); } - sort($serverConnections); - $lastKey = array_pop($serverConnections); - $lastNumber = intval(str_replace('s', '', $lastKey)); - $nextPrefix = 's' . str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT); - return $nextPrefix; + $prefixes[] = $prefix; + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes); + return $prefix; } - private function getServersConfig($value) { + private function getServersConfig(string $value): array { $regex = '/' . $value . '$/S'; - $keys = $this->config->getAppKeys('user_ldap'); + $keys = $this->appConfig->getKeys('user_ldap'); $result = []; foreach ($keys as $key) { if (preg_match($regex, $key) === 1) { @@ -143,115 +135,125 @@ class Helper { /** * 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())) { + $prefixes = $this->getServerConfigurationPrefixes(); + $index = array_search($prefix, $prefixes); + if ($index === false) { return false; } - $saveOtherConfigurations = ''; - if(empty($prefix)) { - $saveOtherConfigurations = 'AND `configkey` NOT LIKE \'s%\''; + $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%'))); } - $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; - } + $deletedRows = $query->executeStatement(); - if($delRows === 0) { - return false; - } + unset($prefixes[$index]); + $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', array_values($prefixes)); - return true; + return $deletedRows !== 0; } /** * 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'); + 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 count($all) !== count($active) || count($all) === 0; + 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)) { + if (!is_array($uinfo)) { return false; } $domain = false; - if(isset($uinfo['host'])) { + if (isset($uinfo['host'])) { $domain = $uinfo['host']; - } else if(isset($uinfo['path'])) { + } elseif (isset($uinfo['path'])) { $domain = $uinfo['path']; } return $domain; } - + /** + * sanitizes a DN received from the LDAP server * - * Set the LDAPProvider in the config + * 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: * - */ - public function setLDAPProvider() { - $current = \OC::$server->getConfig()->getSystemValue('ldapProviderFactory', null); - if(is_null($current)) { - \OC::$server->getConfig()->setSystemValue('ldapProviderFactory', '\\OCA\\User_LDAP\\LDAPProviderFactory'); - } - } - - /** - * sanitizes a DN received from the LDAP server - * @param array $dn the DN in question + * 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 = array(); - foreach($dn as $singleDN) { + 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! - $dn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn); + $sanitizedDn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn); //make comparisons and everything work - $dn = mb_strtolower($dn, 'UTF-8'); + $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 = array( + $replacements = [ '\,' => '\5c2C', '\=' => '\5c3D', '\+' => '\5c2B', @@ -260,17 +262,19 @@ class Helper { '\;' => '\5c3B', '\"' => '\5c22', '\#' => '\5c23', - '(' => '\28', - ')' => '\29', - '*' => '\2A', - ); - $dn = str_replace(array_keys($replacements), array_values($replacements), $dn); - - return $dn; + '(' => '\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 */ @@ -282,30 +286,17 @@ class Helper { * 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 + * @param array $param contains a reference to a $uid var under 'uid' key * @throws \Exception */ - public static function loginName2UserName($param) { - if(!isset($param['uid'])) { + public static function loginName2UserName($param): void { + if (!isset($param['uid'])) { throw new \Exception('key uid is expected to be set in $param'); } - //ain't it ironic? - $helper = new Helper(\OC::$server->getConfig()); - - $configPrefixes = $helper->getServerConfigurationPrefixes(true); - $ldapWrapper = new LDAP(); - $ocConfig = \OC::$server->getConfig(); - $notificationManager = \OC::$server->getNotificationManager(); - - $userSession = \OC::$server->getUserSession(); - $userPluginManager = \OC::$server->query('LDAPUserPluginManager'); - - $userBackend = new User_Proxy( - $configPrefixes, $ldapWrapper, $ocConfig, $notificationManager, $userSession, $userPluginManager - ); - $uid = $userBackend->loginName2UserName($param['uid'] ); - if($uid !== false) { + $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 index 55dd60c16a2..667eb421004 100644 --- a/apps/user_ldap/lib/IGroupLDAP.php +++ b/apps/user_ldap/lib/IGroupLDAP.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2017, EITA Cooperative (eita.org.br) - * - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP; interface IGroupLDAP { @@ -37,8 +20,7 @@ interface IGroupLDAP { /** * Return a new LDAP connection for the specified group. * @param string $gid - * @return resource of the LDAP connection + * @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 index bdad1d7707b..261b9383dc1 100644 --- a/apps/user_ldap/lib/ILDAPGroupPlugin.php +++ b/apps/user_ldap/lib/ILDAPGroupPlugin.php @@ -1,29 +1,11 @@ <?php + /** - * @copyright Copyright (c) 2017 EITA Cooperative (eita.org.br) - * - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP; - interface ILDAPGroupPlugin { /** @@ -82,5 +64,4 @@ interface ILDAPGroupPlugin { * @return array|false */ public function getGroupDetails($gid); - } diff --git a/apps/user_ldap/lib/ILDAPUserPlugin.php b/apps/user_ldap/lib/ILDAPUserPlugin.php index 9250830fc82..80437bef452 100644 --- a/apps/user_ldap/lib/ILDAPUserPlugin.php +++ b/apps/user_ldap/lib/ILDAPUserPlugin.php @@ -1,31 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2017 EITA Cooperative (eita.org.br) - * - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 @@ -40,7 +21,7 @@ interface ILDAPUserPlugin { * * @param string $uid The UID of the user to create * @param string $password The password of the new user - * @return bool + * @return bool|string */ public function createUser($uid, $password); @@ -78,7 +59,7 @@ interface ILDAPUserPlugin { public function setDisplayName($uid, $displayName); /** - * checks whether the user is allowed to change his avatar in Nextcloud + * 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 */ @@ -86,8 +67,7 @@ interface ILDAPUserPlugin { /** * Count the number of users - * @return int|bool + * @return int|false */ public function countUsers(); - } diff --git a/apps/user_ldap/lib/ILDAPWrapper.php b/apps/user_ldap/lib/ILDAPWrapper.php index 52919c48e5f..de2b9c50241 100644 --- a/apps/user_ldap/lib/ILDAPWrapper.php +++ b/apps/user_ldap/lib/ILDAPWrapper.php @@ -1,41 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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; interface ILDAPWrapper { - //LDAP functions in use /** * Bind to LDAP directory - * @param resource $link LDAP link resource + * @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 @@ -48,25 +25,15 @@ interface ILDAPWrapper { * 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 + * @return \LDAP\Connection|false 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 + * @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 @@ -75,22 +42,22 @@ interface ILDAPWrapper { /** * Count the number of entries in a search - * @param resource $link LDAP link resource - * @param resource $result LDAP result resource + * @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 resource $link LDAP link resource + * @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 resource $link LDAP link resource + * @param \LDAP\Connection $link LDAP link resource * @return string error message */ public function error($link); @@ -100,75 +67,75 @@ interface ILDAPWrapper { * @param string $dn * @param int @withAttrib * @return array|false - * @link http://www.php.net/manual/en/function.ldap-explode-dn.php + * @link https://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 + * @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 resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return array containing the results, false on error + * @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 resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return string containing the DN, false on error + * @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 resource $link LDAP link resource - * @param resource $result LDAP result resource - * @return array containing the results, false on error + * @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 resource $link LDAP link resource - * @param resource $result LDAP entry result resource - * @return resource an LDAP search result resource + * @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 resource $link LDAP link resource - * @param array $baseDN The DN of the entry to read from + * @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 resource an LDAP search result resource + * @return \LDAP\Result an LDAP search result resource */ public function read($link, $baseDN, $filter, $attr); /** * Search LDAP tree - * @param resource $link LDAP link resource + * @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 resource|false an LDAP search result resource, false on error + * @return \LDAP\Result|false an LDAP search result resource, false on error */ - public function search($link, $baseDN, $filter, $attr, $attrsOnly = 0, $limit = 0); + 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 resource $link LDAP link resource + * @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 @@ -176,24 +143,31 @@ interface ILDAPWrapper { 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 resource $link LDAP link resource - * @param string $option a defined LDAP Server option - * @param int $value the new value for the option + * @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 resource $link LDAP link resource + * @param \LDAP\Connection $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 + * @param \LDAP\Connection $link LDAP link resource * @return bool true on success, false otherwise */ public function unbind($link); @@ -207,16 +181,10 @@ interface ILDAPWrapper { 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 + * @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 index dcd9d938b29..5e8e29c3adf 100644 --- a/apps/user_ldap/lib/IUserLDAP.php +++ b/apps/user_ldap/lib/IUserLDAP.php @@ -1,44 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, Roger Szabo (roger.szabo@web.de) - * - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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 resource of the LDAP connection + * @return \LDAP\Connection of the LDAP connection */ public function getNewLDAPConnection($uid); diff --git a/apps/user_ldap/lib/Jobs/CleanUp.php b/apps/user_ldap/lib/Jobs/CleanUp.php index 849c30ecd65..76277b43c0b 100644 --- a/apps/user_ldap/lib/Jobs/CleanUp.php +++ b/apps/user_ldap/lib/Jobs/CleanUp.php @@ -1,39 +1,21 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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\Jobs; -use OC\BackgroundJob\TimedJob; use OCA\User_LDAP\Helper; -use OCA\User_LDAP\LDAP; use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\User_LDAP; -use OCA\User_LDAP\User_Proxy; 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 @@ -43,88 +25,75 @@ use OCA\User_LDAP\User\DeletedUsersIndex; * @package OCA\User_LDAP\Jobs; */ class CleanUp extends TimedJob { - /** @var int $limit amount of users that should be checked per run */ - protected $limit = 50; + /** @var ?int $limit amount of users that should be checked per run */ + protected $limit; /** @var int $defaultIntervalMin default interval in minutes */ - protected $defaultIntervalMin = 51; + protected $defaultIntervalMin = 60; - /** @var User_LDAP|User_Proxy $userBackend */ - protected $userBackend; - - /** @var \OCP\IConfig $ocConfig */ + /** @var IConfig $ocConfig */ protected $ocConfig; - /** @var \OCP\IDBConnection $db */ + /** @var IDBConnection $db */ protected $db; /** @var Helper $ldapHelper */ protected $ldapHelper; - /** @var \OCA\User_LDAP\Mapping\UserMapping */ + /** @var UserMapping */ protected $mapping; - /** @var \OCA\User_LDAP\User\DeletedUsersIndex */ - protected $dui; - - public function __construct() { - $minutes = \OC::$server->getConfig()->getSystemValue( - 'ldapUserCleanupInterval', strval($this->defaultIntervalMin)); - $this->setInterval(intval($minutes) * 60); + 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) { + 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'])) { + if (isset($arguments['helper'])) { $this->ldapHelper = $arguments['helper']; } else { - $this->ldapHelper = new Helper(\OC::$server->getConfig()); + $this->ldapHelper = Server::get(Helper::class); } - if(isset($arguments['ocConfig'])) { + if (isset($arguments['ocConfig'])) { $this->ocConfig = $arguments['ocConfig']; } else { - $this->ocConfig = \OC::$server->getConfig(); + $this->ocConfig = Server::get(IConfig::class); } - if(isset($arguments['userBackend'])) { + if (isset($arguments['userBackend'])) { $this->userBackend = $arguments['userBackend']; - } else { - $this->userBackend = new User_Proxy( - $this->ldapHelper->getServerConfigurationPrefixes(true), - new LDAP(), - $this->ocConfig, - \OC::$server->getNotificationManager(), - \OC::$server->getUserSession(), - \OC::$server->query('LDAPUserPluginManager') - ); } - if(isset($arguments['db'])) { + if (isset($arguments['db'])) { $this->db = $arguments['db']; } else { - $this->db = \OC::$server->getDatabaseConnection(); + $this->db = Server::get(IDBConnection::class); } - if(isset($arguments['mapping'])) { + if (isset($arguments['mapping'])) { $this->mapping = $arguments['mapping']; } else { - $this->mapping = new UserMapping($this->db); + $this->mapping = Server::get(UserMapping::class); } - if(isset($arguments['deletedUsersIndex'])) { + if (isset($arguments['deletedUsersIndex'])) { $this->dui = $arguments['deletedUsersIndex']; - } else { - $this->dui = new DeletedUsersIndex( - $this->ocConfig, $this->db, $this->mapping); } } @@ -132,19 +101,13 @@ class CleanUp extends TimedJob { * makes the background job do its work * @param array $argument */ - public function run($argument) { + public function run($argument): void { $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); + if (!$this->isCleanUpAllowed()) { return; } + $users = $this->mapping->getList($this->getOffset(), $this->getChunkSize()); $resetOffset = $this->isOffsetResetNecessary(count($users)); $this->checkUsers($users); $this->setOffset($resetOffset); @@ -152,46 +115,40 @@ class CleanUp extends TimedJob { /** * checks whether next run should start at 0 again - * @param int $resultCount - * @return bool */ - public function isOffsetResetNecessary($resultCount) { - return ($resultCount < $this->limit) ? true : false; + public function isOffsetResetNecessary(int $resultCount): bool { + return $resultCount < $this->getChunkSize(); } /** * checks whether cleaning up LDAP users is allowed - * @return bool */ - public function isCleanUpAllowed() { + public function isCleanUpAllowed(): bool { try { - if($this->ldapHelper->haveDisabledConfigurations()) { + if ($this->ldapHelper->haveDisabledConfigurations()) { return false; } } catch (\Exception $e) { return false; } - $enabled = $this->isCleanUpEnabled(); - - return $enabled; + return $this->isCleanUpEnabled(); } /** * checks whether clean up is enabled by configuration - * @return bool */ - private function isCleanUpEnabled() { + private function isCleanUpEnabled(): bool { return (bool)$this->ocConfig->getSystemValue( - 'ldapUserCleanupInterval', strval($this->defaultIntervalMin)); + 'ldapUserCleanupInterval', (string)$this->defaultIntervalMin); } /** * checks users whether they are still existing * @param array $users result from getMappedUsers() */ - private function checkUsers(array $users) { - foreach($users as $user) { + private function checkUsers(array $users): void { + foreach ($users as $user) { $this->checkUser($user); } } @@ -200,8 +157,8 @@ class CleanUp extends TimedJob { * checks whether a user is still existing in LDAP * @param string[] $user */ - private function checkUser(array $user) { - if($this->userBackend->userExistsOnLDAP($user['name'])) { + private function checkUser(array $user): void { + if ($this->userBackend->userExistsOnLDAP($user['name'])) { //still available, all good return; @@ -212,28 +169,28 @@ class CleanUp extends TimedJob { /** * gets the offset to fetch users from the mappings table - * @return int */ - private function getOffset() { - return intval($this->ocConfig->getAppValue('user_ldap', 'cleanUpJobOffset', 0)); + 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($reset = false) { - $newOffset = $reset ? 0 : - $this->getOffset() + $this->limit; - $this->ocConfig->setAppValue('user_ldap', 'cleanUpJobOffset', $newOffset); + 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) - * @return int */ - public function getChunkSize() { + 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 index 0cc0be7d3ca..26888ae96ae 100644 --- a/apps/user_ldap/lib/Jobs/Sync.php +++ b/apps/user_ldap/lib/Jobs/Sync.php @@ -1,88 +1,64 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\User_LDAP\Jobs; -use OC\BackgroundJob\TimedJob; use OC\ServerNotAvailableException; -use OCA\User_LDAP\Access; use OCA\User_LDAP\AccessFactory; use OCA\User_LDAP\Configuration; -use OCA\User_LDAP\Connection; use OCA\User_LDAP\ConnectionFactory; -use OCA\User_LDAP\FilesystemHelper; use OCA\User_LDAP\Helper; use OCA\User_LDAP\LDAP; -use OCA\User_LDAP\LogWrapper; use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\User\Manager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IAvatarManager; use OCP\IConfig; use OCP\IDBConnection; -use OCP\Image; use OCP\IUserManager; use OCP\Notification\IManager; +use Psr\Log\LoggerInterface; class Sync extends TimedJob { - const MAX_INTERVAL = 12 * 60 * 60; // 12h - const MIN_INTERVAL = 30 * 60; // 30min - /** @var Helper */ - protected $ldapHelper; - /** @var LDAP */ - protected $ldap; - /** @var Manager */ - protected $userManager; - /** @var UserMapping */ - protected $mapper; - /** @var IConfig */ - protected $config; - /** @var IAvatarManager */ - protected $avatarManager; - /** @var IDBConnection */ - protected $dbc; - /** @var IUserManager */ - protected $ncUserManager; - /** @var IManager */ - protected $notificationManager; - /** @var ConnectionFactory */ - protected $connectionFactory; - /** @var AccessFactory */ - protected $accessFactory; - - public function __construct() { + 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( - \OC::$server->getConfig()->getAppValue( + (int)$this->config->getAppValue( 'user_ldap', 'background_sync_interval', - self::MIN_INTERVAL + (string)self::MIN_INTERVAL ) ); + $this->ldap = new LDAP($this->config->getSystemValueString('ldap_log_file')); } /** - * updates the interval + * Updates the interval * - * the idea is to adjust the interval depending on the amount of known users + * 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. */ @@ -95,17 +71,16 @@ class Sync extends TimedJob { $interval = floor(24 * 60 * 60 / $runsPerDay); $interval = min(max($interval, self::MIN_INTERVAL), self::MAX_INTERVAL); - $this->config->setAppValue('user_ldap', 'background_sync_interval', $interval); + $this->config->setAppValue('user_ldap', 'background_sync_interval', (string)$interval); } /** * returns the smallest configured paging size - * @return int */ - protected function getMinPagingSize() { + protected function getMinPagingSize(): int { $configKeys = $this->config->getAppKeys('user_ldap'); - $configKeys = array_filter($configKeys, function($key) { - return strpos($key, 'ldap_paging_size') !== false; + $configKeys = array_filter($configKeys, function ($key) { + return str_contains($key, 'ldap_paging_size'); }); $minPagingSize = null; foreach ($configKeys as $configKey) { @@ -119,24 +94,22 @@ class Sync extends TimedJob { * @param array $argument */ public function run($argument) { - $this->setArgument($argument); - $isBackgroundJobModeAjax = $this->config - ->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'ajax'; - if($isBackgroundJobModeAjax) { + ->getAppValue('core', 'backgroundjobs_mode', 'ajax') === 'ajax'; + if ($isBackgroundJobModeAjax) { return; } $cycleData = $this->getCycle(); - if($cycleData === null) { + if ($cycleData === null) { $cycleData = $this->determineNextCycle(); - if($cycleData === null) { + if ($cycleData === null) { $this->updateInterval(); return; } } - if(!$this->qualifiesToRun($cycleData)) { + if (!$this->qualifiesToRun($cycleData)) { $this->updateInterval(); return; } @@ -155,52 +128,50 @@ class Sync extends TimedJob { } /** - * @param array $cycleData + * @param array{offset: int, prefix: string} $cycleData * @return bool whether more results are expected from the same configuration */ - public function runCycle($cycleData) { + public function runCycle(array $cycleData): bool { $connection = $this->connectionFactory->get($cycleData['prefix']); $access = $this->accessFactory->get($connection); $access->setUserMapper($this->mapper); - $filter = $access->combineFilterWithAnd(array( + $filter = $access->combineFilterWithAnd([ $access->connection->ldapUserFilter, $access->connection->ldapUserDisplayName . '=*', $access->getFilterPartForUserSearch('') - )); + ]); $results = $access->fetchListOfUsers( $filter, $access->userManager->getAttributes(), - $connection->ldapPagingSize, + (int)$connection->ldapPagingSize, $cycleData['offset'], true ); - if((int)$connection->ldapPagingSize === 0) { + 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, + * Returns the info about the current cycle that should be run, if any, * otherwise null - * - * @return array|null */ - public function getCycle() { + public function getCycle(): ?array { $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); - if(count($prefixes) === 0) { + if (count($prefixes) === 0) { return null; } $cycleData = [ - 'prefix' => $this->config->getAppValue('user_ldap', 'background_sync_prefix', null), - 'offset' => (int)$this->config->getAppValue('user_ldap', 'background_sync_offset', 0), + 'prefix' => $this->config->getAppValue('user_ldap', 'background_sync_prefix', 'none'), + 'offset' => (int)$this->config->getAppValue('user_ldap', 'background_sync_offset', '0'), ]; - if( - $cycleData['prefix'] !== null + if ( + $cycleData['prefix'] !== 'none' && in_array($cycleData['prefix'], $prefixes) ) { return $cycleData; @@ -212,30 +183,30 @@ class Sync extends TimedJob { /** * Save the provided cycle information in the DB * - * @param array $cycleData + * @param array{prefix: ?string, offset: int} $cycleData */ - public function setCycle(array $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', $cycleData['offset']); + $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|null $cycleData the old cycle - * @return array|null + * @param ?array{prefix: string, offset: int} $cycleData the old cycle + * @return ?array{prefix: string, offset: int} */ - public function determineNextCycle(array $cycleData = null) { + public function determineNextCycle(?array $cycleData = null): ?array { $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); - if(count($prefixes) === 0) { + 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) { + if ($prefix === null) { return null; } $cycleData['prefix'] = $prefix; @@ -246,140 +217,56 @@ class Sync extends TimedJob { } /** - * Checks whether the provided cycle should be run. Currently only the + * Checks whether the provided cycle should be run. Currently, only the * last configuration change goes into account (at least one hour). * - * @param $cycleData - * @return bool + * @param array{prefix: string} $cycleData */ - public function qualifiesToRun($cycleData) { - $lastChange = $this->config->getAppValue('user_ldap', $cycleData['prefix'] . '_lastChange', 0); - if((time() - $lastChange) > 60 * 30) { + 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 + * Increases the offset of the current cycle for the next run * - * @param $cycleData + * @param array{prefix: string, offset: int} $cycleData */ - protected function increaseOffset($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) - * - * @param string|null $lastPrefix - * @return string|null + * Determines the next configuration prefix based on the last one (if any) */ - protected function getNextPrefix($lastPrefix) { + protected function getNextPrefix(?string $lastPrefix): ?string { $prefixes = $this->ldapHelper->getServerConfigurationPrefixes(true); $noOfPrefixes = count($prefixes); - if($noOfPrefixes === 0) { + if ($noOfPrefixes === 0) { return null; } $i = $lastPrefix === null ? false : array_search($lastPrefix, $prefixes, true); - if($i === false) { + if ($i === false) { $i = -1; } else { $i++; } - if(!isset($prefixes[$i])) { + if (!isset($prefixes[$i])) { $i = 0; } return $prefixes[$i]; } /** - * "fixes" DI - * - * @param array $argument + * Only used in tests */ - public function setArgument($argument) { - if(isset($argument['config'])) { - $this->config = $argument['config']; - } else { - $this->config = \OC::$server->getConfig(); - } - - if(isset($argument['helper'])) { - $this->ldapHelper = $argument['helper']; - } else { - $this->ldapHelper = new Helper($this->config); - } - - if(isset($argument['ldapWrapper'])) { - $this->ldap = $argument['ldapWrapper']; - } else { - $this->ldap = new LDAP(); - } - - if(isset($argument['avatarManager'])) { - $this->avatarManager = $argument['avatarManager']; - } else { - $this->avatarManager = \OC::$server->getAvatarManager(); - } - - if(isset($argument['dbc'])) { - $this->dbc = $argument['dbc']; - } else { - $this->dbc = \OC::$server->getDatabaseConnection(); - } - - if(isset($argument['ncUserManager'])) { - $this->ncUserManager = $argument['ncUserManager']; - } else { - $this->ncUserManager = \OC::$server->getUserManager(); - } - - if(isset($argument['notificationManager'])) { - $this->notificationManager = $argument['notificationManager']; - } else { - $this->notificationManager = \OC::$server->getNotificationManager(); - } - - if(isset($argument['userManager'])) { - $this->userManager = $argument['userManager']; - } else { - $this->userManager = new Manager( - $this->config, - new FilesystemHelper(), - new LogWrapper(), - $this->avatarManager, - new Image(), - $this->dbc, - $this->ncUserManager, - $this->notificationManager - ); - } - - if(isset($argument['mapper'])) { - $this->mapper = $argument['mapper']; - } else { - $this->mapper = new UserMapping($this->dbc); - } - - if(isset($argument['connectionFactory'])) { - $this->connectionFactory = $argument['connectionFactory']; - } else { - $this->connectionFactory = new ConnectionFactory($this->ldap); - } - - if(isset($argument['accessFactory'])) { - $this->accessFactory = $argument['accessFactory']; - } else { - $this->accessFactory = new AccessFactory( - $this->ldap, - $this->userManager, - $this->ldapHelper, - $this->config - ); - } + 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 index c5eff77dbe3..9e72bcd8432 100644 --- a/apps/user_ldap/lib/Jobs/UpdateGroups.php +++ b/apps/user_ldap/lib/Jobs/UpdateGroups.php @@ -1,227 +1,38 @@ <?php + +declare(strict_types=1); + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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\Jobs; -use OCA\User_LDAP\Access; -use OCA\User_LDAP\Connection; -use OCA\User_LDAP\FilesystemHelper; -use OCA\User_LDAP\Helper; -use OCA\User_LDAP\LDAP; -use OCA\User_LDAP\LogWrapper; -use OCA\User_LDAP\Mapping\GroupMapping; -use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\User\Manager; - -class UpdateGroups extends \OC\BackgroundJob\TimedJob { - static private $groupsFromDB; - - static private $groupBE; +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; - public function __construct(){ - $this->interval = self::getRefreshInterval(); +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){ - self::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 \OC::$server->getConfig()->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(\OC::$server->getConfig()); - $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 Manager( - \OC::$server->getConfig(), - new FilesystemHelper(), - new LogWrapper(), - \OC::$server->getAvatarManager(), - new \OCP\Image(), - $dbc, - \OC::$server->getUserManager(), - \OC::$server->getNotificationManager()); - $connector = new Connection($ldapWrapper, $configPrefixes[0]); - $ldapAccess = new Access($connector, $ldapWrapper, $userManager, $helper, \OC::$server->getConfig()); - $groupMapper = new GroupMapping($dbc); - $userMapper = new UserMapping($dbc); - $ldapAccess->setGroupMapper($groupMapper); - $ldapAccess->setUserMapper($userMapper); - self::$groupBE = new \OCA\User_LDAP\Group_LDAP($ldapAccess, \OC::$server->query('LDAPGroupPluginManager')); - } else { - self::$groupBE = new \OCA\User_LDAP\Group_Proxy($configPrefixes, $ldapWrapper, \OC::$server->query('LDAPGroupPluginManager')); - } - - 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; + 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 index bdc2f204225..1cf20c4b939 100644 --- a/apps/user_ldap/lib/LDAP.php +++ b/apps/user_ldap/lib/LDAP.php @@ -1,61 +1,59 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * - * @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; 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 $curFunc = ''; - protected $curArgs = array(); + 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); + } /** - * @param resource $link - * @param string $dn - * @param string $password - * @return bool|mixed + * {@inheritDoc} */ public function bind($link, $dn, $password) { return $this->invokeLDAPMethod('bind', $link, $dn, $password); } /** - * @param string $host - * @param string $port - * @return mixed + * {@inheritDoc} */ public function connect($host, $port) { - if(strpos($host, '://') === false) { + $pos = strpos($host, '://'); + if ($pos === false) { $host = 'ldap://' . $host; + $pos = 4; } - if(strpos($host, ':', strpos($host, '://') + 1) === false) { + if (strpos($host, ':', $pos + 1) === false && !empty($port)) { //ldap_connect ignores port parameter when URLs are passed $host .= ':' . $port; } @@ -63,52 +61,51 @@ class LDAP implements ILDAPWrapper { } /** - * @param resource $link - * @param resource $result - * @param string $cookie - * @return bool|LDAP + * {@inheritDoc} */ - 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(); + 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(); + } - return $result; - } + $cookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'] ?? ''; - /** - * @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); + return $success; } /** - * @param LDAP $link - * @param LDAP $result - * @return mixed + * {@inheritDoc} */ public function countEntries($link, $result) { return $this->invokeLDAPMethod('count_entries', $link, $result); } /** - * @param LDAP $link - * @return integer + * {@inheritDoc} */ public function errno($link) { return $this->invokeLDAPMethod('errno', $link); } /** - * @param LDAP $link - * @return string + * {@inheritDoc} */ public function error($link) { return $this->invokeLDAPMethod('error', $link); @@ -117,114 +114,122 @@ class LDAP implements ILDAPWrapper { /** * Splits DN into its component parts * @param string $dn - * @param int @withAttrib + * @param int $withAttrib * @return array|false - * @link http://www.php.net/manual/en/function.ldap-explode-dn.php + * @link https://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 + * {@inheritDoc} */ public function firstEntry($link, $result) { return $this->invokeLDAPMethod('first_entry', $link, $result); } /** - * @param LDAP $link - * @param LDAP $result - * @return array|mixed + * {@inheritDoc} */ public function getAttributes($link, $result) { return $this->invokeLDAPMethod('get_attributes', $link, $result); } /** - * @param LDAP $link - * @param LDAP $result - * @return mixed|string + * {@inheritDoc} */ public function getDN($link, $result) { return $this->invokeLDAPMethod('get_dn', $link, $result); } /** - * @param LDAP $link - * @param LDAP $result - * @return array|mixed + * {@inheritDoc} */ public function getEntries($link, $result) { return $this->invokeLDAPMethod('get_entries', $link, $result); } /** - * @param LDAP $link - * @param resource $result - * @return mixed + * {@inheritDoc} */ 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 + * {@inheritDoc} */ public function read($link, $baseDN, $filter, $attr) { - return $this->invokeLDAPMethod('read', $link, $baseDN, $filter, $attr); + return $this->invokeLDAPMethod('read', $link, $baseDN, $filter, $attr, 0, -1); } /** - * @param LDAP $link - * @param string $baseDN - * @param string $filter - * @param array $attr - * @param int $attrsOnly - * @param int $limit - * @return mixed + * {@inheritDoc} */ - public function search($link, $baseDN, $filter, $attr, $attrsOnly = 0, $limit = 0) { - return $this->invokeLDAPMethod('search', $link, $baseDN, $filter, $attr, $attrsOnly, $limit); + 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; + } } /** - * @param LDAP $link - * @param string $userDN - * @param string $password - * @return bool + * {@inheritDoc} */ public function modReplace($link, $userDN, $password) { - return $this->invokeLDAPMethod('mod_replace', $link, $userDN, array('userPassword' => $password)); + return $this->invokeLDAPMethod('mod_replace', $link, $userDN, ['userPassword' => $password]); } /** - * @param LDAP $link - * @param string $option - * @param int $value - * @return bool|mixed + * {@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); } /** - * @param LDAP $link - * @return mixed|true + * {@inheritDoc} */ public function startTls($link) { return $this->invokeLDAPMethod('start_tls', $link); } /** - * @param resource $link - * @return bool|mixed + * {@inheritDoc} */ public function unbind($link) { return $this->invokeLDAPMethod('unbind', $link); @@ -239,22 +244,10 @@ class LDAP implements ILDAPWrapper { } /** - * 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 + * {@inheritDoc} */ public function isResource($resource) { - return is_resource($resource); + return is_resource($resource) || is_object($resource); } /** @@ -263,17 +256,16 @@ class LDAP implements ILDAPWrapper { * When using ldap_search we provide an array, in case multiple bases are * configured. Thus, we need to check the array elements. * - * @param $result - * @return bool + * @param mixed $result */ - protected function isResultFalse($result) { - if($result === false) { + protected function isResultFalse(string $functionName, $result): bool { + if ($result === false) { return true; } - if($this->curFunc === 'ldap_search' && is_array($result)) { + if ($functionName === 'ldap_search' && is_array($result)) { foreach ($result as $singleResult) { - if($singleResult === false) { + if ($singleResult === false) { return true; } } @@ -283,16 +275,19 @@ class LDAP implements ILDAPWrapper { } /** + * @param array $arguments * @return mixed */ - protected function invokeLDAPMethod() { - $arguments = func_get_args(); - $func = 'ldap_' . array_shift($arguments); - if(function_exists($func)) { + 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($result)) { - $this->postFunctionCall(); + if ($this->isResultFalse($func, $result)) { + $this->postFunctionCall($func); + } + if ($this->dataCollector !== null) { + $this->dataCollector->stopLastLdapRequest(); } return $result; } @@ -300,64 +295,97 @@ class LDAP implements ILDAPWrapper { } /** - * @param string $functionName - * @param array $args + * Turn resources into string, and removes potentially problematic cookie string to avoid breaking logfiles */ - private function preFunctionCall($functionName, $args) { - $this->curFunc = $functionName; + 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 resource $resource the LDAP Connection resource + * @param \LDAP\Connection $resource the LDAP Connection resource * @throws ConstraintViolationException * @throws ServerNotAvailableException * @throws \Exception */ - private function processLDAPError($resource) { - $errorCode = ldap_errno($resource); - if($errorCode === 0) { - return; - } - $errorMsg = ldap_error($resource); - - if($this->curFunc === 'ldap_get_entries' + 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) { - } else if ($errorCode === 32) { + } elseif ($errorCode === 32) { //for now - } else if ($errorCode === 10) { + } elseif ($errorCode === 10) { //referrals, we switch them off, but then there is AD :) - } else if ($errorCode === -1) { + } elseif ($errorCode === -1) { throw new ServerNotAvailableException('Lost connection to LDAP server.'); - } else if ($errorCode === 52) { + } elseif ($errorCode === 52) { throw new ServerNotAvailableException('LDAP server is shutting down.'); - } else if ($errorCode === 48) { + } elseif ($errorCode === 48) { throw new \Exception('LDAP authentication method rejected', $errorCode); - } else if ($errorCode === 1) { + } elseif ($errorCode === 1) { throw new \Exception('LDAP Operations error', $errorCode); - } else if ($errorCode === 19) { - ldap_get_option($this->curArgs[0], LDAP_OPT_ERROR_STRING, $extended_error); - throw new ConstraintViolationException(!empty($extended_error)?$extended_error:$errorMsg, $errorCode); - } else { - \OC::$server->getLogger()->debug('LDAP error {message} ({code}) after calling {func}', [ - 'app' => 'user_ldap', - 'message' => $errorMsg, - 'code' => $errorCode, - 'func' => $this->curFunc, - ]); + } 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() { - if($this->isResource($this->curArgs[0])) { + private function postFunctionCall(string $functionName): void { + if ($this->isResource($this->curArgs[0])) { $resource = $this->curArgs[0]; - } else if( - $this->curFunc === 'ldap_search' + } elseif ( + $functionName === 'ldap_search' && is_array($this->curArgs[0]) && $this->isResource($this->curArgs[0][0]) ) { @@ -368,9 +396,14 @@ class LDAP implements ILDAPWrapper { return; } - $this->processLDAPError($resource); + $errorCode = ldap_errno($resource); + if ($errorCode === 0) { + return; + } + $errorMsg = ldap_error($resource); + + $this->processLDAPError($resource, $functionName, $errorCode, $errorMsg); - $this->curFunc = ''; $this->curArgs = []; } } diff --git a/apps/user_ldap/lib/LDAPProvider.php b/apps/user_ldap/lib/LDAPProvider.php index 94793980b39..d9750ae3fcf 100644 --- a/apps/user_ldap/lib/LDAPProvider.php +++ b/apps/user_ldap/lib/LDAPProvider.php @@ -1,71 +1,51 @@ <?php + /** - * @copyright Copyright (c) 2016, Roger Szabo (roger.szabo@web.de) - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\LDAP\ILDAPProvider; -use OCP\LDAP\IDeletionFlagSupport; -use OCP\IServerContainer; use OCA\User_LDAP\User\DeletedUsersIndex; +use OCP\IServerContainer; +use OCP\LDAP\IDeletionFlagSupport; +use OCP\LDAP\ILDAPProvider; +use Psr\Log\LoggerInterface; /** - * LDAP provider for pulic access to the LDAP backend. + * LDAP provider for public access to the LDAP backend. */ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { - private $userBackend; private $groupBackend; private $logger; - private $helper; - private $deletedUsersIndex; - + /** * Create new LDAPProvider - * @param \OCP\IServerContainer $serverContainer + * @param IServerContainer $serverContainer * @param Helper $helper * @param DeletedUsersIndex $deletedUsersIndex * @throws \Exception if user_ldap app was not enabled */ - public function __construct(IServerContainer $serverContainer, Helper $helper, DeletedUsersIndex $deletedUsersIndex) { - $this->logger = $serverContainer->getLogger(); - $this->helper = $helper; - $this->deletedUsersIndex = $deletedUsersIndex; + 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']); + 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']); + } + 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; @@ -73,11 +53,11 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { } } - if (!$userBackendFound or !$groupBackendFound) { + 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 @@ -85,11 +65,11 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if translation was unsuccessful */ public function getUserDN($uid) { - if(!$this->userBackend->userExists($uid)){ + if (!$this->userBackend->userExists($uid)) { throw new \Exception('User id not found in LDAP'); } $result = $this->userBackend->getLDAPAccess($uid)->username2dn($uid); - if(!$result){ + if (!$result) { throw new \Exception('Translation to LDAP DN unsuccessful'); } return $result; @@ -102,18 +82,18 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception */ public function getGroupDN($gid) { - if(!$this->groupBackend->groupExists($gid)){ + if (!$this->groupBackend->groupExists($gid)) { throw new \Exception('Group id not found in LDAP'); } $result = $this->groupBackend->getLDAPAccess($gid)->groupname2dn($gid); - if(!$result){ + if (!$result) { throw new \Exception('Translation to LDAP DN unsuccessful'); } - return $result; + return $result; } /** - * Translate a LDAP DN to an internal user name. If there is no mapping between + * 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 @@ -121,12 +101,12 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { */ public function getUserName($dn) { $result = $this->userBackend->dn2UserName($dn); - if(!$result){ + 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 @@ -135,25 +115,25 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { public function DNasBaseParameter($dn) { return $this->helper->DNasBaseParameter($dn); } - + /** * Sanitize a DN received from the LDAP server. - * @param array $dn the DN in question - * @return array the sanitized DN + * @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. + * Return a new LDAP connection resource for the specified user. * The connection must be closed manually. * @param string $uid user id - * @return resource of the LDAP connection + * @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)){ + if (!$this->userBackend->userExists($uid)) { throw new \Exception('User id not found in LDAP'); } return $this->userBackend->getNewLDAPConnection($uid); @@ -163,16 +143,16 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * Return a new LDAP connection resource for the specified user. * The connection must be closed manually. * @param string $gid group id - * @return resource of the LDAP connection + * @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)){ + 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 @@ -180,12 +160,29 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if user id was not found in LDAP */ public function getLDAPBaseUsers($uid) { - if(!$this->userBackend->userExists($uid)){ + if (!$this->userBackend->userExists($uid)) { throw new \Exception('User id not found in LDAP'); - } - return $this->userBackend->getLDAPAccess($uid)->getConnection()->getConfiguration()['ldap_base_users']; + } + $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 @@ -193,19 +190,20 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if user id was not found in LDAP */ public function getLDAPBaseGroups($uid) { - if(!$this->userBackend->userExists($uid)){ + if (!$this->userBackend->userExists($uid)) { throw new \Exception('User id not found in LDAP'); } - return $this->userBackend->getLDAPAccess($uid)->getConnection()->getConfiguration()['ldap_base_groups']; + $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)){ + if (!$this->userBackend->userExists($uid)) { throw new \Exception('User id not found in LDAP'); } $this->userBackend->getLDAPAccess($uid)->getConnection()->clearCache(); @@ -218,12 +216,12 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if user id was not found in LDAP */ public function clearGroupCache($gid) { - if(!$this->groupBackend->groupExists($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 @@ -233,7 +231,7 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { $result = $this->userBackend->dn2UserName($dn); return !$result ? false : true; } - + /** * Flag record for deletion. * @param string $uid user id @@ -241,7 +239,7 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { public function flagRecord($uid) { $this->deletedUsersIndex->markUser($uid); } - + /** * Unflag record for deletion. * @param string $uid user id @@ -257,7 +255,7 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if user id was not found in LDAP */ public function getLDAPDisplayNameField($uid) { - if(!$this->userBackend->userExists($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']; @@ -270,7 +268,7 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { * @throws \Exception if user id was not found in LDAP */ public function getLDAPEmailField($uid) { - if(!$this->userBackend->userExists($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']; @@ -279,14 +277,54 @@ class LDAPProvider implements ILDAPProvider, IDeletionFlagSupport { /** * 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' + * @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)){ + 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 index c57f996cde5..8fad9d52206 100644 --- a/apps/user_ldap/lib/LDAPProviderFactory.php +++ b/apps/user_ldap/lib/LDAPProviderFactory.php @@ -1,62 +1,28 @@ <?php + /** - * @copyright Copyright (c) 2016, Roger Szabo (roger.szabo@web.de) - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\LDAP\ILDAPProviderFactory; use OCP\IServerContainer; -use OCA\User_LDAP\User\DeletedUsersIndex; -use OCA\User_LDAP\Mapping\UserMapping; +use OCP\LDAP\ILDAPProvider; +use OCP\LDAP\ILDAPProviderFactory; class LDAPProviderFactory implements ILDAPProviderFactory { - /** - * Server container - * - * @var IServerContainer - */ - private $serverContainer; - - /** - * Constructor for the LDAP provider factory - * - * @param IServerContainer $serverContainer server container - */ - public function __construct(IServerContainer $serverContainer) { - $this->serverContainer = $serverContainer; + public function __construct( + /** * @var IServerContainer */ + private IServerContainer $serverContainer, + ) { } - - /** - * creates and returns an instance of the ILDAPProvider - * - * @return OCP\LDAP\ILDAPProvider - */ - public function getLDAPProvider() { - $dbConnection = $this->serverContainer->getDatabaseConnection(); - $userMapping = new UserMapping($dbConnection); - return new LDAPProvider($this->serverContainer, new Helper($this->serverContainer->getConfig()), - new DeletedUsersIndex($this->serverContainer->getConfig(), - $dbConnection, $userMapping)); + + 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 index 4c908ee44dd..39b517528e2 100644 --- a/apps/user_ldap/lib/LDAPUtility.php +++ b/apps/user_ldap/lib/LDAPUtility.php @@ -1,38 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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: 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 { - protected $ldap; - /** * constructor, make sure the subclasses call this one! - * @param ILDAPWrapper $ldapWrapper an instance of an ILDAPWrapper + * @param ILDAPWrapper $ldap an instance of an ILDAPWrapper */ - public function __construct(ILDAPWrapper $ldapWrapper) { - $this->ldap = $ldapWrapper; + public function __construct( + protected ILDAPWrapper $ldap, + ) { } } diff --git a/apps/user_ldap/lib/LogWrapper.php b/apps/user_ldap/lib/LogWrapper.php deleted file mode 100644 index af5323565f8..00000000000 --- a/apps/user_ldap/lib/LogWrapper.php +++ /dev/null @@ -1,40 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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; - -/** - * @brief wraps around static Nextcloud 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/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 index f5f56ce03d6..fa10312a915 100644 --- a/apps/user_ldap/lib/Mapping/AbstractMapping.php +++ b/apps/user_ldap/lib/Mapping/AbstractMapping.php @@ -1,60 +1,53 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Aaron Wood <aaronjwood@gmail.com> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\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 -*/ + * 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(); + abstract protected function getTableName(bool $includePrefix = true); /** - * @param \OCP\IDBConnection $dbc + * @param IDBConnection $dbc */ - public function __construct(\OCP\IDBConnection $dbc) { - $this->dbc = $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) { + switch ($col) { case 'ldap_dn': + case 'ldap_dn_hash': case 'owncloud_name': case 'directory_uuid': return true; @@ -65,67 +58,91 @@ abstract class AbstractMapping { /** * 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 + * @throws \Exception */ protected function getXbyY($fetchCol, $compareCol, $search) { - if(!$this->isColNameValid($fetchCol)) { + 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() .'` + FROM `' . $this->getTableName() . '` WHERE `' . $compareCol . '` = ? '); - $res = $query->execute(array($search)); - if($res !== false) { - return $query->fetchColumn(); + try { + $res = $query->execute([$search]); + $data = $res->fetchOne(); + $res->closeCursor(); + return $data; + } catch (Exception $e) { + return false; } - - return false; } /** * Performs a DELETE or UPDATE query to the database. - * @param \Doctrine\DBAL\Driver\Statement $query + * + * @param IPreparedStatement $statement * @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); + 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) { - return $this->getXbyY('ldap_dn', 'owncloud_name', $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) { - $query = $this->dbc->prepare(' + $oldDn = $this->getDnByUUID($uuid); + $statement = $this->dbc->prepare(' UPDATE `' . $this->getTableName() . '` - SET `ldap_dn` = ? + SET `ldap_dn_hash` = ?, `ldap_dn` = ? WHERE `directory_uuid` = ? '); - return $this->modify($query, array($fdn, $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; } /** @@ -137,51 +154,130 @@ abstract class AbstractMapping { * @param $fdn * @return bool */ - public function setUUIDbyDN($uuid, $fdn) { - $query = $this->dbc->prepare(' + public function setUUIDbyDN($uuid, $fdn): bool { + $statement = $this->dbc->prepare(' UPDATE `' . $this->getTableName() . '` SET `directory_uuid` = ? - WHERE `ldap_dn` = ? + WHERE `ldap_dn_hash` = ? '); - return $this->modify($query, [$uuid, $fdn]); + 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) { - return $this->getXbyY('owncloud_name', 'ldap_dn', $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 - * @param string $search - * @param string $prefixMatch - * @param string $postfixMatch + * * @return string[] */ - public function getNamesBySearch($search, $prefixMatch = "", $postfixMatch = "") { - $query = $this->dbc->prepare(' + public function getNamesBySearch(string $search, string $prefixMatch = '', string $postfixMatch = ''): array { + $statement = $this->dbc->prepare(' SELECT `owncloud_name` - FROM `'. $this->getTableName() .'` + FROM `' . $this->getTableName() . '` WHERE `owncloud_name` LIKE ? '); - $res = $query->execute(array($prefixMatch.$this->dbc->escapeLikeParameter($search).$postfixMatch)); - $names = array(); - if($res !== false) { - while($row = $query->fetch()) { - $names[] = $row['owncloud_name']; - } + 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 */ @@ -189,48 +285,53 @@ abstract class AbstractMapping { 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', $dn); + return $this->getXbyY('directory_uuid', 'ldap_dn_hash', $this->getDNHash($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(); + 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) > 255) { - \OC::$server->getLogger()->error( - 'Cannot map, because the DN exceeds 255 characters: {dn}', + if (mb_strlen($fdn) > 4000) { + Server::get(LoggerInterface::class)->error( + 'Cannot map, because the DN exceeds 4000 characters: {dn}', [ 'app' => 'user_ldap', 'dn' => $fdn, @@ -239,14 +340,18 @@ abstract class AbstractMapping { return false; } - $row = array( - 'ldap_dn' => $fdn, - 'owncloud_name' => $name, + $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) { @@ -256,26 +361,65 @@ abstract class AbstractMapping { /** * 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() .'` + $statement = $this->dbc->prepare(' + DELETE FROM `' . $this->getTableName() . '` WHERE `owncloud_name` = ?'); - return $this->modify($query, array($name)); + $dn = array_search($name, $this->cache); + if ($dn !== false) { + unset($this->cache[$dn]); + } + + return $this->modify($statement, [$name]); } /** - * Truncate's the mapping table + * Truncates the mapping table + * * @return bool */ public function clear() { $sql = $this->dbc ->getDatabasePlatform() ->getTruncateTableSQL('`' . $this->getTableName() . '`'); - return $this->dbc->prepare($sql)->execute(); + 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; } /** @@ -283,12 +427,23 @@ abstract class AbstractMapping { * * @return int */ - public function count() { - $qb = $this->dbc->getQueryBuilder(); - $query = $qb->select($qb->createFunction('COUNT(`ldap_dn`)')) + public function count(): int { + $query = $this->dbc->getQueryBuilder(); + $query->select($query->func()->count('ldap_dn_hash')) ->from($this->getTableName()); - $res = $query->execute(); - $count = $res->fetchColumn(); + $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 index 135faa30187..d9ae5e749fc 100644 --- a/apps/user_ldap/lib/Mapping/GroupMapping.php +++ b/apps/user_ldap/lib/Mapping/GroupMapping.php @@ -1,40 +1,24 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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: 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 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'; + * 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 index 10be4b3375e..a030cd0ab52 100644 --- a/apps/user_ldap/lib/Mapping/UserMapping.php +++ b/apps/user_ldap/lib/Mapping/UserMapping.php @@ -1,40 +1,64 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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: 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 + * + * @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() { - return '*PREFIX*ldap_user_mapping'; + 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 index 5b9e5e2231d..e853f3bba66 100644 --- a/apps/user_ldap/lib/Migration/UUIDFix.php +++ b/apps/user_ldap/lib/Migration/UUIDFix.php @@ -1,60 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP\Migration; - -use OC\BackgroundJob\QueuedJob; 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 { - /** @var AbstractMapping */ - protected $mapper; - - /** @var Proxy */ - protected $proxy; + protected AbstractMapping $mapper; + protected Proxy $proxy; public function run($argument) { $isUser = $this->proxy instanceof User_Proxy; - foreach($argument['records'] as $record) { + foreach ($argument['records'] as $record) { $access = $this->proxy->getLDAPAccess($record['name']); $uuid = $access->getUUID($record['dn'], $isUser); - if($uuid === false) { + if ($uuid === false) { // record not found, no prob, continue with the next continue; } - if($uuid !== $record['uuid']) { + if ($uuid !== $record['uuid']) { $this->mapper->setUUIDbyDN($uuid, $record['dn']); } } } - - /** - * @param Proxy $proxy - */ - public function overrideProxy(Proxy $proxy) { - $this->proxy = $proxy; - } } diff --git a/apps/user_ldap/lib/Migration/UUIDFixGroup.php b/apps/user_ldap/lib/Migration/UUIDFixGroup.php index 7258029dfd1..3924c91e7ba 100644 --- a/apps/user_ldap/lib/Migration/UUIDFixGroup.php +++ b/apps/user_ldap/lib/Migration/UUIDFixGroup.php @@ -1,41 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Roger Szabo <roger.szabo@web.de> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\Helper; -use OCA\User_LDAP\LDAP; +use OCA\User_LDAP\Group_Proxy; use OCA\User_LDAP\Mapping\GroupMapping; -use OCA\User_LDAP\User_Proxy; -use OCP\IConfig; +use OCP\AppFramework\Utility\ITimeFactory; class UUIDFixGroup extends UUIDFix { - public function __construct(GroupMapping $mapper, LDAP $ldap, IConfig $config, Helper $helper) { + public function __construct(ITimeFactory $time, GroupMapping $mapper, Group_Proxy $proxy) { + parent::__construct($time); $this->mapper = $mapper; - $this->proxy = new User_Proxy($helper->getServerConfigurationPrefixes(true), $ldap, $config, - \OC::$server->getNotificationManager(), \OC::$server->getUserSession(), - \OC::$server->query('LDAPUserPluginManager')); + $this->proxy = $proxy; } } diff --git a/apps/user_ldap/lib/Migration/UUIDFixInsert.php b/apps/user_ldap/lib/Migration/UUIDFixInsert.php index 4a1104f2c6f..bb92314d93a 100644 --- a/apps/user_ldap/lib/Migration/UUIDFixInsert.php +++ b/apps/user_ldap/lib/Migration/UUIDFixInsert.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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; @@ -32,23 +15,12 @@ use OCP\Migration\IRepairStep; class UUIDFixInsert implements IRepairStep { - /** @var IConfig */ - protected $config; - - /** @var UserMapping */ - protected $userMapper; - - /** @var GroupMapping */ - protected $groupMapper; - - /** @var IJobList */ - protected $jobList; - - public function __construct(IConfig $config, UserMapping $userMapper, GroupMapping $groupMapper, IJobList $jobList) { - $this->config = $config; - $this->userMapper = $userMapper; - $this->groupMapper = $groupMapper; - $this->jobList = $jobList; + public function __construct( + protected IConfig $config, + protected UserMapping $userMapper, + protected GroupMapping $groupMapper, + protected IJobList $jobList, + ) { } /** @@ -71,7 +43,7 @@ class UUIDFixInsert implements IRepairStep { */ public function run(IOutput $output) { $installedVersion = $this->config->getAppValue('user_ldap', 'installed_version', '1.2.1'); - if(version_compare($installedVersion, '1.2.1') !== -1) { + if (version_compare($installedVersion, '1.2.1') !== -1) { return; } @@ -82,20 +54,19 @@ class UUIDFixInsert implements IRepairStep { do { $retry = false; $records = $mapper->getList($offset, $batchSize); - if(count($records) === 0){ + if (count($records) === 0) { continue; } try { $this->jobList->add($jobClass, ['records' => $records]); $offset += $batchSize; } catch (\InvalidArgumentException $e) { - if(strpos($e->getMessage(), 'Background job arguments can\'t exceed 4000') !== false) { - $batchSize = intval(floor(count($records) * 0.8)); + 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 index 28765917ae0..71c3f638095 100644 --- a/apps/user_ldap/lib/Migration/UUIDFixUser.php +++ b/apps/user_ldap/lib/Migration/UUIDFixUser.php @@ -1,39 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\Helper; -use OCA\User_LDAP\LDAP; use OCA\User_LDAP\Mapping\UserMapping; -use OCA\User_LDAP\Group_Proxy; -use OCP\IConfig; +use OCA\User_LDAP\User_Proxy; +use OCP\AppFramework\Utility\ITimeFactory; class UUIDFixUser extends UUIDFix { - public function __construct(UserMapping $mapper, LDAP $ldap, IConfig $config, Helper $helper) { + public function __construct(ITimeFactory $time, UserMapping $mapper, User_Proxy $proxy) { + parent::__construct($time); $this->mapper = $mapper; - $groupPluginManager = \OC::$server->query('LDAPGroupPluginManager'); - $this->proxy = new Group_Proxy($helper->getServerConfigurationPrefixes(true), $ldap, $groupPluginManager); + $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 index 8c9d20c12dc..0195cb9e65b 100644 --- a/apps/user_ldap/lib/Notification/Notifier.php +++ b/apps/user_ldap/lib/Notification/Notifier.php @@ -1,59 +1,56 @@ <?php + /** - * @copyright Copyright (c) 2017 Roger Szabo <roger.szabo@web.de> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP\Notification; - -use OCP\IUser; -use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Notification\INotification; use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; class Notifier implements INotifier { - /** @var IFactory */ - protected $l10nFactory; - /** * @param IFactory $l10nFactory */ - public function __construct(\OCP\L10N\IFactory $l10nFactory) { - $this->l10nFactory = $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 \InvalidArgumentException When the notification was not prepared by a notifier + * @throws UnknownNotificationException When the notification was not prepared by a notifier */ - public function prepare(INotification $notification, $languageCode) { + public function prepare(INotification $notification, string $languageCode): INotification { if ($notification->getApp() !== 'user_ldap') { // Not my app => throw - throw new \InvalidArgumentException(); + throw new UnknownNotificationException(); } // Read the language from the notification @@ -63,10 +60,10 @@ class Notifier implements INotifier { // Deal with known subjects case 'pwd_exp_warn_days': $params = $notification->getSubjectParameters(); - $days = (int) $params[0]; + $days = (int)$params[0]; if ($days === 2) { $notification->setParsedSubject($l->t('Your password will expire tomorrow.')); - } else if ($days === 1) { + } elseif ($days === 1) { $notification->setParsedSubject($l->t('Your password will expire today.')); } else { $notification->setParsedSubject($l->n( @@ -79,7 +76,7 @@ class Notifier implements INotifier { default: // Unknown subject => Unknown notification => throw - throw new \InvalidArgumentException(); + 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 index dc8c6fc77cc..22b2c6617af 100644 --- a/apps/user_ldap/lib/Proxy.php +++ b/apps/user_ldap/lib/Proxy.php @@ -1,99 +1,88 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roger Szabo <roger.szabo@web.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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; -use OCA\User_LDAP\Mapping\UserMapping; use OCA\User_LDAP\Mapping\GroupMapping; -use OCA\User_LDAP\User\Manager; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\Server; +/** + * @template T + */ abstract class Proxy { - static private $accesses = array(); - private $ldap = null; + /** @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(); + } + } - /** @var \OCP\ICache|null */ - private $cache; + protected function setup(): void { + if ($this->isSetUp) { + return; + } - /** - * @param ILDAPWrapper $ldap - */ - public function __construct(ILDAPWrapper $ldap) { - $this->ldap = $ldap; - $memcache = \OC::$server->getMemCacheFactory(); - if($memcache->isAvailable()) { - $this->cache = $memcache->createDistributed(); + $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; } /** - * @param string $configPrefix + * @return T */ - private function addAccess($configPrefix) { - static $ocConfig; - static $fs; - static $log; - static $avatarM; - static $userMap; - static $groupMap; - static $db; - static $coreUserManager; - static $coreNotificationManager; - if($fs === null) { - $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(); - $coreNotificationManager = \OC::$server->getNotificationManager(); - } - $userManager = - new Manager($ocConfig, $fs, $log, $avatarM, new \OCP\Image(), $db, - $coreUserManager, $coreNotificationManager); + 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 = new Access($connector, $this->ldap, $userManager, new Helper($ocConfig), $ocConfig); + $access = $this->accessFactory->get($connector); $access->setUserMapper($userMap); $access->setGroupMapper($groupMap); self::$accesses[$configPrefix] = $access; } - /** - * @param string $configPrefix - * @return mixed - */ - protected function getAccess($configPrefix) { - if(!isset(self::$accesses[$configPrefix])) { + protected function getAccess(string $configPrefix): Access { + if (!isset(self::$accesses[$configPrefix])) { $this->addAccess($configPrefix); } return self::$accesses[$configPrefix]; @@ -104,7 +93,7 @@ abstract class Proxy { * @return string */ protected function getUserCacheKey($uid) { - return 'user-'.$uid.'-lastSeenOn'; + return 'user-' . $uid . '-lastSeenOn'; } /** @@ -112,7 +101,7 @@ abstract class Proxy { * @return string */ protected function getGroupCacheKey($gid) { - return 'group-'.$gid.'-lastSeenOn'; + return 'group-' . $gid . '-lastSeenOn'; } /** @@ -138,17 +127,29 @@ abstract class Proxy { */ 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 + * @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) { + if (!$this->isSingleBackend()) { + $result = $this->callOnLastSeenOn($id, $method, $parameters, $passOnWhen); + } + if (!isset($result) || $result === $passOnWhen) { $result = $this->walkBackends($id, $method, $parameters); } return $result; @@ -160,10 +161,10 @@ abstract class Proxy { */ private function getCacheKey($key) { $prefix = 'LDAP-Proxy-'; - if($key === null) { + if ($key === null) { return $prefix; } - return $prefix.md5($key); + return $prefix . hash('sha256', $key); } /** @@ -171,7 +172,7 @@ abstract class Proxy { * @return mixed|null */ public function getFromCache($key) { - if($this->cache === null) { + if ($this->cache === null) { return null; } @@ -189,16 +190,16 @@ abstract class Proxy { * @param mixed $value */ public function writeToCache($key, $value) { - if($this->cache === null) { + if ($this->cache === null) { return; } - $key = $this->getCacheKey($key); + $key = $this->getCacheKey($key); $value = base64_encode(json_encode($value)); $this->cache->set($key, $value, 2592000); } public function clearCache() { - if($this->cache === null) { + 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 index 33a86ad72d2..89fb063265b 100644 --- a/apps/user_ldap/lib/Settings/Admin.php +++ b/apps/user_ldap/lib/Settings/Admin.php @@ -1,71 +1,60 @@ <?php + /** - * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\Settings\ISettings; -use OCP\Template; - -class Admin implements ISettings { - /** @var IL10N */ - private $l; +use OCP\Server; +use OCP\Settings\IDelegatedSettings; +use OCP\Template\ITemplateManager; - /** - * @param IL10N $l - */ - public function __construct(IL10N $l) { - $this->l = $l; +class Admin implements IDelegatedSettings { + public function __construct( + private IL10N $l, + private ITemplateManager $templateManager, + ) { } /** * @return TemplateResponse */ public function getForm() { - $helper = new Helper(\OC::$server->getConfig()); + $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 = new Template('user_ldap', 'part.wizardcontrols'); + $wControls = $this->templateManager->getTemplate('user_ldap', 'part.wizardcontrols'); $wControls = $wControls->fetchPage(); - $sControls = new Template('user_ldap', 'part.settingcontrols'); + $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 - $config = new Configuration('', false); + if (!isset($config)) { + $config = new Configuration('', false); + } $defaults = $config->getDefaults(); - foreach($defaults as $key => $default) { - $parameters[$key.'_default'] = $default; + foreach ($defaults as $key => $default) { + $parameters[$key . '_default'] = $default; } return new TemplateResponse('user_ldap', 'settings', $parameters); @@ -80,12 +69,20 @@ class Admin implements ISettings { /** * @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. + * 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 index 0ff52160a97..3b95e25513d 100644 --- a/apps/user_ldap/lib/Settings/Section.php +++ b/apps/user_ldap/lib/Settings/Section.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016 Arthur Schiwon <blizzz@arthur-schiwon.de> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\User_LDAP\Settings; use OCP\IL10N; @@ -30,18 +11,14 @@ use OCP\IURLGenerator; use OCP\Settings\IIconSection; class Section implements IIconSection { - /** @var IL10N */ - private $l; - /** @var IURLGenerator */ - private $url; - /** * @param IURLGenerator $url * @param IL10N $l */ - public function __construct(IURLGenerator $url, IL10N $l) { - $this->url = $url; - $this->l = $l; + public function __construct( + private IURLGenerator $url, + private IL10N $l, + ) { } /** @@ -55,19 +32,19 @@ class Section implements IIconSection { } /** - * returns the translated name as it should be displayed, e.g. 'LDAP / AD + * 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 $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. + * 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 */ 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 index 9ec95a01b58..f57f71a9d47 100644 --- a/apps/user_ldap/lib/User/DeletedUsersIndex.php +++ b/apps/user_ldap/lib/User/DeletedUsersIndex.php @@ -1,78 +1,45 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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 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 { - /** - * @var \OCP\IConfig $config - */ - protected $config; - - /** - * @var \OCP\IDBConnection $db - */ - protected $db; - - /** - * @var \OCA\User_LDAP\Mapping\UserMapping $mapping - */ - protected $mapping; + protected ?array $deletedUsers = null; - /** - * @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; + public function __construct( + protected IConfig $config, + protected UserMapping $mapping, + private IManager $shareManager, + ) { } /** * reads LDAP users marked as deleted from the database - * @return \OCA\User_LDAP\User\OfflineUser[] + * @return OfflineUser[] */ - private function fetchDeletedUsers() { - $deletedUsers = $this->config->getUsersForUserValue( - 'user_ldap', 'isDeleted', '1'); + private function fetchDeletedUsers(): array { + $deletedUsers = $this->config->getUsersForUserValue('user_ldap', 'isDeleted', '1'); - $userObjects = array(); - foreach($deletedUsers as $user) { - $userObjects[] = new OfflineUser($user, $this->config, $this->db, $this->mapping); + $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; @@ -81,10 +48,10 @@ class DeletedUsersIndex { /** * returns all LDAP users that are marked as deleted - * @return \OCA\User_LDAP\User\OfflineUser[] + * @return OfflineUser[] */ - public function getUsers() { - if(is_array($this->deletedUsers)) { + public function getUsers(): array { + if (is_array($this->deletedUsers)) { return $this->deletedUsers; } return $this->fetchDeletedUsers(); @@ -92,23 +59,30 @@ class DeletedUsersIndex { /** * whether at least one user was detected as deleted - * @return bool */ - public function hasUsers() { - if($this->deletedUsers === false) { + public function hasUsers(): bool { + if (!is_array($this->deletedUsers)) { $this->fetchDeletedUsers(); } - if(is_array($this->deletedUsers) && count($this->deletedUsers) > 0) { - return true; - } - return false; + return is_array($this->deletedUsers) && (count($this->deletedUsers) > 0); } /** * marks a user as deleted - * @param string $ocName + * + * @throws PreConditionNotMetException */ - public function markUser($ocName) { + 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/IUserTools.php b/apps/user_ldap/lib/User/IUserTools.php deleted file mode 100644 index 4ba9cebb1a6..00000000000 --- a/apps/user_ldap/lib/User/IUserTools.php +++ /dev/null @@ -1,42 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\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 index b04a321652c..88a001dd965 100644 --- a/apps/user_ldap/lib/User/Manager.php +++ b/apps/user_ldap/lib/User/Manager.php @@ -1,42 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roger Szabo <roger.szabo@web.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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\User; -use OC\Cache\CappedMemoryCache; use OCA\User_LDAP\Access; -use OCA\User_LDAP\LogWrapper; -use OCA\User_LDAP\FilesystemHelper; +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 @@ -45,76 +25,32 @@ use OCP\Notification\IManager as INotificationManager; * cache */ class Manager { - /** @var IUserTools */ - protected $access; - - /** @var IConfig */ - protected $ocConfig; - - /** @var IDBConnection */ - protected $db; - - /** @var IUserManager */ - protected $userManager; - - /** @var INotificationManager */ - protected $notificationManager; - - /** @var FilesystemHelper */ - protected $ocFilesystem; - - /** @var LogWrapper */ - protected $ocLog; - - /** @var Image */ - protected $image; - - /** @param \OCP\IAvatarManager */ - protected $avatarManager; - - /** - * @var CappedMemoryCache $usersByDN - */ - protected $usersByDN; - /** - * @var CappedMemoryCache $usersByUid - */ - protected $usersByUid; - - /** - * @param IConfig $ocConfig - * @param \OCA\User_LDAP\FilesystemHelper $ocFilesystem object that - * gives access to necessary functions from the OC filesystem - * @param \OCA\User_LDAP\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, - INotificationManager $notificationManager) { + protected ?Access $access = null; + protected IDBConnection $db; + /** @var CappedMemoryCache<User> $usersByDN */ + protected CappedMemoryCache $usersByDN; + /** @var CappedMemoryCache<User> $usersByUid */ + protected CappedMemoryCache $usersByUid; - $this->ocConfig = $ocConfig; - $this->ocFilesystem = $ocFilesystem; - $this->ocLog = $ocLog; - $this->avatarManager = $avatarManager; - $this->image = $image; - $this->db = $db; - $this->userManager = $userManager; - $this->notificationManager = $notificationManager; - $this->usersByDN = new CappedMemoryCache(); - $this->usersByUid = new CappedMemoryCache(); + 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(); } /** - * @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 + * Binds manager to an instance of Access. + * It needs to be assigned first before the manager can be used. + * @param Access */ - public function setLdapAccess(IUserTools $access) { + public function setLdapAccess(Access $access) { $this->access = $access; } @@ -123,15 +59,15 @@ class Manager { * property array * @param string $dn the DN of the user * @param string $uid the internal (owncloud) username - * @return \OCA\User_LDAP\User\User + * @return 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, + clone $this->image, $this->logger, + $this->avatarManager, $this->userManager, $this->notificationManager); - $this->usersByDN[$dn] = $user; + $this->usersByDN[$dn] = $user; $this->usersByUid[$uid] = $user; return $user; } @@ -141,7 +77,7 @@ class Manager { * @param $uid */ public function invalidate($uid) { - if(!isset($this->usersByUid[$uid])) { + if (!isset($this->usersByUid[$uid])) { return; } $dn = $this->usersByUid[$uid]->getDN(); @@ -152,10 +88,11 @@ class Manager { /** * @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)) { + if (is_null($this->access)) { throw new \Exception('LDAP Access instance must be set first'); } } @@ -163,39 +100,60 @@ class Manager { /** * 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 + * payload * @return string[] */ public function getAttributes($minimal = false) { - $attributes = array_merge(Access::UUID_ATTRIBUTES, ['dn', 'uid', 'samaccountname', 'memberof']); - $possible = array( + $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, - ); - foreach($possible as $attr) { - if(!is_null($attr)) { - $attributes[] = $attr; - } - } + $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 = $this->access->getConnection()->homeFolderNamingRule; - if(strpos($homeRule, 'attr:') === 0) { + $homeRule = (string)$this->access->getConnection()->homeFolderNamingRule; + if (str_starts_with($homeRule, 'attr:')) { $attributes[] = substr($homeRule, strlen('attr:')); } - if(!$minimal) { + if (!$minimal) { // attributes that are not really important but may come with big // payload. - $attributes = array_merge($attributes, array( - 'jpegphoto', - 'thumbnailphoto' - )); + $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; } @@ -207,56 +165,57 @@ class Manager { public function isDeletedUser($id) { $isDeleted = $this->ocConfig->getUserValue( $id, 'user_ldap', 'isDeleted', 0); - return intval($isDeleted) === 1; + return (int)$isDeleted === 1; } /** * creates and returns an instance of OfflineUser for the specified user * @param string $id - * @return \OCA\User_LDAP\User\OfflineUser + * @return OfflineUser */ public function getDeletedUser($id) { return new OfflineUser( $id, $this->ocConfig, - $this->db, - $this->access->getUserMapper()); + $this->access->getUserMapper(), + $this->shareManager + ); } /** - * @brief returns a User object by it's Nextcloud username + * @brief returns a User object by its Nextcloud username * @param string $id the DN or username of the user - * @return \OCA\User_LDAP\User\User|\OCA\User_LDAP\User\OfflineUser|null + * @return User|OfflineUser|null */ protected function createInstancyByUserName($id) { //most likely a uid. Check whether it is a deleted user - if($this->isDeletedUser($id)) { + if ($this->isDeletedUser($id)) { return $this->getDeletedUser($id); } $dn = $this->access->username2dn($id); - if($dn !== false) { + if ($dn !== false) { return $this->createAndCache($dn, $id); } return null; } /** - * @brief returns a User object by it's DN or Nextcloud username + * @brief returns a User object by its DN or Nextcloud username * @param string $id the DN or username of the user - * @return \OCA\User_LDAP\User\User|\OCA\User_LDAP\User\OfflineUser|null + * @return User|OfflineUser|null * @throws \Exception when connection could not be established */ public function get($id) { $this->checkAccess(); - if(isset($this->usersByDN[$id])) { + if (isset($this->usersByDN[$id])) { return $this->usersByDN[$id]; - } else if(isset($this->usersByUid[$id])) { + } elseif (isset($this->usersByUid[$id])) { return $this->usersByUid[$id]; } - if($this->access->stringResemblesDN($id) ) { + if ($this->access->stringResemblesDN($id)) { $uid = $this->access->dn2username($id); - if($uid !== false) { + if ($uid !== false) { return $this->createAndCache($id, $uid); } } @@ -264,4 +223,36 @@ class Manager { 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 942eee84cb7..ecaab7188ba 100644 --- a/apps/user_ldap/lib/User/OfflineUser.php +++ b/apps/user_ldap/lib/User/OfflineUser.php @@ -1,39 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\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; @@ -54,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; @@ -62,37 +48,27 @@ class OfflineUser { */ protected $hasActiveShares; /** - * @var IConfig $config - */ - protected $config; - /** * @var IDBConnection $db */ protected $db; - /** - * @var \OCA\User_LDAP\Mapping\UserMapping - */ - protected $mapping; /** * @param string $ocName - * @param IConfig $config - * @param IDBConnection $db - * @param \OCA\User_LDAP\Mapping\UserMapping $mapping - */ - public function __construct($ocName, IConfig $config, 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'); } /** @@ -100,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(); @@ -126,6 +102,9 @@ class OfflineUser { * @return string */ public function getUID() { + if ($this->uid === null) { + $this->fetchDetails(); + } return $this->uid; } @@ -134,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; } @@ -142,6 +125,9 @@ class OfflineUser { * @return string */ public function getDisplayName() { + if ($this->displayName === null) { + $this->fetchDetails(); + } return $this->displayName; } @@ -150,6 +136,9 @@ class OfflineUser { * @return string */ public function getEmail() { + if ($this->email === null) { + $this->fetchDetails(); + } return $this->email; } @@ -158,6 +147,9 @@ class OfflineUser { * @return string */ public function getHomePath() { + if ($this->homePath === null) { + $this->fetchDetails(); + } return $this->homePath; } @@ -166,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; } /** @@ -174,6 +187,9 @@ class OfflineUser { * @return bool */ public function getHasActiveShares() { + if ($this->hasActiveShares === null) { + $this->determineShares(); + } return $this->hasActiveShares; } @@ -181,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 index c93d2a77d80..8f97ec1701f 100644 --- a/apps/user_ldap/lib/User/User.php +++ b/apps/user_ldap/lib/User/User.php @@ -1,44 +1,31 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Juan Pablo Villafáñez <jvillafanez@solidgear.es> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roger Szabo <roger.szabo@web.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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\User; +use InvalidArgumentException; +use OC\Accounts\AccountManager; +use OCA\User_LDAP\Access; use OCA\User_LDAP\Connection; -use OCA\User_LDAP\FilesystemHelper; -use OCA\User_LDAP\LogWrapper; +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\Util; use OCP\Notification\IManager as INotificationManager; +use OCP\PreConditionNotMetException; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; /** * User @@ -46,142 +33,68 @@ use OCP\Notification\IManager as INotificationManager; * represents an LDAP user, gets and holds user-specific information from LDAP */ class User { + protected Connection $connection; /** - * @var IUserTools - */ - protected $access; - /** - * @var Connection - */ - protected $connection; - /** - * @var IConfig - */ - protected $config; - /** - * @var FilesystemHelper - */ - protected $fs; - /** - * @var Image - */ - protected $image; - /** - * @var LogWrapper + * @var array<string,1> */ - protected $log; - /** - * @var IAvatarManager - */ - protected $avatarManager; - /** - * @var IUserManager - */ - protected $userManager; - /** - * @var INotificationManager - */ - protected $notificationManager; - /** - * @var string - */ - protected $dn; - /** - * @var string - */ - protected $uid; - /** - * @var string[] - */ - protected $refreshedFeatures = array(); - /** - * @var string - */ - protected $avatarImage; + protected array $refreshedFeatures = []; + protected string|false|null $avatarImage = null; + + protected BirthdateParserService $birthdateParser; /** * DB config keys for user preferences + * @var string */ - const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished'; - const USER_PREFKEY_LASTREFRESH = 'lastFeatureRefresh'; + public const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished'; /** * @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 Image $image any empty instance - * @param LogWrapper $log - * @param IAvatarManager $avatarManager - * @param IUserManager $userManager - * @param INotificationManager $notificationManager */ - public function __construct($username, $dn, IUserTools $access, - IConfig $config, FilesystemHelper $fs, Image $image, - LogWrapper $log, IAvatarManager $avatarManager, IUserManager $userManager, - INotificationManager $notificationManager) { - - if ($username === null) { - $log->log("uid for '$dn' must not be null!", Util::ERROR); - throw new \InvalidArgumentException('uid must not be null!'); - } else if ($username === '') { - $log->log("uid for '$dn' must not be an empty string", Util::ERROR); + 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(); - $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; - $this->notificationManager = $notificationManager; - - \OCP\Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry'); + Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry'); } /** - * @brief updates properties like email, quota or avatar provided by LDAP - * @return null + * marks a user as deleted + * + * @throws PreConditionNotMetException */ - 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(); - } + 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($ldapEntry) { - $this->markRefreshTime(); + public function processAttributes(array $ldapEntry): void { //Quota $attr = strtolower($this->connection->ldapQuotaAttribute); - if(isset($ldapEntry[$attr])) { + if (isset($ldapEntry[$attr])) { $this->updateQuota($ldapEntry[$attr][0]); } else { if ($this->connection->ldapQuotaDefault !== '') { @@ -193,15 +106,15 @@ class User { //displayName $displayName = $displayName2 = ''; $attr = strtolower($this->connection->ldapUserDisplayName); - if(isset($ldapEntry[$attr])) { - $displayName = strval($ldapEntry[$attr][0]); + if (isset($ldapEntry[$attr])) { + $displayName = (string)$ldapEntry[$attr][0]; } $attr = strtolower($this->connection->ldapUserDisplayName2); - if(isset($ldapEntry[$attr])) { - $displayName2 = strval($ldapEntry[$attr][0]); + if (isset($ldapEntry[$attr])) { + $displayName2 = (string)$ldapEntry[$attr][0]; } if ($displayName !== '') { - $this->composeAndStoreDisplayName($displayName); + $this->composeAndStoreDisplayName($displayName, $displayName2); $this->access->cacheUserDisplayName( $this->getUsername(), $displayName, @@ -214,45 +127,185 @@ class User { //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])) { - $this->updateEmail($ldapEntry[$attr][0]); + 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'])) { + if (isset($ldapEntry['uid'])) { $this->storeLDAPUserName($ldapEntry['uid'][0]); - } else if(isset($ldapEntry['samaccountname'])) { + } elseif (isset($ldapEntry['samaccountname'])) { $this->storeLDAPUserName($ldapEntry['samaccountname'][0]); } //homePath - if(strpos($this->connection->homeFolderNamingRule, 'attr:') === 0) { + if (str_starts_with($this->connection->homeFolderNamingRule, 'attr:')) { $attr = strtolower(substr($this->connection->homeFolderNamingRule, strlen('attr:'))); - if(isset($ldapEntry[$attr])) { + if (isset($ldapEntry[$attr])) { $this->access->cacheUserHome( $this->getUsername(), $this->getHomePath($ldapEntry[$attr][0])); } } //memberOf groups - $cacheKey = 'getMemberOf'.$this->getUsername(); + $cacheKey = 'getMemberOf' . $this->getUsername(); $groups = false; - if(isset($ldapEntry['memberof'])) { + 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 - $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'); + /** @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; } } @@ -276,22 +329,22 @@ class User { /** * 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 = strval($valueFromLDAP); + public function getHomePath(?string $valueFromLDAP = null): string|false { + $path = (string)$valueFromLDAP; $attr = null; if (is_null($valueFromLDAP) - && strpos($this->access->connection->homeFolderNamingRule, 'attr:') === 0 - && $this->access->connection->homeFolderNamingRule !== 'attr:') - { + && str_starts_with($this->access->connection->homeFolderNamingRule, 'attr:') + && $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])) { + $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]; } } @@ -299,12 +352,12 @@ class User { 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] - && !(3 < strlen($path) && ctype_alpha($path[0]) - && $path[1] === ':' && ('\\' === $path[2] || '/' === $path[2])) + 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; + \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 @@ -314,8 +367,8 @@ class User { return $path; } - if( !is_null($attr) - && $this->config->getAppValue('user_ldap', 'enforce_home_folder_naming_rule', true) + 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()); @@ -326,10 +379,10 @@ class User { return false; } - public function getMemberOfGroups() { - $cacheKey = 'getMemberOf'.$this->getUsername(); + public function getMemberOfGroups(): array|false { + $cacheKey = 'getMemberOf' . $this->getUsername(); $memberOfGroups = $this->connection->getFromCache($cacheKey); - if(!is_null($memberOfGroups)) { + if (!is_null($memberOfGroups)) { return $memberOfGroups; } $groupDNs = $this->access->readAttribute($this->getDN(), 'memberOf'); @@ -339,18 +392,20 @@ class User { /** * @brief reads the image from LDAP that shall be used as Avatar - * @return string data (provided by LDAP) | false + * @return string|false data (provided by LDAP) */ - public function getAvatarImage() { - if(!is_null($this->avatarImage)) { + public function getAvatarImage(): string|false { + if (!is_null($this->avatarImage)) { return $this->avatarImage; } $this->avatarImage = false; - $attributes = array('jpegPhoto', 'thumbnailPhoto'); - foreach($attributes as $attribute) { + /** @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 && is_array($result) && isset($result[0])) { + if ($result !== false && isset($result[0])) { $this->avatarImage = $result[0]; break; } @@ -361,45 +416,16 @@ class User { /** * @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() { + public function markLogin(): void { $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); - - if((time() - intval($lastChecked)) < intval($this->config->getAppValue('user_ldap', 'updateAttributesInterval', 86400)) ) { - return false; - } - return true; + $this->uid, 'user_ldap', self::USER_PREFKEY_FIRSTLOGIN, '1'); } /** * Stores a key-value pair in relation to this user - * - * @param string $key - * @param string $value */ - private function store($key, $value) { + private function store(string $key, string $value): void { $this->config->setUserValue($this->uid, 'user_ldap', $key, $value); } @@ -407,24 +433,29 @@ class User { * 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 + * @return string the effective display name */ - public function composeAndStoreDisplayName($displayName, $displayName2 = '') { - $displayName2 = strval($displayName2); - if($displayName2 !== '') { + public function composeAndStoreDisplayName(string $displayName, string $displayName2 = ''): string { + if ($displayName2 !== '') { $displayName .= ' (' . $displayName2 . ')'; } - $this->store('displayName', $displayName); + $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 - * @param string $userName */ - public function storeLDAPUserName($userName) { + public function storeLDAPUserName(string $userName): void { $this->store('uid', $userName); } @@ -432,11 +463,10 @@ class User { * @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 + * @param string $feature email | quota | avatar | profile (can be extended) */ - private function wasRefreshed($feature) { - if(isset($this->refreshedFeatures[$feature])) { + private function wasRefreshed(string $feature): bool { + if (isset($this->refreshedFeatures[$feature])) { return true; } $this->refreshedFeatures[$feature] = 1; @@ -445,29 +475,28 @@ class User { /** * fetches the email from LDAP and stores it as Nextcloud user value - * @param string $valueFromLDAP if known, to save an LDAP read request - * @return null + * @param ?string $valueFromLDAP if known, to save an LDAP read request */ - public function updateEmail($valueFromLDAP = null) { - if($this->wasRefreshed('email')) { + public function updateEmail(?string $valueFromLDAP = null): void { + if ($this->wasRefreshed('email')) { return; } - $email = strval($valueFromLDAP); - if(is_null($valueFromLDAP)) { + $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 = strval($aEmail[0]); + if (is_array($aEmail) && (count($aEmail) > 0)) { + $email = (string)$aEmail[0]; } } } if ($email !== '') { $user = $this->userManager->get($this->uid); if (!is_null($user)) { - $currentEmail = strval($user->getEMailAddress()); + $currentEmail = (string)$user->getSystemEMailAddress(); if ($currentEmail !== $email) { - $user->setEMailAddress($email); + $user->setSystemEMailAddress($email); } } } @@ -486,193 +515,290 @@ class User { * 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 \OC_Helper::computerFileSize method for more info) + * 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 - * @return null + * @param ?string $valueFromLDAP the quota attribute's value can be passed, + * to save the readAttribute request */ - public function updateQuota($valueFromLDAP = null) { - if($this->wasRefreshed('quota')) { + 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 = $this->connection->ldapQuotaAttribute; - if ($quotaAttribute !== '') { - $aQuota = $this->access->readAttribute($this->dn, $quotaAttribute); - if($aQuota && (count($aQuota) > 0)) { - if ($this->verifyQuotaValue($aQuota[0])) { - $quota = $aQuota[0]; - } else { - $this->log->log('not suitable LDAP quota found for user ' . $this->uid . ': [' . $aQuota[0] . ']', \OCP\Util::WARN); - } - } + 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 { - if ($this->verifyQuotaValue($valueFromLDAP)) { - $quota = $valueFromLDAP; - } else { - $this->log->log('not suitable LDAP quota found for user ' . $this->uid . ': [' . $valueFromLDAP . ']', \OCP\Util::WARN); - } + $this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . ($valueFromLDAP ?? '') . ']', ['app' => 'user_ldap']); } - if ($quota === false) { + if ($quota === false && $this->verifyQuotaValue($defaultQuota)) { // quota not found using the LDAP attribute (or not parseable). Try the default quota - $defaultQuota = $this->connection->ldapQuotaDefault; - if ($this->verifyQuotaValue($defaultQuota)) { - $quota = $defaultQuota; - } + $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) { - if($quota !== false) { - $targetUser->setQuota($quota); - } else { - $this->log->log('not suitable default quota found for user ' . $this->uid . ': [' . $defaultQuota . ']', \OCP\Util::WARN); - } + if ($targetUser instanceof IUser) { + $targetUser->setQuota($quota); } else { - $this->log->log('trying to set a quota for user ' . $this->uid . ' but the user is missing', \OCP\Util::ERROR); + $this->logger->info('trying to set a quota for user ' . $this->uid . ' but the user is missing', ['app' => 'user_ldap']); } } - private function verifyQuotaValue($quotaValue) { - return $quotaValue === 'none' || $quotaValue === 'default' || \OC_Helper::computerFileSize($quotaValue) !== false; + private function verifyQuotaValue(string $quotaValue): bool { + return $quotaValue === 'none' || $quotaValue === 'default' || Util::computerFileSize($quotaValue) !== false; } /** - * called by a post_login hook to save the avatar picture + * takes values from LDAP and stores it as Nextcloud user profile value * - * @param array $params + * @param array $profileValues associative array of property keys and values from LDAP */ - public function updateAvatarPostLogin($params) { - if(isset($params['uid']) && $params['uid'] === $this->getUsername()) { - $this->updateAvatar(); + 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 null + * @return bool true when the avatar was set successfully or is up to date */ - public function updateAvatar() { - if($this->wasRefreshed('avatar')) { - return; + public function updateAvatar(bool $force = false): bool { + if (!$force && $this->wasRefreshed('avatar')) { + return false; } $avatarImage = $this->getAvatarImage(); - if($avatarImage === false) { + if ($avatarImage === false) { //not set, nothing left to do; - return; + 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; } - $this->image->loadFromBase64(base64_encode($avatarImage)); - $this->setOwnCloudAvatar(); } /** * @brief sets an image as Nextcloud avatar - * @return null */ - private function setOwnCloudAvatar() { - if(!$this->image->valid()) { - $this->log->log('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('croping image for avatar failed for '.$this->dn, \OCP\Util::ERROR); - return; + 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; } - if(!$this->fs->isLoaded()) { - $this->fs->setup($this->uid); + + //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) { - \OC::$server->getLogger()->notice( - 'Could not set avatar for ' . $this->dn . ', because: ' . $e->getMessage(), - ['app' => 'user_ldap']); + $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 - * - * @param array $params */ - public function handlePasswordExpiry($params) { + public function handlePasswordExpiry(array $params): void { $ppolicyDN = $this->connection->ldapDefaultPPolicyDN; - if (empty($ppolicyDN) || (intval($this->connection->turnOnPasswordChange) !== 1)) { - return;//password expiry handling disabled + if (empty($ppolicyDN) || ((int)$this->connection->turnOnPasswordChange !== 1)) { + //password expiry handling disabled + return; } $uid = $params['uid']; - if(isset($uid) && $uid === $this->getUsername()) { + if (isset($uid) && $uid === $this->getUsername()) { //retrieve relevant user attributes $result = $this->access->search('objectclass=*', $this->dn, ['pwdpolicysubentry', 'pwdgraceusetime', 'pwdreset', 'pwdchangedtime']); - - if(array_key_exists('pwdpolicysubentry', $result[0])) { - $pwdPolicySubentry = $result[0]['pwdpolicysubentry']; - if($pwdPolicySubentry && (count($pwdPolicySubentry) > 0)){ - $ppolicyDN = $pwdPolicySubentry[0];//custom ppolicy DN + + 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'] : []; } - - $pwdGraceUseTime = array_key_exists('pwdgraceusetime', $result[0]) ? $result[0]['pwdgraceusetime'] : null; - $pwdReset = array_key_exists('pwdreset', $result[0]) ? $result[0]['pwdreset'] : null; - $pwdChangedTime = array_key_exists('pwdchangedtime', $result[0]) ? $result[0]['pwdchangedtime'] : null; - + //retrieve relevant password policy attributes $cacheKey = 'ppolicyAttributes' . $ppolicyDN; $result = $this->connection->getFromCache($cacheKey); - if(is_null($result)) { + 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'] : null; - $pwdMaxAge = array_key_exists('pwdmaxage', $result[0]) ? $result[0]['pwdmaxage'] : null; - $pwdExpireWarning = array_key_exists('pwdexpirewarning', $result[0]) ? $result[0]['pwdexpirewarning'] : null; - + + $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 - $pwdGraceUseTimeCount = count($pwdGraceUseTime); - if($pwdGraceUseTime && $pwdGraceUseTimeCount > 0) { //was this a grace login? - if($pwdGraceAuthNLimit - && (count($pwdGraceAuthNLimit) > 0) - &&($pwdGraceUseTimeCount < intval($pwdGraceAuthNLimit[0]))) { //at least one more grace login available? + 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: '.\OC::$server->getURLGenerator()->linkToRouteAbsolute( - 'user_ldap.renewPassword.showRenewPasswordForm', array('user' => $uid))); + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid])); } else { //no more grace login available - header('Location: '.\OC::$server->getURLGenerator()->linkToRouteAbsolute( - 'user_ldap.renewPassword.showLoginFormInvalidPassword', array('user' => $uid))); + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showLoginFormInvalidPassword', ['user' => $uid])); } exit(); } //handle pwdReset attribute - if($pwdReset && (count($pwdReset) > 0) && $pwdReset[0] === 'TRUE') { //user must change his password + if (!empty($pwdReset) && $pwdReset[0] === 'TRUE') { //user must change their password $this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true'); - header('Location: '.\OC::$server->getURLGenerator()->linkToRouteAbsolute( - 'user_ldap.renewPassword.showRenewPasswordForm', array('user' => $uid))); + header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute( + 'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid])); exit(); } //handle password expiry warning - if($pwdChangedTime && (count($pwdChangedTime) > 0)) { - if($pwdMaxAge && (count($pwdMaxAge) > 0) - && $pwdExpireWarning && (count($pwdExpireWarning) > 0)) { - $pwdMaxAgeInt = intval($pwdMaxAge[0]); - $pwdExpireWarningInt = intval($pwdExpireWarning[0]); - if($pwdMaxAgeInt > 0 && $pwdExpireWarningInt > 0){ + 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')); + $pwdChangedTimeDt->add(new \DateInterval('PT' . $pwdMaxAgeInt . 'S')); $currentDateTime = new \DateTime(); $secondsToExpiry = $pwdChangedTimeDt->getTimestamp() - $currentDateTime->getTimestamp(); - if($secondsToExpiry <= $pwdExpireWarningInt) { + if ($secondsToExpiry <= $pwdExpireWarningInt) { //remove last password expiry warning if any $notification = $this->notificationManager->createNotification(); $notification->setApp('user_ldap') @@ -685,8 +811,8 @@ class User { $notification->setApp('user_ldap') ->setUser($uid) ->setDateTime($currentDateTime) - ->setObject('pwd_exp_warn', $uid) - ->setSubject('pwd_exp_warn_days', [(int) ceil($secondsToExpiry / 60 / 60 / 24)]) + ->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 index b3fda494022..ed87fea6fde 100644 --- a/apps/user_ldap/lib/UserPluginManager.php +++ b/apps/user_ldap/lib/UserPluginManager.php @@ -1,37 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2017 EITA Cooperative (eita.org.br) - * - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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; - public $test = false; - - private $respondToActions = 0; - - private $which = array( + private array $which = [ Backend::CREATE_USER => null, Backend::SET_PASSWORD => null, Backend::GET_HOME => null, @@ -40,7 +22,9 @@ class UserPluginManager { Backend::PROVIDE_AVATAR => null, Backend::COUNT_USERS => null, 'deleteUser' => null - ); + ]; + + private bool $suppressDeletion = false; /** * @return int All implemented actions, except for 'deleteUser' @@ -50,7 +34,7 @@ class UserPluginManager { } /** - * Registers a group plugin that may implement some actions, overriding User_LDAP's user actions. + * Registers a user plugin that may implement some actions, overriding User_LDAP's user actions. * * @param ILDAPUserPlugin $plugin */ @@ -58,15 +42,15 @@ class UserPluginManager { $respondToActions = $plugin->respondToActions(); $this->respondToActions |= $respondToActions; - foreach($this->which as $action => $v) { + foreach ($this->which as $action => $v) { if (is_int($action) && (bool)($respondToActions & $action)) { $this->which[$action] = $plugin; - \OC::$server->getLogger()->debug("Registered action ".$action." to plugin ".get_class($plugin), ['app' => 'user_ldap']); + Server::get(LoggerInterface::class)->debug('Registered action ' . $action . ' to plugin ' . get_class($plugin), ['app' => 'user_ldap']); } } - if (method_exists($plugin,'deleteUser')) { + if (method_exists($plugin, 'deleteUser')) { $this->which['deleteUser'] = $plugin; - \OC::$server->getLogger()->debug("Registered action deleteUser to plugin ".get_class($plugin), ['app' => 'user_ldap']); + Server::get(LoggerInterface::class)->debug('Registered action deleteUser to plugin ' . get_class($plugin), ['app' => 'user_ldap']); } } @@ -84,14 +68,14 @@ class UserPluginManager { * * @param string $username The username of the user to create * @param string $password The password of the new user - * @return bool + * @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); + return $plugin->createUser($username, $password); } throw new \Exception('No plugin implements createUser in this LDAP Backend.'); } @@ -107,13 +91,13 @@ class UserPluginManager { $plugin = $this->which[Backend::SET_PASSWORD]; if ($plugin) { - return $plugin->setPassword($uid,$password); + return $plugin->setPassword($uid, $password); } throw new \Exception('No plugin implements setPassword in this LDAP Backend.'); } /** - * checks whether the user is allowed to change his avatar in Nextcloud + * 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 @@ -175,7 +159,7 @@ class UserPluginManager { /** * Count the number of users - * @return int|bool + * @return int|false * @throws \Exception */ public function countUsers() { @@ -191,7 +175,7 @@ class UserPluginManager { * @return bool */ public function canDeleteUser() { - return $this->which['deleteUser'] !== null; + return !$this->suppressDeletion && $this->which['deleteUser'] !== null; } /** @@ -202,9 +186,21 @@ class UserPluginManager { 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 index 506ea36c529..c3f56f5ff9b 100644 --- a/apps/user_ldap/lib/User_LDAP.php +++ b/apps/user_ldap/lib/User_LDAP.php @@ -1,146 +1,102 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Dominik Schmidt <dev@dominik-schmidt.de> - * @author felixboehm <felix@webhippie.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Renaud Fortier <Renaud.Fortier@fsaa.ulaval.ca> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tom Needham <tom@owncloud.com> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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; +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\IConfig; -use OCP\IUser; -use OCP\IUserSession; +use OCP\IUserBackend; use OCP\Notification\IManager as INotificationManager; -use OCP\Util; - -class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserInterface, IUserLDAP { - /** @var \OCP\IConfig */ - protected $ocConfig; - - /** @var INotificationManager */ - protected $notificationManager; - - /** @var string */ - protected $currentUserInDeletionProcess; - - /** @var UserPluginManager */ - protected $userPluginManager; - - /** - * @param Access $access - * @param \OCP\IConfig $ocConfig - * @param \OCP\Notification\IManager $notificationManager - * @param IUserSession $userSession - */ - public function __construct(Access $access, IConfig $ocConfig, INotificationManager $notificationManager, IUserSession $userSession, UserPluginManager $userPluginManager) { +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); - $this->ocConfig = $ocConfig; - $this->notificationManager = $notificationManager; - $this->userPluginManager = $userPluginManager; - $this->registerHooks($userSession); - } - - protected function registerHooks(IUserSession $userSession) { - $userSession->listen('\OC\User', 'preDelete', [$this, 'preDeleteUser']); - $userSession->listen('\OC\User', 'postDelete', [$this, 'postDeleteUser']); - } - - public function preDeleteUser(IUser $user) { - $this->currentUserInDeletionProcess = $user->getUID(); - } - - public function postDeleteUser() { - $this->currentUserInDeletionProcess = null; } /** - * checks whether the user is allowed to change his avatar in Nextcloud + * 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) { + if (!$user instanceof User) { return false; } - if($user->getAvatarImage() === false) { + $imageData = $user->getAvatarImage(); + if ($imageData === false) { return true; } - - return false; + return !$user->updateAvatar(true); } /** - * returns the username for the given login name, if available + * Return the username for the given login name, if available * * @param string $loginName * @return string|false + * @throws \Exception */ - public function loginName2UserName($loginName) { - $cacheKey = 'loginName2UserName-'.$loginName; + public function loginName2UserName($loginName, bool $forceLdapRefetch = false) { + $cacheKey = 'loginName2UserName-' . $loginName; $username = $this->access->connection->getFromCache($cacheKey); - if(!is_null($username)) { + + $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 instanceof OfflineUser) { + if ($user === null || $user instanceof OfflineUser) { // this path is not really possible, however get() is documented - // to return User or OfflineUser so we are very defensive here. + // 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 * @@ -162,9 +118,9 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn //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); + 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]; } @@ -177,32 +133,28 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn * @return false|string */ public function checkPassword($uid, $password) { - try { - $ldapRecord = $this->getLDAPUserByLoginName($uid); - } catch(NotOnLDAP $e) { - if($this->ocConfig->getSystemValue('loglevel', Util::WARN) === Util::DEBUG) { - \OC::$server->getLogger()->logException($e, ['app' => 'user_ldap']); - } + $username = $this->loginName2UserName($uid, true); + if ($username === false) { return false; } - $dn = $ldapRecord['dn'][0]; + $dn = $this->access->username2dn($username); $user = $this->access->userManager->get($dn); - if(!$user instanceof User) { - Util::writeLog('user_ldap', - 'LDAP Login: Could not get user object for DN ' . $dn . - '. Maybe the LDAP entry has no set display name attribute?', - Util::WARN); + 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) { + if ($user->getUsername() !== false) { //are the credentials OK? - if(!$this->access->areCredentialsValid($dn, $password)) { + if (!$this->access->areCredentialsValid($dn, $password)) { return false; } $this->access->cacheUserExists($user->getUsername()); - $user->processAttributes($ldapRecord); $user->markLogin(); return $user->getUsername(); @@ -224,14 +176,14 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn $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 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)) { + if ($user->getUsername() !== false && $this->access->setPassword($user->getDN(), $password)) { $ldapDefaultPPolicyDN = $this->access->connection->ldapDefaultPPolicyDN; $turnOnPasswordChange = $this->access->connection->turnOnPasswordChange; - if (!empty($ldapDefaultPPolicyDN) && (intval($turnOnPasswordChange) === 1)) { + if (!empty($ldapDefaultPPolicyDN) && ((int)$turnOnPasswordChange === 1)) { //remove last password expiry warning if any $notification = $this->notificationManager->createNotification(); $notification->setApp('user_ldap') @@ -256,35 +208,39 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn */ public function getUsers($search = '', $limit = 10, $offset = 0) { $search = $this->access->escapeFilterPart($search, true); - $cachekey = 'getUsers-'.$search.'-'.$limit.'-'.$offset; + $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)) { + 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) { + if ($limit <= 0) { $limit = null; } - $filter = $this->access->combineFilterWithAnd(array( + $filter = $this->access->combineFilterWithAnd([ $this->access->connection->ldapUserFilter, $this->access->connection->ldapUserDisplayName . '=*', $this->access->getFilterPartForUserSearch($search) - )); + ]); - Util::writeLog('user_ldap', - 'getUsers: Options: search '.$search.' limit '.$limit.' offset '.$offset.' Filter: '.$filter, - Util::DEBUG); + $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); - Util::writeLog('user_ldap', 'getUsers: '.count($ldap_users). ' Users found', Util::DEBUG); + $this->logger->debug( + 'getUsers: ' . count($ldap_users) . ' Users found', + ['app' => 'user_ldap'] + ); $this->access->connection->writeToCache($cachekey, $ldap_users); return $ldap_users; @@ -293,49 +249,56 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn /** * checks whether a user is still available on LDAP * - * @param string|\OCA\User_LDAP\User\User $user either the Nextcloud user - * name or an instance of that user - * @return bool + * @param string|User $user either the Nextcloud user + * name or an instance of that user * @throws \Exception * @throws \OC\ServerNotAvailableException */ - public function userExistsOnLDAP($user) { - if(is_string($user)) { + public function userExistsOnLDAP($user, bool $ignoreCache = false): bool { + if (is_string($user)) { $user = $this->access->userManager->get($user); } - if(is_null($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))) { - $lcr = $this->access->connection->getConnectionResource(); - if(is_null($lcr)) { - throw new \Exception('No LDAP Connection to server ' . $this->access->connection->ldapHost); - } - + if (!is_array($this->access->readAttribute($dn, '', $this->access->connection->ldapUserFilter))) { try { $uuid = $this->access->getUserMapper()->getUUIDByDN($dn); - if(!$uuid) { + 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(!is_array($this->access->readAttribute($newDn, '', $this->access->connection->ldapUserFilter))) { + 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); - return true; + } catch (ServerNotAvailableException $e) { + throw $e; } catch (\Exception $e) { + $this->access->connection->writeToCache($cacheKey, false); return false; } } - if($user instanceof OfflineUser) { + if ($user instanceof OfflineUser) { $user->unmark(); } + $this->access->connection->writeToCache($cacheKey, true); return true; } @@ -346,55 +309,67 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn * @throws \Exception when connection could not be established */ public function userExists($uid) { - $userExists = $this->access->connection->getFromCache('userExists'.$uid); - if(!is_null($userExists)) { + $userExists = $this->access->connection->getFromCache('userExists' . $uid); + if (!is_null($userExists)) { return (bool)$userExists; } - //getting dn, if false the user does not exist. If dn, he may be mapped only, requires more checking. - $user = $this->access->userManager->get($uid); + $userExists = $this->access->userManager->exists($uid); - if(is_null($user)) { - Util::writeLog('user_ldap', 'No DN found for '.$uid.' on '. - $this->access->connection->ldapHost, Util::DEBUG); - $this->access->connection->writeToCache('userExists'.$uid, false); + 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; - } else if($user instanceof OfflineUser) { - //express check for users marked as deleted. Returning true is - //necessary for cleanup - return true; } - $result = $this->userExistsOnLDAP($user); - $this->access->connection->writeToCache('userExists'.$uid, $result); - if($result === true) { - $user->update(); - } - return $result; + $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 - */ + * 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()) { - return $this->userPluginManager->deleteUser($uid); + $status = $this->userPluginManager->deleteUser($uid); + if ($status === false) { + return false; + } } - $marked = $this->ocConfig->getUserValue($uid, 'user_ldap', 'isDeleted', 0); - if(intval($marked) === 0) { - \OC::$server->getLogger()->notice( - 'User '.$uid . ' is not marked as deleted, not cleaning up.', - array('app' => 'user_ldap')); - 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; + } } - \OC::$server->getLogger()->info('Cleaning up after user ' . $uid, - array('app' => 'user_ldap')); + $this->logger->info('Cleaning up after user ' . $uid, + ['app' => 'user_ldap']); - $this->access->getUserMapper()->unmap($uid); + $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; } @@ -408,7 +383,7 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn */ public function getHome($uid) { // user Exists check required as it is not done in user proxy! - if(!$this->userExists($uid)) { + if (!$this->userExists($uid)) { return false; } @@ -416,29 +391,21 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn return $this->userPluginManager->getHome($uid); } - $cacheKey = 'getHome'.$uid; + $cacheKey = 'getHome' . $uid; $path = $this->access->connection->getFromCache($cacheKey); - if(!is_null($path)) { + if (!is_null($path)) { return $path; } // early return path if it is a deleted user $user = $this->access->userManager->get($uid); - if($user instanceof OfflineUser) { - if($this->currentUserInDeletionProcess !== null - && $this->currentUserInDeletionProcess === $user->getOCName() - ) { - return $user->getHomePath(); - } else { - throw new NoUserException($uid . ' is not a valid user anymore'); - } - } else if ($user === null) { + if ($user instanceof User || $user instanceof OfflineUser) { + $path = $user->getHomePath() ?: false; + } else { throw new NoUserException($uid . ' is not a valid user anymore'); } - $path = $user->getHomePath(); $this->access->cacheUserHome($uid, $path); - return $path; } @@ -452,12 +419,12 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn return $this->userPluginManager->getDisplayName($uid); } - if(!$this->userExists($uid)) { + if (!$this->userExists($uid)) { return false; } - $cacheKey = 'getDisplayName'.$uid; - if(!is_null($displayName = $this->access->connection->getFromCache($cacheKey))) { + $cacheKey = 'getDisplayName' . $uid; + if (!is_null($displayName = $this->access->connection->getFromCache($cacheKey))) { return $displayName; } @@ -474,20 +441,19 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn $this->access->username2dn($uid), $this->access->connection->ldapUserDisplayName); - if($displayName && (count($displayName) > 0)) { + if ($displayName && (count($displayName) > 0)) { $displayName = $displayName[0]; - if (is_array($displayName2)){ + if (is_array($displayName2)) { $displayName2 = count($displayName2) > 0 ? $displayName2[0] : ''; } $user = $this->access->userManager->get($uid); if ($user instanceof User) { - $displayName = $user->composeAndStoreDisplayName($displayName, $displayName2); + $displayName = $user->composeAndStoreDisplayName($displayName, (string)$displayName2); $this->access->connection->writeToCache($cacheKey, $displayName); } if ($user instanceof OfflineUser) { - /** @var OfflineUser $user*/ $displayName = $user->getDisplayName(); } return $displayName; @@ -504,7 +470,9 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn */ public function setDisplayName($uid, $displayName) { if ($this->userPluginManager->implementsActions(Backend::SET_DISPLAYNAME)) { - return $this->userPluginManager->setDisplayName($uid, $displayName); + $this->userPluginManager->setDisplayName($uid, $displayName); + $this->access->cacheUserDisplayName($uid, $displayName); + return $displayName; } return false; } @@ -513,17 +481,17 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn * Get a list of all display names * * @param string $search - * @param string|null $limit - * @param string|null $offset + * @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))) { + $cacheKey = 'getDisplayNames-' . $search . '-' . $limit . '-' . $offset; + if (!is_null($displayNames = $this->access->connection->getFromCache($cacheKey))) { return $displayNames; } - $displayNames = array(); + $displayNames = []; $users = $this->getUsers($search, $limit, $offset); foreach ($users as $user) { $displayNames[$user] = $this->getDisplayName($user); @@ -533,20 +501,20 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn } /** - * 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. - */ + * 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 - | Backend::PROVIDE_AVATAR + | (($this->access->connection->ldapUserAvatarRule !== 'none') ? Backend::PROVIDE_AVATAR : 0) | Backend::COUNT_USERS - | ((intval($this->access->connection->turnOnPasswordChange) === 1)?(Backend::SET_PASSWORD):0) + | (((int)$this->access->connection->turnOnPasswordChange === 1)? Backend::SET_PASSWORD :0) | $this->userPluginManager->getImplementedActions()) & $actions); } @@ -560,32 +528,34 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn /** * counts the users in LDAP - * - * @return int|bool */ - public function countUsers() { + 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; - if(!is_null($entries = $this->access->connection->getFromCache($cacheKey))) { + $cacheKey = 'countUsers-' . $filter . '-' . $limit; + if (!is_null($entries = $this->access->connection->getFromCache($cacheKey))) { return $entries; } - $entries = $this->access->countUsers($filter); + $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(){ + public function getBackendName() { return 'LDAP'; } - + /** * Return access for LDAP interaction. * @param string $uid @@ -594,13 +564,13 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn 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 resource of the LDAP connection + * @return \LDAP\Connection The LDAP connection */ public function getNewLDAPConnection($uid) { $connection = clone $this->access->getConnection(); @@ -611,13 +581,55 @@ class User_LDAP extends BackendUtility implements \OCP\IUserBackend, \OCP\UserIn * create new user * @param string $username username of the new user * @param string $password password of the new user - * @return bool was the user created? + * @throws \UnexpectedValueException + * @return bool */ public function createUser($username, $password) { if ($this->userPluginManager->implementsActions(Backend::CREATE_USER)) { - return $this->userPluginManager->createUser($username, $password); + 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 index ccc82760b02..0d41f495ce9 100644 --- a/apps/user_ldap/lib/User_Proxy.php +++ b/apps/user_ldap/lib/User_Proxy.php @@ -1,85 +1,72 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roger Szabo <roger.szabo@web.de> - * @author root <root@localhost.localdomain> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @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; +use OCA\User_LDAP\User\DeletedUsersIndex; +use OCA\User_LDAP\User\OfflineUser; use OCA\User_LDAP\User\User; -use OCP\IConfig; -use OCP\IUserSession; +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_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, IUserLDAP { - private $backends = array(); - private $refBackend = null; +/** + * @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); + } - /** - * Constructor - * - * @param array $serverConfigPrefixes array containing the config Prefixes - * @param ILDAPWrapper $ldap - * @param IConfig $ocConfig - * @param INotificationManager $notificationManager - * @param IUserSession $userSession - */ - public function __construct(array $serverConfigPrefixes, ILDAPWrapper $ldap, IConfig $ocConfig, - INotificationManager $notificationManager, IUserSession $userSession, - UserPluginManager $userPluginManager) { - parent::__construct($ldap); - foreach($serverConfigPrefixes as $configPrefix) { - $this->backends[$configPrefix] = - new User_LDAP($this->getAccess($configPrefix), $ocConfig, $notificationManager, $userSession, $userPluginManager); - - if(is_null($this->refBackend)) { - $this->refBackend = &$this->backends[$configPrefix]; - } - } + 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 $uid the uid connected to 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 * @return mixed the result of the method or false */ - protected function walkBackends($uid, $method, $parameters) { + protected function walkBackends($id, $method, $parameters) { + $this->setup(); + + $uid = $id; $cacheKey = $this->getUserCacheKey($uid); - foreach($this->backends as $configPrefix => $backend) { + foreach ($this->backends as $configPrefix => $backend) { $instance = $backend; - if(!method_exists($instance, $method) + if (!method_exists($instance, $method) && method_exists($this->getAccess($configPrefix), $method)) { $instance = $this->getAccess($configPrefix); } - if($result = call_user_func_array(array($instance, $method), $parameters)) { - $this->writeToCache($cacheKey, $configPrefix); + if ($result = call_user_func_array([$instance, $method], $parameters)) { + if (!$this->isSingleBackend()) { + $this->writeToCache($cacheKey, $configPrefix); + } return $result; } } @@ -88,32 +75,36 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, /** * Asks the backend connected to the server that supposely takes care of the uid from the request. - * @param string $uid the uid connected to 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($uid, $method, $parameters, $passOnWhen) { + 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])) { + if (!is_null($prefix)) { + if (isset($this->backends[$prefix])) { $instance = $this->backends[$prefix]; - if(!method_exists($instance, $method) + if (!method_exists($instance, $method) && method_exists($this->getAccess($prefix), $method)) { $instance = $this->getAccess($prefix); } - $result = call_user_func_array(array($instance, $method), $parameters); - if($result === $passOnWhen) { + $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( - array($this->backends[$prefix], 'userExists'), - array($uid) + [$this->backends[$prefix], 'userExistsOnLDAP'], + [$uid] ); - if(!$userExists) { + if (!$userExists) { $this->writeToCache($cacheKey, null); } } @@ -123,8 +114,14 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, 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 * @@ -132,15 +129,18 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, * 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(); } @@ -153,9 +153,11 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, * @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 = array(); - foreach($this->backends as $backend) { + $users = []; + foreach ($this->backends as $backend) { $backendUsers = $backend->getUsers($search, $limit, $offset); if (is_array($backendUsers)) { $users = array_merge($users, $backendUsers); @@ -166,26 +168,43 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, /** * check if a user exists + * * @param string $uid the username * @return boolean */ public function userExists($uid) { - return $this->handleRequest($uid, 'userExists', array($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|\OCA\User_LDAP\User\User $user either the Nextcloud user - * name or an instance of that user - * @return boolean + * + * @param string|User $user either the Nextcloud user + * name or an instance of that user */ - public function userExistsOnLDAP($user) { + public function userExistsOnLDAP($user, bool $ignoreCache = false): bool { $id = ($user instanceof User) ? $user->getUsername() : $user; - return $this->handleRequest($id, 'userExistsOnLDAP', array($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 @@ -193,7 +212,7 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, * Check if the password is correct without logging in the user */ public function checkPassword($uid, $password) { - return $this->handleRequest($uid, 'checkPassword', array($uid, $password)); + return $this->handleRequest($uid, 'checkPassword', [$uid, $password]); } /** @@ -204,9 +223,9 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, */ public function loginName2UserName($loginName) { $id = 'LOGINNAME,' . $loginName; - return $this->handleRequest($id, 'loginName2UserName', array($loginName)); + return $this->handleRequest($id, 'loginName2UserName', [$loginName]); } - + /** * returns the username for the given LDAP DN, if available * @@ -215,25 +234,27 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, */ public function dn2UserName($dn) { $id = 'DN,' . $dn; - return $this->handleRequest($id, 'dn2UserName', array($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', array($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', array($uid)); + return $this->handleRequest($uid, 'getDisplayName', [$uid]); } /** @@ -244,29 +265,33 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, * @return string display name */ public function setDisplayName($uid, $displayName) { - return $this->handleRequest($uid, 'setDisplayName', array($uid, $displayName)); + return $this->handleRequest($uid, 'setDisplayName', [$uid, $displayName]); } /** - * checks whether the user is allowed to change his avatar in Nextcloud + * 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', array($uid)); + return $this->handleRequest($uid, 'canChangeAvatar', [$uid], true); } /** * Get a list of all display names and user ids. + * * @param string $search - * @param string|null $limit - * @param string|null $offset + * @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 = array(); - foreach($this->backends as $backend) { + $users = []; + foreach ($this->backends as $backend) { $backendUsers = $backend->getDisplayNames($search, $limit, $offset); if (is_array($backendUsers)) { $users = $users + $backendUsers; @@ -277,74 +302,133 @@ class User_Proxy extends Proxy implements \OCP\IUserBackend, \OCP\UserInterface, /** * 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', array($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', array($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 - * @return int|bool */ - public function countUsers() { + public function countUsers(int $limit = 0): int|false { + $this->setup(); + $users = false; - foreach($this->backends as $backend) { - $backendUsers = $backend->countUsers(); + foreach ($this->backends as $backend) { + $backendUsers = $backend->countUsers($limit); if ($backendUsers !== false) { - $users += $backendUsers; + $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', array($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 resource of the LDAP connection + * @return \LDAP\Connection The LDAP connection */ public function getNewLDAPConnection($uid) { - return $this->handleRequest($uid, 'getNewLDAPConnection', array($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', array($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 bf7c6bbeb93..15a9f9cb212 100644 --- a/apps/user_ldap/lib/Wizard.php +++ b/apps/user_ldap/lib/Wizard.php @@ -1,85 +1,54 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Alexander Bergolth <leo@strike.wu.ac.at> - * @author Allan Nordhøy <epost@anotheragency.no> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Jean-Louis Dupond <jean-louis@dupond.be> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nicolas Grekas <nicolas.grekas@gmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Tobias Brunner <tobias@tobru.ch> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Xuanwo <xuanwo@yunify.com> - * - * @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; 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 { - /** @var \OCP\IL10N */ - 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 - * @param Access $access - */ - 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(); } } @@ -89,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); } - 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; } @@ -170,56 +145,50 @@ 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; } @@ -227,8 +196,8 @@ class Wizard extends LDAPUtility { 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 meanwhile $this->result->addChange('ldap_display_name', $attr); @@ -237,15 +206,15 @@ class Wizard extends LDAPUtility { } // 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.')); } @@ -257,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 ($attr !== '') { - $count = intval($this->countUsersWithAttribute($attr, true)); - if($count > 0) { + $count = (int)$this->countUsersWithAttribute($attr, true); + if ($count > 0) { return false; } $writeLog = true; @@ -276,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'] + ); } } @@ -300,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); } @@ -329,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]; } @@ -367,8 +346,8 @@ class Wizard extends LDAPUtility { */ public function determineGroupsForGroups() { return $this->determineGroups('ldap_groupfilter_groups', - 'ldapGroupFilterGroups', - false); + 'ldapGroupFilterGroups', + false); } /** @@ -377,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'); } } @@ -416,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', 'groupOfUniqueNames'); + 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; } @@ -453,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 { @@ -461,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; @@ -491,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', 'groupOfUniqueNames', '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; } @@ -548,10 +528,11 @@ 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 @@ -559,7 +540,7 @@ class Wizard extends LDAPUtility { 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); @@ -572,10 +553,11 @@ 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 @@ -585,7 +567,7 @@ class Wizard extends LDAPUtility { $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'); } @@ -594,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'); } @@ -616,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); @@ -655,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 { @@ -680,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; @@ -688,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; } @@ -708,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; } @@ -728,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(\OC::$server->getConfig()); + $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); @@ -754,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]); } /** @@ -764,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', 'gidNumber' - * @return string|false, string with the attribute name, false on error + * @return string|false string with the attribute name, false on error * @throws \Exception */ private function detectGroupMemberAssoc() { - $possibleAttrs = array('uniqueMember', 'memberUid', 'member', 'gidNumber'); + $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); } @@ -826,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); } @@ -855,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; @@ -869,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 ($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; @@ -924,7 +910,7 @@ class Wizard extends LDAPUtility { $parts++; } //wrap parts in AND condition - if($parts > 1) { + if ($parts > 1) { $filter = '(&' . $filter . ')'; } if ($filter === '') { @@ -935,26 +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 .= '(|'; - 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; @@ -964,16 +950,19 @@ 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'; } @@ -984,16 +973,16 @@ class Wizard extends LDAPUtility { } $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 .= ')'; @@ -1001,21 +990,24 @@ 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; } @@ -1025,21 +1017,24 @@ class Wizard extends LDAPUtility { * * @param int $port the port to connect with * @param bool $tls whether startTLS is to be used - * @return bool * @throws \Exception */ - private function connectAndBind($port, $tls) { + private function connectAndBind(int $port, bool $tls): bool { //connect, does not really trigger any server communication $host = $this->configuration->ldapHost; - $hostInfo = parse_url($host); - if(!$hostInfo) { + $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)) { + $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 */ //set LDAP options $this->ldap->setOption($cr, LDAP_OPT_PROTOCOL_VERSION, 3); @@ -1047,33 +1042,38 @@ class Wizard extends LDAPUtility { $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); - \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) { + if ($errNo === -1) { //host, port or TLS wrong return false; } @@ -1083,27 +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 ($agent !== '' && $pwd !== '') - || ($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; } } @@ -1116,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; } @@ -1159,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); } } @@ -1191,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 = ''; @@ -1212,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 @@ -1226,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 && $maxEntryObjC !== '') { + } elseif ($po && $maxEntryObjC !== '') { //pre-select objectclass with most result entries $maxEntryObjC = str_replace($p, '', $maxEntryObjC); $this->applyFind($dbkey, $maxEntryObjC); @@ -1241,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; } } @@ -1273,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; } @@ -1285,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; } @@ -1304,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 index e5294a76506..d6fd67d4204 100644 --- a/apps/user_ldap/lib/WizardResult.php +++ b/apps/user_ldap/lib/WizardResult.php @@ -1,35 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @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: 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 = array(); - protected $options = array(); + protected $changes = []; + protected $options = []; protected $markedChange = false; /** @@ -40,9 +20,7 @@ class WizardResult { $this->changes[$key] = $value; } - /** - * - */ + public function markChange() { $this->markedChange = true; } @@ -52,8 +30,8 @@ class WizardResult { * @param array|string $values */ public function addOptions($key, $values) { - if(!is_array($values)) { - $values = array($values); + if (!is_array($values)) { + $values = [$values]; } $this->options[$key] = $values; } @@ -69,9 +47,9 @@ class WizardResult { * @return array */ public function getResultArray() { - $result = array(); + $result = []; $result['changes'] = $this->changes; - if(count($this->options) > 0) { + if (count($this->options) > 0) { $result['options'] = $this->options; } return $result; |