diff options
Diffstat (limited to 'lib/private/User')
-rw-r--r-- | lib/private/User/AvailabilityCoordinator.php | 126 | ||||
-rw-r--r-- | lib/private/User/Backend.php | 33 | ||||
-rw-r--r-- | lib/private/User/BackgroundJobs/CleanupDeletedUsers.php | 63 | ||||
-rw-r--r-- | lib/private/User/Database.php | 428 | ||||
-rw-r--r-- | lib/private/User/DisabledUserException.php | 10 | ||||
-rw-r--r-- | lib/private/User/DisplayNameCache.php | 84 | ||||
-rw-r--r-- | lib/private/User/LazyUser.php | 178 | ||||
-rw-r--r-- | lib/private/User/Listeners/BeforeUserDeletedListener.php | 55 | ||||
-rw-r--r-- | lib/private/User/Listeners/UserChangedListener.php | 47 | ||||
-rw-r--r-- | lib/private/User/LoginException.php | 24 | ||||
-rw-r--r-- | lib/private/User/Manager.php | 574 | ||||
-rw-r--r-- | lib/private/User/NoUserException.php | 24 | ||||
-rw-r--r-- | lib/private/User/OutOfOfficeData.php | 72 | ||||
-rw-r--r-- | lib/private/User/PartiallyDeletedUsersBackend.php | 56 | ||||
-rw-r--r-- | lib/private/User/Session.php | 343 | ||||
-rw-r--r-- | lib/private/User/User.php | 548 |
16 files changed, 1849 insertions, 816 deletions
diff --git a/lib/private/User/AvailabilityCoordinator.php b/lib/private/User/AvailabilityCoordinator.php new file mode 100644 index 00000000000..988f2e55260 --- /dev/null +++ b/lib/private/User/AvailabilityCoordinator.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\User; + +use JsonException; +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Service\AbsenceService; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IUser; +use OCP\User\IAvailabilityCoordinator; +use OCP\User\IOutOfOfficeData; +use Psr\Log\LoggerInterface; + +class AvailabilityCoordinator implements IAvailabilityCoordinator { + private ICache $cache; + + public function __construct( + ICacheFactory $cacheFactory, + private IConfig $config, + private AbsenceService $absenceService, + private LoggerInterface $logger, + private TimezoneService $timezoneService, + ) { + $this->cache = $cacheFactory->createLocal('OutOfOfficeData'); + } + + public function isEnabled(): bool { + return $this->config->getAppValue(Application::APP_ID, 'hide_absence_settings', 'no') === 'no'; + } + + private function getCachedOutOfOfficeData(IUser $user): ?OutOfOfficeData { + $cachedString = $this->cache->get($user->getUID()); + if ($cachedString === null) { + return null; + } + + try { + $cachedData = json_decode($cachedString, true, 10, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->error('Failed to deserialize cached out-of-office data: ' . $e->getMessage(), [ + 'exception' => $e, + 'json' => $cachedString, + ]); + return null; + } + + return new OutOfOfficeData( + $cachedData['id'], + $user, + $cachedData['startDate'], + $cachedData['endDate'], + $cachedData['shortMessage'], + $cachedData['message'], + $cachedData['replacementUserId'], + $cachedData['replacementUserDisplayName'], + ); + } + + private function setCachedOutOfOfficeData(IOutOfOfficeData $data): void { + try { + $cachedString = json_encode([ + 'id' => $data->getId(), + 'startDate' => $data->getStartDate(), + 'endDate' => $data->getEndDate(), + 'shortMessage' => $data->getShortMessage(), + 'message' => $data->getMessage(), + 'replacementUserId' => $data->getReplacementUserId(), + 'replacementUserDisplayName' => $data->getReplacementUserDisplayName(), + ], JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->error('Failed to serialize out-of-office data: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + return; + } + + $this->cache->set($data->getUser()->getUID(), $cachedString, 300); + } + + public function getCurrentOutOfOfficeData(IUser $user): ?IOutOfOfficeData { + $timezone = $this->getCachedTimezone($user->getUID()); + if ($timezone === null) { + $timezone = $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(); + $this->setCachedTimezone($user->getUID(), $timezone); + } + + $data = $this->getCachedOutOfOfficeData($user); + if ($data === null) { + $absenceData = $this->absenceService->getAbsence($user->getUID()); + if ($absenceData === null) { + return null; + } + $data = $absenceData->toOutOufOfficeData($user, $timezone); + } + + $this->setCachedOutOfOfficeData($data); + return $data; + } + + private function getCachedTimezone(string $userId): ?string { + return $this->cache->get($userId . '_timezone') ?? null; + } + + private function setCachedTimezone(string $userId, string $timezone): void { + $this->cache->set($userId . '_timezone', $timezone, 3600); + } + + public function clearCache(string $userId): void { + $this->cache->set($userId, null, 300); + $this->cache->set($userId . '_timezone', null, 3600); + } + + public function isInEffect(IOutOfOfficeData $data): bool { + return $this->absenceService->isInEffect($data); + } +} diff --git a/lib/private/User/Backend.php b/lib/private/User/Backend.php index c87dc5d2d50..9b6a9a890ef 100644 --- a/lib/private/User/Backend.php +++ b/lib/private/User/Backend.php @@ -1,27 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OC\User; use OCP\UserInterface; @@ -124,9 +107,9 @@ abstract class Backend implements UserInterface { /** * get the user's home directory * @param string $uid the username - * @return boolean + * @return string|boolean */ - public function getHome($uid) { + public function getHome(string $uid) { return false; } @@ -143,8 +126,8 @@ abstract class Backend implements UserInterface { * 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) { diff --git a/lib/private/User/BackgroundJobs/CleanupDeletedUsers.php b/lib/private/User/BackgroundJobs/CleanupDeletedUsers.php new file mode 100644 index 00000000000..f999031a2a9 --- /dev/null +++ b/lib/private/User/BackgroundJobs/CleanupDeletedUsers.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\User\BackgroundJobs; + +use OC\User\Manager; +use OC\User\PartiallyDeletedUsersBackend; +use OC\User\User; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +class CleanupDeletedUsers extends TimedJob { + public function __construct( + ITimeFactory $time, + private Manager $userManager, + private IConfig $config, + private LoggerInterface $logger, + ) { + parent::__construct($time); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + $this->setInterval(24 * 60 * 60); + } + + protected function run($argument): void { + $backend = new PartiallyDeletedUsersBackend($this->config); + $users = $backend->getUsers(); + + if (empty($users)) { + $this->logger->debug('No failed deleted users found.'); + return; + } + + foreach ($users as $userId) { + if ($this->userManager->userExists($userId)) { + $this->logger->info('Skipping user {userId}, marked as deleted, as they still exists in user backend.', ['userId' => $userId]); + $backend->unmarkUser($userId); + continue; + } + + try { + $user = new User( + $userId, + $backend, + \OCP\Server::get(IEventDispatcher::class), + $this->userManager, + $this->config, + ); + $user->delete(); + $this->logger->info('Cleaned up deleted user {userId}', ['userId' => $userId]); + } catch (\Throwable $error) { + $this->logger->warning('Could not cleanup deleted user {userId}', ['userId' => $userId, 'exception' => $error]); + } + } + } +} diff --git a/lib/private/User/Database.php b/lib/private/User/Database.php index 85e22d196e4..31488247939 100644 --- a/lib/private/User/Database.php +++ b/lib/private/User/Database.php @@ -1,75 +1,31 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author adrien <adrien.waksberg@believedigital.com> - * @author Aldo "xoen" Giambelluca <xoen@xoen.org> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author fabian <fabian@web2.0-apps.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Loki3000 <github@labcms.ru> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author nishiki <nishiki@yaegashi.fr> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.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/> - * - */ - -/* - * - * The following SQL statement is just a help for developers and will not be - * executed! - * - * CREATE TABLE `users` ( - * `uid` varchar(64) COLLATE utf8_unicode_ci NOT NULL, - * `password` varchar(255) COLLATE utf8_unicode_ci NOT NULL, - * PRIMARY KEY (`uid`) - * ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\User; -use OC\Cache\CappedMemoryCache; +use InvalidArgumentException; +use OCP\AppFramework\Db\TTransactional; +use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; use OCP\IDBConnection; +use OCP\IUserManager; use OCP\Security\Events\ValidatePasswordPolicyEvent; +use OCP\Security\IHasher; use OCP\User\Backend\ABackend; use OCP\User\Backend\ICheckPasswordBackend; -use OCP\User\Backend\ICountUsersBackend; use OCP\User\Backend\ICreateUserBackend; use OCP\User\Backend\IGetDisplayNameBackend; use OCP\User\Backend\IGetHomeBackend; use OCP\User\Backend\IGetRealUIDBackend; +use OCP\User\Backend\ILimitAwareCountUsersBackend; +use OCP\User\Backend\IPasswordHashBackend; +use OCP\User\Backend\ISearchKnownUsersBackend; use OCP\User\Backend\ISetDisplayNameBackend; use OCP\User\Backend\ISetPasswordBackend; @@ -78,24 +34,23 @@ use OCP\User\Backend\ISetPasswordBackend; */ class Database extends ABackend implements ICreateUserBackend, - ISetPasswordBackend, - ISetDisplayNameBackend, - IGetDisplayNameBackend, - ICheckPasswordBackend, - IGetHomeBackend, - ICountUsersBackend, - IGetRealUIDBackend { - /** @var CappedMemoryCache */ - private $cache; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var IDBConnection */ - private $dbConn; - - /** @var string */ - private $table; + ISetPasswordBackend, + ISetDisplayNameBackend, + IGetDisplayNameBackend, + ICheckPasswordBackend, + IGetHomeBackend, + ILimitAwareCountUsersBackend, + ISearchKnownUsersBackend, + IGetRealUIDBackend, + IPasswordHashBackend { + + private CappedMemoryCache $cache; + private IConfig $config; + private ?IDBConnection $dbConnection; + private IEventDispatcher $eventDispatcher; + private string $table; + + use TTransactional; /** * \OC\User\Database constructor. @@ -106,16 +61,19 @@ class Database extends ABackend implements public function __construct($eventDispatcher = null, $table = 'users') { $this->cache = new CappedMemoryCache(); $this->table = $table; - $this->eventDispatcher = $eventDispatcher ? $eventDispatcher : \OC::$server->query(IEventDispatcher::class); + $this->eventDispatcher = $eventDispatcher ?? \OCP\Server::get(IEventDispatcher::class); + $this->config = \OCP\Server::get(IConfig::class); + $this->dbConnection = null; } /** * FIXME: This function should not be required! */ - private function fixDI() { - if ($this->dbConn === null) { - $this->dbConn = \OC::$server->getDatabaseConnection(); + private function getDbConnection() { + if ($this->dbConnection === null) { + $this->dbConnection = \OCP\Server::get(IDBConnection::class); } + return $this->dbConnection; } /** @@ -129,48 +87,54 @@ class Database extends ABackend implements * itself, not in its subclasses. */ public function createUser(string $uid, string $password): bool { - $this->fixDI(); + if ($this->userExists($uid)) { + return false; + } - if (!$this->userExists($uid)) { - $this->eventDispatcher->dispatchTyped(new ValidatePasswordPolicyEvent($password)); + $this->eventDispatcher->dispatchTyped(new ValidatePasswordPolicyEvent($password)); - $qb = $this->dbConn->getQueryBuilder(); + $dbConn = $this->getDbConnection(); + return $this->atomic(function () use ($uid, $password, $dbConn) { + $qb = $dbConn->getQueryBuilder(); $qb->insert($this->table) ->values([ 'uid' => $qb->createNamedParameter($uid), - 'password' => $qb->createNamedParameter(\OC::$server->getHasher()->hash($password)), + 'password' => $qb->createNamedParameter(\OCP\Server::get(IHasher::class)->hash($password)), 'uid_lower' => $qb->createNamedParameter(mb_strtolower($uid)), ]); - $result = $qb->execute(); + $result = $qb->executeStatement(); // Clear cache unset($this->cache[$uid]); + // Repopulate the cache + $this->loadUser($uid); - return $result ? true : false; - } - - return false; + return (bool)$result; + }, $dbConn); } /** - * delete a user + * Deletes a user * * @param string $uid The username of the user to delete * @return bool - * - * Deletes a user */ public function deleteUser($uid) { - $this->fixDI(); - // Delete user-group-relation - $query = $this->dbConn->getQueryBuilder(); + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); $query->delete($this->table) ->where($query->expr()->eq('uid_lower', $query->createNamedParameter(mb_strtolower($uid)))); - $result = $query->execute(); + $result = $query->executeStatement(); if (isset($this->cache[$uid])) { + // If the user logged in through email there is a second cache entry, also unset that. + $email = $this->cache[$uid]['email'] ?? null; + if ($email !== null) { + unset($this->cache[$email]); + } + // Unset the cache entry unset($this->cache[$uid]); } @@ -178,11 +142,12 @@ class Database extends ABackend implements } private function updatePassword(string $uid, string $passwordHash): bool { - $query = $this->dbConn->getQueryBuilder(); + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); $query->update($this->table) ->set('password', $query->createNamedParameter($passwordHash)) ->where($query->expr()->eq('uid_lower', $query->createNamedParameter(mb_strtolower($uid)))); - $result = $query->execute(); + $result = $query->executeStatement(); return $result ? true : false; } @@ -197,18 +162,59 @@ class Database extends ABackend implements * Change the password of a user */ public function setPassword(string $uid, string $password): bool { - $this->fixDI(); + if (!$this->userExists($uid)) { + return false; + } - if ($this->userExists($uid)) { - $this->eventDispatcher->dispatchTyped(new ValidatePasswordPolicyEvent($password)); + $this->eventDispatcher->dispatchTyped(new ValidatePasswordPolicyEvent($password)); + + $hasher = \OCP\Server::get(IHasher::class); + $hashedPassword = $hasher->hash($password); - $hasher = \OC::$server->getHasher(); - $hashedPassword = $hasher->hash($password); + $return = $this->updatePassword($uid, $hashedPassword); - return $this->updatePassword($uid, $hashedPassword); + if ($return) { + $this->cache[$uid]['password'] = $hashedPassword; } - return false; + return $return; + } + + public function getPasswordHash(string $userId): ?string { + if (!$this->userExists($userId)) { + return null; + } + if (!empty($this->cache[$userId]['password'])) { + return $this->cache[$userId]['password']; + } + + $dbConn = $this->getDbConnection(); + $qb = $dbConn->getQueryBuilder(); + $qb->select('password') + ->from($this->table) + ->where($qb->expr()->eq('uid_lower', $qb->createNamedParameter(mb_strtolower($userId)))); + /** @var false|string $hash */ + $hash = $qb->executeQuery()->fetchOne(); + if ($hash === false) { + return null; + } + + $this->cache[$userId]['password'] = $hash; + return $hash; + } + + public function setPasswordHash(string $userId, string $passwordHash): bool { + if (!\OCP\Server::get(IHasher::class)->validate($passwordHash)) { + throw new InvalidArgumentException(); + } + + $result = $this->updatePassword($userId, $passwordHash); + if (!$result) { + return false; + } + + $this->cache[$userId]['password'] = $passwordHash; + return true; } /** @@ -218,24 +224,29 @@ class Database extends ABackend implements * @param string $displayName The new display name * @return bool * + * @throws \InvalidArgumentException + * * Change the display name of a user */ public function setDisplayName(string $uid, string $displayName): bool { - $this->fixDI(); + if (mb_strlen($displayName) > 64) { + throw new \InvalidArgumentException('Invalid displayname'); + } - if ($this->userExists($uid)) { - $query = $this->dbConn->getQueryBuilder(); - $query->update($this->table) - ->set('displayname', $query->createNamedParameter($displayName)) - ->where($query->expr()->eq('uid_lower', $query->createNamedParameter(mb_strtolower($uid)))); - $query->execute(); + if (!$this->userExists($uid)) { + return false; + } - $this->cache[$uid]['displayname'] = $displayName; + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); + $query->update($this->table) + ->set('displayname', $query->createNamedParameter($displayName)) + ->where($query->expr()->eq('uid_lower', $query->createNamedParameter(mb_strtolower($uid)))); + $query->executeStatement(); - return true; - } + $this->cache[$uid]['displayname'] = $displayName; - return false; + return true; } /** @@ -254,16 +265,15 @@ class Database extends ABackend implements * 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) { $limit = $this->fixLimit($limit); - $this->fixDI(); - - $query = $this->dbConn->getQueryBuilder(); + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); $query->select('uid', 'displayname') ->from($this->table, 'u') @@ -273,15 +283,54 @@ class Database extends ABackend implements $query->expr()->eq('configkey', $query->expr()->literal('email'))) ) // sqlite doesn't like re-using a single named parameter here - ->where($query->expr()->iLike('uid', $query->createPositionalParameter('%' . $this->dbConn->escapeLikeParameter($search) . '%'))) - ->orWhere($query->expr()->iLike('displayname', $query->createPositionalParameter('%' . $this->dbConn->escapeLikeParameter($search) . '%'))) - ->orWhere($query->expr()->iLike('configvalue', $query->createPositionalParameter('%' . $this->dbConn->escapeLikeParameter($search) . '%'))) + ->where($query->expr()->iLike('uid', $query->createPositionalParameter('%' . $dbConn->escapeLikeParameter($search) . '%'))) + ->orWhere($query->expr()->iLike('displayname', $query->createPositionalParameter('%' . $dbConn->escapeLikeParameter($search) . '%'))) + ->orWhere($query->expr()->iLike('configvalue', $query->createPositionalParameter('%' . $dbConn->escapeLikeParameter($search) . '%'))) ->orderBy($query->func()->lower('displayname'), 'ASC') - ->orderBy('uid_lower', 'ASC') + ->addOrderBy('uid_lower', 'ASC') ->setMaxResults($limit) ->setFirstResult($offset); - $result = $query->execute(); + $result = $query->executeQuery(); + $displayNames = []; + while ($row = $result->fetch()) { + $displayNames[(string)$row['uid']] = (string)$row['displayname']; + } + + return $displayNames; + } + + /** + * @param string $searcher + * @param string $pattern + * @param int|null $limit + * @param int|null $offset + * @return array + * @since 21.0.1 + */ + public function searchKnownUsersByDisplayName(string $searcher, string $pattern, ?int $limit = null, ?int $offset = null): array { + $limit = $this->fixLimit($limit); + + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); + + $query->select('u.uid', 'u.displayname') + ->from($this->table, 'u') + ->leftJoin('u', 'known_users', 'k', $query->expr()->andX( + $query->expr()->eq('k.known_user', 'u.uid'), + $query->expr()->eq('k.known_to', $query->createNamedParameter($searcher)) + )) + ->where($query->expr()->eq('k.known_to', $query->createNamedParameter($searcher))) + ->andWhere($query->expr()->orX( + $query->expr()->iLike('u.uid', $query->createNamedParameter('%' . $dbConn->escapeLikeParameter($pattern) . '%')), + $query->expr()->iLike('u.displayname', $query->createNamedParameter('%' . $dbConn->escapeLikeParameter($pattern) . '%')) + )) + ->orderBy('u.displayname', 'ASC') + ->addOrderBy('u.uid_lower', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + $result = $query->executeQuery(); $displayNames = []; while ($row = $result->fetch()) { $displayNames[(string)$row['uid']] = (string)$row['displayname']; @@ -293,7 +342,7 @@ class Database extends ABackend implements /** * Check if the password is correct * - * @param string $loginName The loginname + * @param string $loginName The login name * @param string $password The password * @return string * @@ -301,28 +350,16 @@ class Database extends ABackend implements * returns the user id or false */ public function checkPassword(string $loginName, string $password) { - $this->fixDI(); - - $qb = $this->dbConn->getQueryBuilder(); - $qb->select('uid', 'password') - ->from($this->table) - ->where( - $qb->expr()->eq( - 'uid_lower', $qb->createNamedParameter(mb_strtolower($loginName)) - ) - ); - $result = $qb->execute(); - $row = $result->fetch(); - $result->closeCursor(); + $found = $this->loadUser($loginName); - if ($row) { - $storedHash = $row['password']; + if ($found && is_array($this->cache[$loginName])) { + $storedHash = $this->cache[$loginName]['password']; $newHash = ''; - if (\OC::$server->getHasher()->verify($password, $storedHash, $newHash)) { + if (\OCP\Server::get(IHasher::class)->verify($password, $storedHash, $newHash)) { if (!empty($newHash)) { $this->updatePassword($loginName, $newHash); } - return (string)$row['uid']; + return (string)$this->cache[$loginName]['uid']; } } @@ -332,44 +369,64 @@ class Database extends ABackend implements /** * Load an user in the cache * - * @param string $uid the username + * @param string $loginName the username or email * @return boolean true if user was found, false otherwise */ - private function loadUser($uid) { - $this->fixDI(); + private function loadUser(string $loginName, bool $tryEmail = true): bool { + if (isset($this->cache[$loginName])) { + return $this->cache[$loginName] !== false; + } - $uid = (string)$uid; - if (!isset($this->cache[$uid])) { - //guests $uid could be NULL or '' - if ($uid === '') { - $this->cache[$uid] = false; - return true; - } + //guests $uid could be NULL or '' + if ($loginName === '') { + $this->cache[$loginName] = false; + return false; + } - $qb = $this->dbConn->getQueryBuilder(); - $qb->select('uid', 'displayname') - ->from($this->table) - ->where( - $qb->expr()->eq( - 'uid_lower', $qb->createNamedParameter(mb_strtolower($uid)) - ) - ); - $result = $qb->execute(); - $row = $result->fetch(); - $result->closeCursor(); - - $this->cache[$uid] = false; - - // "uid" is primary key, so there can only be a single result - if ($row !== false) { - $this->cache[$uid]['uid'] = (string)$row['uid']; - $this->cache[$uid]['displayname'] = (string)$row['displayname']; - } else { - return false; + $dbConn = $this->getDbConnection(); + $qb = $dbConn->getQueryBuilder(); + $qb->select('uid', 'displayname', 'password') + ->from($this->table) + ->where( + $qb->expr()->eq( + 'uid_lower', $qb->createNamedParameter(mb_strtolower($loginName)) + ) + ); + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + // "uid" is primary key, so there can only be a single result + if ($row !== false) { + $this->cache[$loginName] = [ + 'uid' => (string)$row['uid'], + 'displayname' => (string)$row['displayname'], + 'password' => (string)$row['password'], + ]; + return true; + } + + // Not found by UID so we try also for email, load uid for email. + if ($tryEmail) { + /** @var string|null $uid Psalm does not get the type correct here */ + [$uid] = [...$this->config->getUsersForUserValue('settings', 'email', mb_strtolower($loginName)), null]; + + // If found, try loading it + if ($uid !== null && $uid !== $loginName) { + $result = $this->loadUser($uid, false); + if ($result) { + // Also add cache result for the email + $this->cache[$loginName] = $this->cache[$uid]; + // Set a reference to the uid cache entry for also delete email entry on user delete + $this->cache[$uid]['email'] = $loginName; + return true; + } } } - return true; + // Not found by uid nor email, so cache as not existing + $this->cache[$loginName] = false; + return false; } /** @@ -398,8 +455,7 @@ class Database extends ABackend implements * @return boolean */ public function userExists($uid) { - $this->loadUser($uid); - return $this->cache[$uid] !== false; + return $this->loadUser($uid); } /** @@ -410,7 +466,7 @@ class Database extends ABackend implements */ public function getHome(string $uid) { if ($this->userExists($uid)) { - return \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/' . $uid; + return $this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data') . '/' . $uid; } return false; @@ -425,18 +481,18 @@ class Database extends ABackend implements /** * counts the users in the database - * - * @return int|bool */ - public function countUsers() { - $this->fixDI(); - - $query = $this->dbConn->getQueryBuilder(); + public function countUsers(int $limit = 0): int|false { + $dbConn = $this->getDbConnection(); + $query = $dbConn->getQueryBuilder(); $query->select($query->func()->count('uid')) ->from($this->table); - $result = $query->execute(); + $result = $query->executeQuery()->fetchOne(); + if ($result === false) { + return false; + } - return $result->fetchOne(); + return (int)$result; } /** @@ -467,7 +523,7 @@ class Database extends ABackend implements throw new \Exception('key uid is expected to be set in $param'); } - $backends = \OC::$server->getUserManager()->getBackends(); + $backends = \OCP\Server::get(IUserManager::class)->getBackends(); foreach ($backends as $backend) { if ($backend instanceof Database) { /** @var \OC\User\Database $backend */ diff --git a/lib/private/User/DisabledUserException.php b/lib/private/User/DisabledUserException.php new file mode 100644 index 00000000000..db8a23d2027 --- /dev/null +++ b/lib/private/User/DisabledUserException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\User; + +class DisabledUserException extends LoginException { +} diff --git a/lib/private/User/DisplayNameCache.php b/lib/private/User/DisplayNameCache.php new file mode 100644 index 00000000000..4321d95f88e --- /dev/null +++ b/lib/private/User/DisplayNameCache.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\User; + +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\Events\UserChangedEvent; +use OCP\User\Events\UserDeletedEvent; + +/** + * Class that cache the relation UserId -> Display name + * + * This saves fetching the user from a user backend and later on fetching + * their preferences. It's generally not an issue if this data is slightly + * outdated. + * @template-implements IEventListener<UserChangedEvent|UserDeletedEvent> + */ +class DisplayNameCache implements IEventListener { + private const CACHE_TTL = 24 * 60 * 60; // 1 day + + private array $cache = []; + private ICache $memCache; + private IUserManager $userManager; + + public function __construct(ICacheFactory $cacheFactory, IUserManager $userManager) { + $this->memCache = $cacheFactory->createDistributed('displayNameMappingCache'); + $this->userManager = $userManager; + } + + public function getDisplayName(string $userId): ?string { + if (isset($this->cache[$userId])) { + return $this->cache[$userId]; + } + + if (strlen($userId) > IUser::MAX_USERID_LENGTH) { + return null; + } + + $displayName = $this->memCache->get($userId); + if ($displayName) { + $this->cache[$userId] = $displayName; + return $displayName; + } + + $user = $this->userManager->get($userId); + if ($user) { + $displayName = $user->getDisplayName(); + } else { + $displayName = null; + } + $this->cache[$userId] = $displayName; + $this->memCache->set($userId, $displayName, self::CACHE_TTL); + + return $displayName; + } + + public function clear(): void { + $this->cache = []; + $this->memCache->clear(); + } + + public function handle(Event $event): void { + if ($event instanceof UserChangedEvent && $event->getFeature() === 'displayName') { + $userId = $event->getUser()->getUID(); + $newDisplayName = $event->getValue(); + $this->cache[$userId] = $newDisplayName; + $this->memCache->set($userId, $newDisplayName, self::CACHE_TTL); + } + if ($event instanceof UserDeletedEvent) { + $userId = $event->getUser()->getUID(); + unset($this->cache[$userId]); + $this->memCache->remove($userId); + } + } +} diff --git a/lib/private/User/LazyUser.php b/lib/private/User/LazyUser.php new file mode 100644 index 00000000000..501169019d4 --- /dev/null +++ b/lib/private/User/LazyUser.php @@ -0,0 +1,178 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\User; + +use OCP\IUser; +use OCP\IUserManager; +use OCP\UserInterface; + +class LazyUser implements IUser { + private ?IUser $user = null; + private string $uid; + private ?string $displayName; + private IUserManager $userManager; + private ?UserInterface $backend; + + public function __construct(string $uid, IUserManager $userManager, ?string $displayName = null, ?UserInterface $backend = null) { + $this->uid = $uid; + $this->userManager = $userManager; + $this->displayName = $displayName; + $this->backend = $backend; + } + + private function getUser(): IUser { + if ($this->user === null) { + if ($this->backend) { + /** @var \OC\User\Manager $manager */ + $manager = $this->userManager; + $this->user = $manager->getUserObject($this->uid, $this->backend); + } else { + $this->user = $this->userManager->get($this->uid); + } + } + + if ($this->user === null) { + throw new NoUserException('User not found in backend'); + } + + return $this->user; + } + + public function getUID() { + return $this->uid; + } + + public function getDisplayName() { + if ($this->displayName) { + return $this->displayName; + } + + return $this->userManager->getDisplayName($this->uid) ?? $this->uid; + } + + public function setDisplayName($displayName) { + return $this->getUser()->setDisplayName($displayName); + } + + public function getLastLogin(): int { + return $this->getUser()->getLastLogin(); + } + + public function getFirstLogin(): int { + return $this->getUser()->getFirstLogin(); + } + + public function updateLastLoginTimestamp(): bool { + return $this->getUser()->updateLastLoginTimestamp(); + } + + public function delete() { + return $this->getUser()->delete(); + } + + public function setPassword($password, $recoveryPassword = null) { + return $this->getUser()->setPassword($password, $recoveryPassword); + } + + public function getPasswordHash(): ?string { + return $this->getUser()->getPasswordHash(); + } + + public function setPasswordHash(string $passwordHash): bool { + return $this->getUser()->setPasswordHash($passwordHash); + } + + public function getHome() { + return $this->getUser()->getHome(); + } + + public function getBackendClassName() { + return $this->getUser()->getBackendClassName(); + } + + public function getBackend(): ?UserInterface { + return $this->getUser()->getBackend(); + } + + public function canChangeAvatar() { + return $this->getUser()->canChangeAvatar(); + } + + public function canChangePassword() { + return $this->getUser()->canChangePassword(); + } + + public function canChangeDisplayName() { + return $this->getUser()->canChangeDisplayName(); + } + + public function canChangeEmail(): bool { + return $this->getUser()->canChangeEmail(); + } + + public function isEnabled() { + return $this->getUser()->isEnabled(); + } + + public function setEnabled(bool $enabled = true) { + return $this->getUser()->setEnabled($enabled); + } + + public function getEMailAddress() { + return $this->getUser()->getEMailAddress(); + } + + public function getSystemEMailAddress(): ?string { + return $this->getUser()->getSystemEMailAddress(); + } + + public function getPrimaryEMailAddress(): ?string { + return $this->getUser()->getPrimaryEMailAddress(); + } + + public function getAvatarImage($size) { + return $this->getUser()->getAvatarImage($size); + } + + public function getCloudId() { + return $this->getUser()->getCloudId(); + } + + public function setEMailAddress($mailAddress) { + $this->getUser()->setEMailAddress($mailAddress); + } + + public function setSystemEMailAddress(string $mailAddress): void { + $this->getUser()->setSystemEMailAddress($mailAddress); + } + + public function setPrimaryEMailAddress(string $mailAddress): void { + $this->getUser()->setPrimaryEMailAddress($mailAddress); + } + + public function getQuota() { + return $this->getUser()->getQuota(); + } + + public function getQuotaBytes(): int|float { + return $this->getUser()->getQuotaBytes(); + } + + public function setQuota($quota) { + $this->getUser()->setQuota($quota); + } + + public function getManagerUids(): array { + return $this->getUser()->getManagerUids(); + } + + public function setManagerUids(array $uids): void { + $this->getUser()->setManagerUids($uids); + } +} diff --git a/lib/private/User/Listeners/BeforeUserDeletedListener.php b/lib/private/User/Listeners/BeforeUserDeletedListener.php new file mode 100644 index 00000000000..50dc9835400 --- /dev/null +++ b/lib/private/User/Listeners/BeforeUserDeletedListener.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\User\Listeners; + +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\NotFoundException; +use OCP\IAvatarManager; +use OCP\Security\ICredentialsManager; +use OCP\User\Events\BeforeUserDeletedEvent; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener<BeforeUserDeletedEvent> + */ +class BeforeUserDeletedListener implements IEventListener { + private IAvatarManager $avatarManager; + private ICredentialsManager $credentialsManager; + private LoggerInterface $logger; + + public function __construct(LoggerInterface $logger, IAvatarManager $avatarManager, ICredentialsManager $credentialsManager) { + $this->avatarManager = $avatarManager; + $this->credentialsManager = $credentialsManager; + $this->logger = $logger; + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeUserDeletedEvent)) { + return; + } + + $user = $event->getUser(); + + // Delete avatar on user deletion + try { + $avatar = $this->avatarManager->getAvatar($user->getUID()); + $avatar->remove(true); + } catch (NotFoundException $e) { + // no avatar to remove + } catch (\Exception $e) { + // Ignore exceptions + $this->logger->info('Could not cleanup avatar of ' . $user->getUID(), [ + 'exception' => $e, + ]); + } + // Delete storages credentials on user deletion + $this->credentialsManager->erase($user->getUID()); + } +} diff --git a/lib/private/User/Listeners/UserChangedListener.php b/lib/private/User/Listeners/UserChangedListener.php new file mode 100644 index 00000000000..8f618950255 --- /dev/null +++ b/lib/private/User/Listeners/UserChangedListener.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\User\Listeners; + +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\NotFoundException; +use OCP\IAvatarManager; +use OCP\User\Events\UserChangedEvent; + +/** + * @template-implements IEventListener<UserChangedEvent> + */ +class UserChangedListener implements IEventListener { + private IAvatarManager $avatarManager; + + public function __construct(IAvatarManager $avatarManager) { + $this->avatarManager = $avatarManager; + } + + public function handle(Event $event): void { + if (!($event instanceof UserChangedEvent)) { + return; + } + + $user = $event->getUser(); + $feature = $event->getFeature(); + $oldValue = $event->getOldValue(); + $value = $event->getValue(); + + // We only change the avatar on display name changes + if ($feature === 'displayName') { + try { + $avatar = $this->avatarManager->getAvatar($user->getUID()); + $avatar->userChanged($feature, $oldValue, $value); + } catch (NotFoundException $e) { + // no avatar to remove + } + } + } +} diff --git a/lib/private/User/LoginException.php b/lib/private/User/LoginException.php index 77e70d07075..6f0e98513dd 100644 --- a/lib/private/User/LoginException.php +++ b/lib/private/User/LoginException.php @@ -1,26 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @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 OC\User; class LoginException extends \Exception { diff --git a/lib/private/User/Manager.php b/lib/private/User/Manager.php index 036d2703d35..097fd9a0dc8 100644 --- a/lib/private/User/Manager.php +++ b/lib/private/User/Manager.php @@ -1,43 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.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 Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Chan <plus.vincchan@gmail.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 OC\User; -use OC\HintException; +use Doctrine\DBAL\Platforms\OraclePlatform; use OC\Hooks\PublicEmitter; +use OC\Memcache\WithLocalCache; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\HintException; use OCP\ICache; use OCP\ICacheFactory; use OCP\IConfig; @@ -45,12 +20,20 @@ use OCP\IGroup; use OCP\IUser; use OCP\IUserBackend; use OCP\IUserManager; -use OCP\Support\Subscription\IRegistry; +use OCP\L10N\IFactory; +use OCP\Server; +use OCP\Support\Subscription\IAssertion; +use OCP\User\Backend\ICheckPasswordBackend; +use OCP\User\Backend\ICountMappedUsersBackend; +use OCP\User\Backend\ICountUsersBackend; use OCP\User\Backend\IGetRealUIDBackend; +use OCP\User\Backend\ILimitAwareCountUsersBackend; +use OCP\User\Backend\IProvideEnabledStateBackend; +use OCP\User\Backend\ISearchKnownUsersBackend; use OCP\User\Events\BeforeUserCreatedEvent; use OCP\User\Events\UserCreatedEvent; use OCP\UserInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; /** * Class Manager @@ -71,75 +54,52 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; */ class Manager extends PublicEmitter implements IUserManager { /** - * @var \OCP\UserInterface[] $backends + * @var UserInterface[] $backends */ - private $backends = []; + private array $backends = []; /** - * @var \OC\User\User[] $cachedUsers + * @var array<string,\OC\User\User> $cachedUsers */ - private $cachedUsers = []; - - /** @var IConfig */ - private $config; - - /** @var EventDispatcherInterface */ - private $dispatcher; - - /** @var ICache */ - private $cache; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - public function __construct(IConfig $config, - EventDispatcherInterface $oldDispatcher, - ICacheFactory $cacheFactory, - IEventDispatcher $eventDispatcher) { - $this->config = $config; - $this->dispatcher = $oldDispatcher; - $this->cache = $cacheFactory->createDistributed('user_backend_map'); - $cachedUsers = &$this->cachedUsers; - $this->listen('\OC\User', 'postDelete', function ($user) use (&$cachedUsers) { - /** @var \OC\User\User $user */ - unset($cachedUsers[$user->getUID()]); + private array $cachedUsers = []; + + private ICache $cache; + + private DisplayNameCache $displayNameCache; + + public function __construct( + private IConfig $config, + ICacheFactory $cacheFactory, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger, + ) { + $this->cache = new WithLocalCache($cacheFactory->createDistributed('user_backend_map')); + $this->listen('\OC\User', 'postDelete', function (IUser $user): void { + unset($this->cachedUsers[$user->getUID()]); }); - $this->eventDispatcher = $eventDispatcher; + $this->displayNameCache = new DisplayNameCache($cacheFactory, $this); } /** * Get the active backends - * @return \OCP\UserInterface[] + * @return UserInterface[] */ - public function getBackends() { + public function getBackends(): array { return $this->backends; } - /** - * register a user backend - * - * @param \OCP\UserInterface $backend - */ - public function registerBackend($backend) { + public function registerBackend(UserInterface $backend): void { $this->backends[] = $backend; } - /** - * remove a user backend - * - * @param \OCP\UserInterface $backend - */ - public function removeBackend($backend) { + public function removeBackend(UserInterface $backend): void { $this->cachedUsers = []; if (($i = array_search($backend, $this->backends)) !== false) { unset($this->backends[$i]); } } - /** - * remove all user backends - */ - public function clearBackends() { + public function clearBackends(): void { $this->cachedUsers = []; $this->backends = []; } @@ -158,7 +118,11 @@ class Manager extends PublicEmitter implements IUserManager { return $this->cachedUsers[$uid]; } - $cachedBackend = $this->cache->get($uid); + if (strlen($uid) > IUser::MAX_USERID_LENGTH) { + return null; + } + + $cachedBackend = $this->cache->get(sha1($uid)); if ($cachedBackend !== null && isset($this->backends[$cachedBackend])) { // Cache has the info of the user backend already, so ask that one directly $backend = $this->backends[$cachedBackend]; @@ -174,13 +138,18 @@ class Manager extends PublicEmitter implements IUserManager { } if ($backend->userExists($uid)) { - $this->cache->set($uid, $i, 300); + // Hash $uid to ensure that only valid characters are used for the cache key + $this->cache->set(sha1($uid), $i, 300); return $this->getUserObject($uid, $backend); } } return null; } + public function getDisplayName(string $uid): ?string { + return $this->displayNameCache->getDisplayName($uid); + } + /** * get or construct the user object * @@ -189,7 +158,7 @@ class Manager extends PublicEmitter implements IUserManager { * @param bool $cacheUser If false the newly created user object will not be cached * @return \OC\User\User */ - protected function getUserObject($uid, $backend, $cacheUser = true) { + public function getUserObject($uid, $backend, $cacheUser = true) { if ($backend instanceof IGetRealUIDBackend) { $uid = $backend->getRealUID($uid); } @@ -198,7 +167,7 @@ class Manager extends PublicEmitter implements IUserManager { return $this->cachedUsers[$uid]; } - $user = new User($uid, $backend, $this->dispatcher, $this, $this->config); + $user = new User($uid, $backend, $this->eventDispatcher, $this, $this->config); if ($cacheUser) { $this->cachedUsers[$uid] = $user; } @@ -212,6 +181,10 @@ class Manager extends PublicEmitter implements IUserManager { * @return bool */ public function userExists($uid) { + if (strlen($uid) > IUser::MAX_USERID_LENGTH) { + return false; + } + $user = $this->get($uid); return ($user !== null); } @@ -221,13 +194,13 @@ class Manager extends PublicEmitter implements IUserManager { * * @param string $loginName * @param string $password - * @return mixed the User object on success, false otherwise + * @return IUser|false the User object on success, false otherwise */ public function checkPassword($loginName, $password) { $result = $this->checkPasswordNoLogging($loginName, $password); if ($result === false) { - \OC::$server->getLogger()->warning('Login failed: \''. $loginName .'\' (Remote IP: \''. \OC::$server->getRequest()->getRemoteAddress(). '\')', ['app' => 'core']); + $this->logger->warning('Login failed: \'' . $loginName . '\' (Remote IP: \'' . \OC::$server->getRequest()->getRemoteAddress() . '\')', ['app' => 'core']); } return $result; @@ -245,8 +218,15 @@ class Manager extends PublicEmitter implements IUserManager { $loginName = str_replace("\0", '', $loginName); $password = str_replace("\0", '', $password); - foreach ($this->backends as $backend) { - if ($backend->implementsActions(Backend::CHECK_PASSWORD)) { + $cachedBackend = $this->cache->get($loginName); + if ($cachedBackend !== null && isset($this->backends[$cachedBackend])) { + $backends = [$this->backends[$cachedBackend]]; + } else { + $backends = $this->backends; + } + foreach ($backends as $backend) { + if ($backend instanceof ICheckPasswordBackend || $backend->implementsActions(Backend::CHECK_PASSWORD)) { + /** @var ICheckPasswordBackend $backend */ $uid = $backend->checkPassword($loginName, $password); if ($uid !== false) { return $this->getUserObject($uid, $backend); @@ -256,11 +236,12 @@ class Manager extends PublicEmitter implements IUserManager { // since http basic auth doesn't provide a standard way of handling non ascii password we allow password to be urlencoded // we only do this decoding after using the plain password fails to maintain compatibility with any password that happens - // to contains urlencoded patterns by "accident". + // to contain urlencoded patterns by "accident". $password = urldecode($password); - foreach ($this->backends as $backend) { - if ($backend->implementsActions(Backend::CHECK_PASSWORD)) { + foreach ($backends as $backend) { + if ($backend instanceof ICheckPasswordBackend || $backend->implementsActions(Backend::CHECK_PASSWORD)) { + /** @var ICheckPasswordBackend|UserInterface $backend */ $uid = $backend->checkPassword($loginName, $password); if ($uid !== false) { return $this->getUserObject($uid, $backend); @@ -272,12 +253,13 @@ class Manager extends PublicEmitter implements IUserManager { } /** - * search by user id + * Search by user id * * @param string $pattern * @param int $limit * @param int $offset - * @return \OC\User\User[] + * @return IUser[] + * @deprecated 27.0.0, use searchDisplayName instead */ public function search($pattern, $limit = null, $offset = null) { $users = []; @@ -285,28 +267,24 @@ class Manager extends PublicEmitter implements IUserManager { $backendUsers = $backend->getUsers($pattern, $limit, $offset); if (is_array($backendUsers)) { foreach ($backendUsers as $uid) { - $users[$uid] = $this->getUserObject($uid, $backend); + $users[$uid] = new LazyUser($uid, $this, null, $backend); } } } - uasort($users, function ($a, $b) { - /** - * @var \OC\User\User $a - * @var \OC\User\User $b - */ + uasort($users, function (IUser $a, IUser $b) { return strcasecmp($a->getUID(), $b->getUID()); }); return $users; } /** - * search by displayName + * Search by displayName * * @param string $pattern * @param int $limit * @param int $offset - * @return \OC\User\User[] + * @return IUser[] */ public function searchDisplayName($pattern, $limit = null, $offset = null) { $users = []; @@ -314,6 +292,80 @@ class Manager extends PublicEmitter implements IUserManager { $backendUsers = $backend->getDisplayNames($pattern, $limit, $offset); if (is_array($backendUsers)) { foreach ($backendUsers as $uid => $displayName) { + $users[] = new LazyUser($uid, $this, $displayName, $backend); + } + } + } + + usort($users, function (IUser $a, IUser $b) { + return strcasecmp($a->getDisplayName(), $b->getDisplayName()); + }); + return $users; + } + + /** + * @return IUser[] + */ + public function getDisabledUsers(?int $limit = null, int $offset = 0, string $search = ''): array { + $users = $this->config->getUsersForUserValue('core', 'enabled', 'false'); + $users = array_combine( + $users, + array_map( + fn (string $uid): IUser => new LazyUser($uid, $this), + $users + ) + ); + if ($search !== '') { + $users = array_filter( + $users, + function (IUser $user) use ($search): bool { + try { + return mb_stripos($user->getUID(), $search) !== false + || mb_stripos($user->getDisplayName(), $search) !== false + || mb_stripos($user->getEMailAddress() ?? '', $search) !== false; + } catch (NoUserException $ex) { + $this->logger->error('Error while filtering disabled users', ['exception' => $ex, 'userUID' => $user->getUID()]); + return false; + } + }); + } + + $tempLimit = ($limit === null ? null : $limit + $offset); + foreach ($this->backends as $backend) { + if (($tempLimit !== null) && (count($users) >= $tempLimit)) { + break; + } + if ($backend instanceof IProvideEnabledStateBackend) { + $backendUsers = $backend->getDisabledUserList(($tempLimit === null ? null : $tempLimit - count($users)), 0, $search); + foreach ($backendUsers as $uid) { + $users[$uid] = new LazyUser($uid, $this, null, $backend); + } + } + } + + return array_slice($users, $offset, $limit); + } + + /** + * Search known users (from phonebook sync) by displayName + * + * @param string $searcher + * @param string $pattern + * @param int|null $limit + * @param int|null $offset + * @return IUser[] + */ + public function searchKnownUsersByDisplayName(string $searcher, string $pattern, ?int $limit = null, ?int $offset = null): array { + $users = []; + foreach ($this->backends as $backend) { + if ($backend instanceof ISearchKnownUsersBackend) { + $backendUsers = $backend->searchKnownUsersByDisplayName($searcher, $pattern, $limit, $offset); + } else { + // Better than nothing, but filtering after pagination can remove lots of results. + $backendUsers = $backend->getDisplayNames($pattern, $limit, $offset); + } + if (is_array($backendUsers)) { + foreach ($backendUsers as $uid => $displayName) { $users[] = $this->getUserObject($uid, $backend); } } @@ -321,8 +373,8 @@ class Manager extends PublicEmitter implements IUserManager { usort($users, function ($a, $b) { /** - * @var \OC\User\User $a - * @var \OC\User\User $b + * @var IUser $a + * @var IUser $b */ return strcasecmp($a->getDisplayName(), $b->getDisplayName()); }); @@ -332,15 +384,15 @@ class Manager extends PublicEmitter implements IUserManager { /** * @param string $uid * @param string $password + * @return false|IUser the created user or false * @throws \InvalidArgumentException - * @return bool|IUser the created user or false + * @throws HintException */ public function createUser($uid, $password) { // DI injection is not used here as IRegistry needs the user manager itself for user count and thus it would create a cyclic dependency - if (\OC::$server->get(IRegistry::class)->delegateIsHardUserLimitReached()) { - $l = \OC::$server->getL10N('lib'); - throw new HintException($l->t('The user limit has been reached and the user was not created.')); - } + /** @var IAssertion $assertion */ + $assertion = \OC::$server->get(IAssertion::class); + $assertion->createUserIsLegit(); $localBackends = []; foreach ($this->backends as $backend) { @@ -368,37 +420,13 @@ class Manager extends PublicEmitter implements IUserManager { * @param string $uid * @param string $password * @param UserInterface $backend - * @return IUser|null + * @return IUser|false * @throws \InvalidArgumentException */ public function createUserFromBackend($uid, $password, UserInterface $backend) { - $l = \OC::$server->getL10N('lib'); + $l = \OCP\Util::getL10N('lib'); - // Check the name for bad characters - // Allowed are: "a-z", "A-Z", "0-9" and "_.@-'" - if (preg_match('/[^a-zA-Z0-9 _.@\-\']/', $uid)) { - throw new \InvalidArgumentException($l->t('Only the following characters are allowed in a username:' - . ' "a-z", "A-Z", "0-9", and "_.@-\'"')); - } - - // No empty username - if (trim($uid) === '') { - throw new \InvalidArgumentException($l->t('A valid username must be provided')); - } - - // No whitespace at the beginning or at the end - if (trim($uid) !== $uid) { - throw new \InvalidArgumentException($l->t('Username contains whitespace at the beginning or at the end')); - } - - // Username only consists of 1 or 2 dots (directory traversal) - if ($uid === '.' || $uid === '..') { - throw new \InvalidArgumentException($l->t('Username must not consist of dots only')); - } - - if (!$this->verifyUid($uid)) { - throw new \InvalidArgumentException($l->t('Username is invalid because files already exist for this user')); - } + $this->validateUserId($uid, true); // No empty password if (trim($password) === '') { @@ -407,7 +435,7 @@ class Manager extends PublicEmitter implements IUserManager { // Check if user already exists if ($this->userExists($uid)) { - throw new \InvalidArgumentException($l->t('The username is already being used')); + throw new \InvalidArgumentException($l->t('The Login is already being used')); } /** @deprecated 21.0.0 use BeforeUserCreatedEvent event with the IEventDispatcher instead */ @@ -415,32 +443,30 @@ class Manager extends PublicEmitter implements IUserManager { $this->eventDispatcher->dispatchTyped(new BeforeUserCreatedEvent($uid, $password)); $state = $backend->createUser($uid, $password); if ($state === false) { - throw new \InvalidArgumentException($l->t('Could not create user')); + throw new \InvalidArgumentException($l->t('Could not create account')); } $user = $this->getUserObject($uid, $backend); if ($user instanceof IUser) { /** @deprecated 21.0.0 use UserCreatedEvent event with the IEventDispatcher instead */ $this->emit('\OC\User', 'postCreateUser', [$user, $password]); $this->eventDispatcher->dispatchTyped(new UserCreatedEvent($user, $password)); + return $user; } - return $user; + return false; } /** * returns how many users per backend exist (if supported by backend) * * @param boolean $hasLoggedIn when true only users that have a lastLogin - * entry in the preferences table will be affected - * @return array|int an array of backend class as key and count number as value - * if $hasLoggedIn is true only an int is returned + * entry in the preferences table will be affected + * @return array<string, int> an array of backend class as key and count number as value */ - public function countUsers($hasLoggedIn = false) { - if ($hasLoggedIn) { - return $this->countSeenUsers(); - } + public function countUsers() { $userCountStatistics = []; foreach ($this->backends as $backend) { - if ($backend->implementsActions(Backend::COUNT_USERS)) { + if ($backend instanceof ICountUsersBackend || $backend->implementsActions(Backend::COUNT_USERS)) { + /** @var ICountUsersBackend|IUserBackend $backend */ $backendUsers = $backend->countUsers(); if ($backendUsers !== false) { if ($backend instanceof IUserBackend) { @@ -459,32 +485,68 @@ class Manager extends PublicEmitter implements IUserManager { return $userCountStatistics; } + public function countUsersTotal(int $limit = 0, bool $onlyMappedUsers = false): int|false { + $userCount = false; + + foreach ($this->backends as $backend) { + if ($onlyMappedUsers && $backend instanceof ICountMappedUsersBackend) { + $backendUsers = $backend->countMappedUsers(); + } elseif ($backend instanceof ILimitAwareCountUsersBackend) { + $backendUsers = $backend->countUsers($limit); + } elseif ($backend instanceof ICountUsersBackend || $backend->implementsActions(Backend::COUNT_USERS)) { + /** @var ICountUsersBackend $backend */ + $backendUsers = $backend->countUsers(); + } else { + $this->logger->debug('Skip backend for user count: ' . get_class($backend)); + continue; + } + if ($backendUsers !== false) { + $userCount = (int)$userCount + $backendUsers; + if ($limit > 0) { + if ($userCount >= $limit) { + break; + } + $limit -= $userCount; + } + } else { + $this->logger->warning('Can not determine user count for ' . get_class($backend)); + } + } + return $userCount; + } + /** * returns how many users per backend exist in the requested groups (if supported by backend) * - * @param IGroup[] $groups an array of gid to search in - * @return array|int an array of backend class as key and count number as value - * if $hasLoggedIn is true only an int is returned + * @param IGroup[] $groups an array of groups to search in + * @param int $limit limit to stop counting + * @return array{int,int} total number of users, and number of disabled users in the given groups, below $limit. If limit is reached, -1 is returned for number of disabled users */ - public function countUsersOfGroups(array $groups) { + public function countUsersAndDisabledUsersOfGroups(array $groups, int $limit): array { $users = []; + $disabled = []; foreach ($groups as $group) { - $usersIds = array_map(function ($user) { - return $user->getUID(); - }, $group->getUsers()); - $users = array_merge($users, $usersIds); + foreach ($group->getUsers() as $user) { + $users[$user->getUID()] = 1; + if (!$user->isEnabled()) { + $disabled[$user->getUID()] = 1; + } + if (count($users) >= $limit) { + return [count($users),-1]; + } + } } - return count(array_unique($users)); + return [count($users),count($disabled)]; } /** * The callback is executed for each user on each backend. * If the callback returns false no further users will be retrieved. * - * @param \Closure $callback + * @psalm-param \Closure(\OCP\IUser):?bool $callback * @param string $search * @param boolean $onlySeen when true only users that have a lastLogin entry - * in the preferences table will be affected + * in the preferences table will be affected * @since 9.0.0 */ public function callForAllUsers(\Closure $callback, $search = '', $onlySeen = false) { @@ -541,36 +603,6 @@ class Manager extends PublicEmitter implements IUserManager { } /** - * returns how many users are disabled in the requested groups - * - * @param array $groups groupids to search - * @return int - * @since 14.0.0 - */ - public function countDisabledUsersOfGroups(array $groups): int { - $queryBuilder = \OC::$server->getDatabaseConnection()->getQueryBuilder(); - $queryBuilder->select($queryBuilder->createFunction('COUNT(DISTINCT ' . $queryBuilder->getColumnName('uid') . ')')) - ->from('preferences', 'p') - ->innerJoin('p', 'group_user', 'g', $queryBuilder->expr()->eq('p.userid', 'g.uid')) - ->where($queryBuilder->expr()->eq('appid', $queryBuilder->createNamedParameter('core'))) - ->andWhere($queryBuilder->expr()->eq('configkey', $queryBuilder->createNamedParameter('enabled'))) - ->andWhere($queryBuilder->expr()->eq('configvalue', $queryBuilder->createNamedParameter('false'), IQueryBuilder::PARAM_STR)) - ->andWhere($queryBuilder->expr()->in('gid', $queryBuilder->createNamedParameter($groups, IQueryBuilder::PARAM_STR_ARRAY))); - - $result = $queryBuilder->execute(); - $count = $result->fetchOne(); - $result->closeCursor(); - - if ($count !== false) { - $count = (int)$count; - } else { - $count = 0; - } - - return $count; - } - - /** * returns how many users have logged in once * * @return int @@ -581,8 +613,7 @@ class Manager extends PublicEmitter implements IUserManager { $queryBuilder->select($queryBuilder->func()->count('*')) ->from('preferences') ->where($queryBuilder->expr()->eq('appid', $queryBuilder->createNamedParameter('login'))) - ->andWhere($queryBuilder->expr()->eq('configkey', $queryBuilder->createNamedParameter('lastLogin'))) - ->andWhere($queryBuilder->expr()->isNotNull('configvalue')); + ->andWhere($queryBuilder->expr()->eq('configkey', $queryBuilder->createNamedParameter('lastLogin'))); $query = $queryBuilder->execute(); @@ -592,30 +623,14 @@ class Manager extends PublicEmitter implements IUserManager { return $result; } - /** - * @param \Closure $callback - * @psalm-param \Closure(\OCP\IUser):?bool $callback - * @since 11.0.0 - */ public function callForSeenUsers(\Closure $callback) { - $limit = 1000; - $offset = 0; - do { - $userIds = $this->getSeenUserIds($limit, $offset); - $offset += $limit; - foreach ($userIds as $userId) { - foreach ($this->backends as $backend) { - if ($backend->userExists($userId)) { - $user = $this->getUserObject($userId, $backend, false); - $return = $callback($user); - if ($return === false) { - return; - } - break; - } - } + $users = $this->getSeenUsers(); + foreach ($users as $user) { + $return = $callback($user); + if ($return === false) { + return; } - } while (count($userIds) >= $limit); + } } /** @@ -664,6 +679,7 @@ class Manager extends PublicEmitter implements IUserManager { * @since 9.1.0 */ public function getByEmail($email) { + // looking for 'email' only (and not primary_mail) is intentional $userIds = $this->config->getUsersForUserValueCaseInsensitive('settings', 'email', $email); $users = array_map(function ($uid) { @@ -675,21 +691,151 @@ class Manager extends PublicEmitter implements IUserManager { })); } - private function verifyUid(string $uid): bool { + /** + * @param string $uid + * @param bool $checkDataDirectory + * @throws \InvalidArgumentException Message is an already translated string with a reason why the id is not valid + * @since 26.0.0 + */ + public function validateUserId(string $uid, bool $checkDataDirectory = false): void { + $l = Server::get(IFactory::class)->get('lib'); + + // Check the ID for bad characters + // Allowed are: "a-z", "A-Z", "0-9", spaces and "_.@-'" + if (preg_match('/[^a-zA-Z0-9 _.@\-\']/', $uid)) { + throw new \InvalidArgumentException($l->t('Only the following characters are allowed in an Login:' + . ' "a-z", "A-Z", "0-9", spaces and "_.@-\'"')); + } + + // No empty user ID + if (trim($uid) === '') { + throw new \InvalidArgumentException($l->t('A valid Login must be provided')); + } + + // No whitespace at the beginning or at the end + if (trim($uid) !== $uid) { + throw new \InvalidArgumentException($l->t('Login contains whitespace at the beginning or at the end')); + } + + // User ID only consists of 1 or 2 dots (directory traversal) + if ($uid === '.' || $uid === '..') { + throw new \InvalidArgumentException($l->t('Login must not consist of dots only')); + } + + // User ID is too long + if (strlen($uid) > IUser::MAX_USERID_LENGTH) { + // TRANSLATORS User ID is too long + throw new \InvalidArgumentException($l->t('Username is too long')); + } + + if (!$this->verifyUid($uid, $checkDataDirectory)) { + throw new \InvalidArgumentException($l->t('Login is invalid because files already exist for this user')); + } + } + + /** + * Gets the list of user ids sorted by lastLogin, from most recent to least recent + * + * @param int|null $limit how many users to fetch (default: 25, max: 100) + * @param int $offset from which offset to fetch + * @param string $search search users based on search params + * @return list<string> list of user IDs + */ + public function getLastLoggedInUsers(?int $limit = null, int $offset = 0, string $search = ''): array { + // We can't load all users who already logged in + $limit = min(100, $limit ?: 25); + + $connection = \OC::$server->getDatabaseConnection(); + $queryBuilder = $connection->getQueryBuilder(); + $queryBuilder->select('pref_login.userid') + ->from('preferences', 'pref_login') + ->where($queryBuilder->expr()->eq('pref_login.appid', $queryBuilder->expr()->literal('login'))) + ->andWhere($queryBuilder->expr()->eq('pref_login.configkey', $queryBuilder->expr()->literal('lastLogin'))) + ->setFirstResult($offset) + ->setMaxResults($limit) + ; + + // Oracle don't want to run ORDER BY on CLOB column + $loginOrder = $connection->getDatabasePlatform() instanceof OraclePlatform + ? $queryBuilder->expr()->castColumn('pref_login.configvalue', IQueryBuilder::PARAM_INT) + : 'pref_login.configvalue'; + $queryBuilder + ->orderBy($loginOrder, 'DESC') + ->addOrderBy($queryBuilder->func()->lower('pref_login.userid'), 'ASC'); + + if ($search !== '') { + $displayNameMatches = $this->searchDisplayName($search); + $matchedUids = array_map(static fn (IUser $u): string => $u->getUID(), $displayNameMatches); + + $queryBuilder + ->leftJoin('pref_login', 'preferences', 'pref_email', $queryBuilder->expr()->andX( + $queryBuilder->expr()->eq('pref_login.userid', 'pref_email.userid'), + $queryBuilder->expr()->eq('pref_email.appid', $queryBuilder->expr()->literal('settings')), + $queryBuilder->expr()->eq('pref_email.configkey', $queryBuilder->expr()->literal('email')), + )) + ->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->in('pref_login.userid', $queryBuilder->createNamedParameter($matchedUids, IQueryBuilder::PARAM_STR_ARRAY)), + )); + } + + /** @var list<string> */ + $list = $queryBuilder->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + + return $list; + } + + private function verifyUid(string $uid, bool $checkDataDirectory = false): bool { $appdata = 'appdata_' . $this->config->getSystemValueString('instanceid'); if (\in_array($uid, [ '.htaccess', 'files_external', - '.ocdata', + '__groupfolders', + '.ncdata', 'owncloud.log', 'nextcloud.log', + 'updater.log', + 'audit.log', $appdata], true)) { return false; } + if (!$checkDataDirectory) { + return true; + } + $dataDirectory = $this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'); return !file_exists(rtrim($dataDirectory, '/') . '/' . $uid); } + + public function getDisplayNameCache(): DisplayNameCache { + return $this->displayNameCache; + } + + /** + * Gets the list of users sorted by lastLogin, from most recent to least recent + * + * @param int $offset from which offset to fetch + * @return \Iterator<IUser> list of user IDs + * @since 30.0.0 + */ + public function getSeenUsers(int $offset = 0): \Iterator { + $limit = 1000; + + do { + $userIds = $this->getSeenUserIds($limit, $offset); + $offset += $limit; + + foreach ($userIds as $userId) { + foreach ($this->backends as $backend) { + if ($backend->userExists($userId)) { + $user = $this->getUserObject($userId, $backend, false); + yield $user; + break; + } + } + } + } while (count($userIds) === $limit); + } } diff --git a/lib/private/User/NoUserException.php b/lib/private/User/NoUserException.php index 57bb47109f1..c0a5c51b08d 100644 --- a/lib/private/User/NoUserException.php +++ b/lib/private/User/NoUserException.php @@ -1,26 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jörn Friedrich Dreyer <jfd@butonic.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 OC\User; class NoUserException extends \Exception { diff --git a/lib/private/User/OutOfOfficeData.php b/lib/private/User/OutOfOfficeData.php new file mode 100644 index 00000000000..0d4e142567e --- /dev/null +++ b/lib/private/User/OutOfOfficeData.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 OC\User; + +use OCP\IUser; +use OCP\User\IOutOfOfficeData; + +class OutOfOfficeData implements IOutOfOfficeData { + public function __construct( + private string $id, + private IUser $user, + private int $startDate, + private int $endDate, + private string $shortMessage, + private string $message, + private ?string $replacementUserId, + private ?string $replacementUserDisplayName, + ) { + } + + public function getId(): string { + return $this->id; + } + + public function getUser(): IUser { + return $this->user; + } + + public function getStartDate(): int { + return $this->startDate; + } + + public function getEndDate(): int { + return $this->endDate; + } + + public function getShortMessage(): string { + return $this->shortMessage; + } + + public function getMessage(): string { + return $this->message; + } + + public function getReplacementUserId(): ?string { + return $this->replacementUserId; + } + + public function getReplacementUserDisplayName(): ?string { + return $this->replacementUserDisplayName; + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'userId' => $this->getUser()->getUID(), + 'startDate' => $this->getStartDate(), + 'endDate' => $this->getEndDate(), + 'shortMessage' => $this->getShortMessage(), + 'message' => $this->getMessage(), + 'replacementUserId' => $this->getReplacementUserId(), + 'replacementUserDisplayName' => $this->getReplacementUserDisplayName(), + ]; + } +} diff --git a/lib/private/User/PartiallyDeletedUsersBackend.php b/lib/private/User/PartiallyDeletedUsersBackend.php new file mode 100644 index 00000000000..298ddaff6c6 --- /dev/null +++ b/lib/private/User/PartiallyDeletedUsersBackend.php @@ -0,0 +1,56 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\User; + +use OCP\IConfig; +use OCP\IUserBackend; +use OCP\User\Backend\IGetHomeBackend; + +/** + * This is a "fake" backend for users that were deleted, + * but not properly removed from Nextcloud (e.g. an exception occurred). + * This backend is only needed because some APIs in user-deleted-events require a "real" user with backend. + */ +class PartiallyDeletedUsersBackend extends Backend implements IGetHomeBackend, IUserBackend { + + public function __construct( + private IConfig $config, + ) { + } + + public function deleteUser($uid): bool { + // fake true, deleting failed users is automatically handled by User::delete() + return true; + } + + public function getBackendName(): string { + return 'deleted users'; + } + + public function userExists($uid) { + return $this->config->getUserValue($uid, 'core', 'deleted') === 'true'; + } + + public function getHome(string $uid): string|false { + return $this->config->getUserValue($uid, 'core', 'deleted.home-path') ?: false; + } + + public function getUsers($search = '', $limit = null, $offset = null) { + return $this->config->getUsersForUserValue('core', 'deleted', 'true'); + } + + /** + * Unmark a user as deleted. + * This typically the case if the user deletion failed in the backend but before the backend deleted the user, + * meaning the user still exists so we unmark them as it still can be accessed (and deleted) normally. + */ + public function unmarkUser(string $userId): void { + $this->config->deleteUserValue($userId, 'core', 'deleted'); + $this->config->deleteUserValue($userId, 'core', 'deleted.home-path'); + } + +} diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 70c7f11c45f..e7bfcf56407 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -1,71 +1,46 @@ <?php + /** - * @copyright Copyright (c) 2017, Sandro Lutz <sandro.lutz@temparus.ch> - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Felix Rupp <github@felixrupp.com> - * @author Greta Doci <gretadoci@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lionel Elie Mamane <lionel@mamane.lu> - * @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 Sandro Lutz <sandro.lutz@temparus.ch> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\User; use OC; -use OC\Authentication\Exceptions\ExpiredTokenException; -use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; +use OC\Authentication\Token\PublicKeyToken; +use OC\Authentication\TwoFactorAuth\Manager as TwoFactorAuthManager; use OC\Hooks\Emitter; use OC\Hooks\PublicEmitter; +use OC\Security\CSRF\CsrfTokenManager; use OC_User; use OC_Util; use OCA\DAV\Connector\Sabre\Auth; +use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\EventDispatcher\GenericEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\NotPermittedException; use OCP\IConfig; -use OCP\ILogger; +use OCP\IDBConnection; use OCP\IRequest; use OCP\ISession; use OCP\IUser; use OCP\IUserSession; use OCP\Lockdown\ILockdownManager; +use OCP\Security\Bruteforce\IThrottler; use OCP\Security\ISecureRandom; use OCP\Session\Exceptions\SessionNotAvailableException; use OCP\User\Events\PostLoginEvent; +use OCP\User\Events\UserFirstTimeLoggedInEvent; use OCP\Util; -use Symfony\Component\EventDispatcher\GenericEvent; +use Psr\Log\LoggerInterface; /** * Class Session @@ -90,65 +65,22 @@ use Symfony\Component\EventDispatcher\GenericEvent; * @package OC\User */ class Session implements IUserSession, Emitter { - - /** @var Manager|PublicEmitter $manager */ - private $manager; - - /** @var ISession $session */ - private $session; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var IProvider */ - private $tokenProvider; - - /** @var IConfig */ - private $config; + use TTransactional; /** @var User $activeUser */ protected $activeUser; - /** @var ISecureRandom */ - private $random; - - /** @var ILockdownManager */ - private $lockdownManager; - - /** @var ILogger */ - private $logger; - /** @var IEventDispatcher */ - private $dispatcher; - - /** - * @param Manager $manager - * @param ISession $session - * @param ITimeFactory $timeFactory - * @param IProvider $tokenProvider - * @param IConfig $config - * @param ISecureRandom $random - * @param ILockdownManager $lockdownManager - * @param ILogger $logger - */ - public function __construct(Manager $manager, - ISession $session, - ITimeFactory $timeFactory, - $tokenProvider, - IConfig $config, - ISecureRandom $random, - ILockdownManager $lockdownManager, - ILogger $logger, - IEventDispatcher $dispatcher + public function __construct( + private Manager $manager, + private ISession $session, + private ITimeFactory $timeFactory, + private ?IProvider $tokenProvider, + private IConfig $config, + private ISecureRandom $random, + private ILockdownManager $lockdownManager, + private LoggerInterface $logger, + private IEventDispatcher $dispatcher, ) { - $this->manager = $manager; - $this->session = $session; - $this->timeFactory = $timeFactory; - $this->tokenProvider = $tokenProvider; - $this->config = $config; - $this->random = $random; - $this->lockdownManager = $lockdownManager; - $this->logger = $logger; - $this->dispatcher = $dispatcher; } /** @@ -172,7 +104,7 @@ class Session implements IUserSession, Emitter { * @param string $method optional * @param callable $callback optional */ - public function removeListener($scope = null, $method = null, callable $callback = null) { + public function removeListener($scope = null, $method = null, ?callable $callback = null) { $this->manager->removeListener($scope, $method, $callback); } @@ -222,6 +154,15 @@ class Session implements IUserSession, Emitter { } /** + * Temporarily set the currently active user without persisting in the session + * + * @param IUser|null $user + */ + public function setVolatileActiveUser(?IUser $user): void { + $this->activeUser = $user; + } + + /** * get the current active user * * @return IUser|null Current user, otherwise null @@ -300,9 +241,9 @@ class Session implements IUserSession, Emitter { } /** - * get the login name of the current user + * Get the login name of the current user * - * @return string + * @return ?string */ public function getLoginName() { if ($this->activeUser) { @@ -378,12 +319,13 @@ class Session implements IUserSession, Emitter { if (!$user->isEnabled()) { // disabled users can not log in // injecting l10n does not work - there is a circular dependency between session and \OCP\L10N\IFactory - $message = \OC::$server->getL10N('lib')->t('User disabled'); - throw new LoginException($message); + $message = \OCP\Util::getL10N('lib')->t('Account disabled'); + throw new DisabledUserException($message); } if ($regenerateSessionId) { $this->session->regenerateId(); + $this->session->remove(Auth::DAV_AUTHENTICATED); } $this->setUser($user); @@ -393,6 +335,7 @@ class Session implements IUserSession, Emitter { if ($isToken) { $this->setToken($loginDetails['token']->getId()); $this->lockdownManager->setToken($loginDetails['token']); + $user->updateLastLoginTimestamp(); $firstTimeLogin = false; } else { $this->setToken(null); @@ -416,7 +359,7 @@ class Session implements IUserSession, Emitter { return true; } - $message = \OC::$server->getL10N('lib')->t('Login canceled by app'); + $message = \OCP\Util::getL10N('lib')->t('Login canceled by app'); throw new LoginException($message); } @@ -429,16 +372,17 @@ class Session implements IUserSession, Emitter { * @param string $user * @param string $password * @param IRequest $request - * @param OC\Security\Bruteforce\Throttler $throttler + * @param IThrottler $throttler * @throws LoginException * @throws PasswordLoginForbiddenException * @return boolean */ public function logClientIn($user, - $password, - IRequest $request, - OC\Security\Bruteforce\Throttler $throttler) { - $currentDelay = $throttler->sleepDelay($request->getRemoteAddress(), 'login'); + $password, + IRequest $request, + IThrottler $throttler) { + $remoteAddress = $request->getRemoteAddress(); + $currentDelay = $throttler->sleepDelayOrThrowOnMax($remoteAddress, 'login'); if ($this->manager instanceof PublicEmitter) { $this->manager->emit('\OC\User', 'preLogin', [$user, $password]); @@ -460,19 +404,24 @@ class Session implements IUserSession, Emitter { // Try to login with this username and password if (!$this->login($user, $password)) { - // Failed, maybe the user used their email address - $users = $this->manager->getByEmail($user); - if (!(\count($users) === 1 && $this->login($users[0]->getUID(), $password))) { - $this->logger->warning('Login failed: \'' . $user . '\' (Remote IP: \'' . \OC::$server->getRequest()->getRemoteAddress() . '\')', ['app' => 'core']); - - $throttler->registerAttempt('login', $request->getRemoteAddress(), ['user' => $user]); + if (!filter_var($user, FILTER_VALIDATE_EMAIL)) { + $this->handleLoginFailed($throttler, $currentDelay, $remoteAddress, $user, $password); + return false; + } - $this->dispatcher->dispatchTyped(new OC\Authentication\Events\LoginFailed($user)); + if ($isTokenPassword) { + $dbToken = $this->tokenProvider->getToken($password); + $userFromToken = $this->manager->get($dbToken->getUID()); + $isValidEmailLogin = $userFromToken->getEMailAddress() === $user + && $this->validateTokenLoginName($userFromToken->getEMailAddress(), $dbToken); + } else { + $users = $this->manager->getByEmail($user); + $isValidEmailLogin = (\count($users) === 1 && $this->login($users[0]->getUID(), $password)); + } - if ($currentDelay === 0) { - $throttler->sleepDelay($request->getRemoteAddress(), 'login'); - } + if (!$isValidEmailLogin) { + $this->handleLoginFailed($throttler, $currentDelay, $remoteAddress, $user, $password); return false; } } @@ -487,6 +436,17 @@ class Session implements IUserSession, Emitter { return true; } + private function handleLoginFailed(IThrottler $throttler, int $currentDelay, string $remoteAddress, string $user, ?string $password) { + $this->logger->warning("Login failed: '" . $user . "' (Remote IP: '" . $remoteAddress . "')", ['app' => 'core']); + + $throttler->registerAttempt('login', $remoteAddress, ['user' => $user]); + $this->dispatcher->dispatchTyped(new OC\Authentication\Events\LoginFailed($user, $password)); + + if ($currentDelay === 0) { + $throttler->sleepDelayOrThrowOnMax($remoteAddress, 'login'); + } + } + protected function supportsCookies(IRequest $request) { if (!is_null($request->getCookie('cookie_test'))) { return true; @@ -495,8 +455,8 @@ class Session implements IUserSession, Emitter { return false; } - private function isTokenAuthEnforced() { - return $this->config->getSystemValue('token_auth_enforced', false); + private function isTokenAuthEnforced(): bool { + return $this->config->getSystemValueBool('token_auth_enforced', false); } protected function isTwoFactorEnforced($username) { @@ -517,7 +477,7 @@ class Session implements IUserSession, Emitter { $user = $users[0]; } // DI not possible due to cyclic dependencies :'-/ - return OC::$server->getTwoFactorAuthManager()->isTwoFactorAuthenticated($user); + return OC::$server->get(TwoFactorAuthManager::class)->isTwoFactorAuthenticated($user); } /** @@ -534,9 +494,8 @@ class Session implements IUserSession, Emitter { } catch (ExpiredTokenException $e) { throw $e; } catch (InvalidTokenException $ex) { - $this->logger->logException($ex, [ - 'level' => ILogger::DEBUG, - 'message' => 'Token is not valid: ' . $ex->getMessage(), + $this->logger->debug('Token is not valid: ' . $ex->getMessage(), [ + 'exception' => $ex, ]); return false; } @@ -546,14 +505,14 @@ class Session implements IUserSession, Emitter { if ($refreshCsrfToken) { // TODO: mock/inject/use non-static // Refresh the token - \OC::$server->getCsrfTokenManager()->refreshToken(); + \OC::$server->get(CsrfTokenManager::class)->refreshToken(); } - //we need to pass the user name, which may differ from login name - $user = $this->getUser()->getUID(); - OC_Util::setupFS($user); - if ($firstTimeLogin) { + //we need to pass the user name, which may differ from login name + $user = $this->getUser()->getUID(); + OC_Util::setupFS($user); + // TODO: lock necessary? //trigger creation of user home and /files folder $userFolder = \OC::$server->getUserFolder($user); @@ -566,7 +525,8 @@ class Session implements IUserSession, Emitter { } // trigger any other initialization - \OC::$server->getEventDispatcher()->dispatch(IUser::class . '::firstLogin', new GenericEvent($this->getUser())); + \OC::$server->get(IEventDispatcher::class)->dispatch(IUser::class . '::firstLogin', new GenericEvent($this->getUser())); + \OC::$server->get(IEventDispatcher::class)->dispatchTyped(new UserFirstTimeLoggedInEvent($this->getUser())); } } @@ -575,11 +535,11 @@ class Session implements IUserSession, Emitter { * * @todo do not allow basic auth if the user is 2FA enforced * @param IRequest $request - * @param OC\Security\Bruteforce\Throttler $throttler + * @param IThrottler $throttler * @return boolean if the login was successful */ public function tryBasicAuthLogin(IRequest $request, - OC\Security\Bruteforce\Throttler $throttler) { + IThrottler $throttler) { if (!empty($request->server['PHP_AUTH_USER']) && !empty($request->server['PHP_AUTH_PW'])) { try { if ($this->logClientIn($request->server['PHP_AUTH_USER'], $request->server['PHP_AUTH_PW'], $request, $throttler)) { @@ -599,6 +559,8 @@ class Session implements IUserSession, Emitter { return true; } + // If credentials were provided, they need to be valid, otherwise we do boom + throw new LoginException(); } catch (PasswordLoginForbiddenException $ex) { // Nothing to do } @@ -647,7 +609,7 @@ class Session implements IUserSession, Emitter { // Ignore and use empty string instead } - $this->manager->emit('\OC\User', 'preLogin', [$uid, $password]); + $this->manager->emit('\OC\User', 'preLogin', [$dbToken->getLoginName(), $password]); $user = $this->manager->get($uid); if (is_null($user)) { @@ -680,13 +642,15 @@ class Session implements IUserSession, Emitter { // User does not exist return false; } - $name = isset($request->server['HTTP_USER_AGENT']) ? $request->server['HTTP_USER_AGENT'] : 'unknown browser'; + $name = isset($request->server['HTTP_USER_AGENT']) ? mb_convert_encoding($request->server['HTTP_USER_AGENT'], 'UTF-8', 'ISO-8859-1') : 'unknown browser'; try { $sessionId = $this->session->getId(); $pwd = $this->getPassword($password); // Make sure the current sessionId has no leftover tokens - $this->tokenProvider->invalidateToken($sessionId); - $this->tokenProvider->generateToken($sessionId, $uid, $loginName, $pwd, $name, IToken::TEMPORARY_TOKEN, $remember); + $this->atomic(function () use ($sessionId, $uid, $loginName, $pwd, $name, $remember) { + $this->tokenProvider->invalidateToken($sessionId); + $this->tokenProvider->generateToken($sessionId, $uid, $loginName, $pwd, $name, IToken::TEMPORARY_TOKEN, $remember); + }, \OCP\Server::get(IDBConnection::class)); return true; } catch (SessionNotAvailableException $ex) { // This can happen with OCC, where a memory session is used @@ -748,7 +712,6 @@ class Session implements IUserSession, Emitter { return false; } - $dbToken->setLastCheck($now); return true; } @@ -766,6 +729,10 @@ class Session implements IUserSession, Emitter { } $dbToken->setLastCheck($now); + if ($dbToken instanceof PublicKeyToken) { + $dbToken->setLastActivity($now); + } + $this->tokenProvider->updateToken($dbToken); return true; } @@ -782,18 +749,23 @@ class Session implements IUserSession, Emitter { try { $dbToken = $this->tokenProvider->getToken($token); } catch (InvalidTokenException $ex) { + $this->logger->debug('Session token is invalid because it does not exist', [ + 'app' => 'core', + 'user' => $user, + 'exception' => $ex, + ]); return false; } - // Check if login names match - if (!is_null($user) && $dbToken->getLoginName() !== $user) { - // TODO: this makes it imposssible to use different login names on browser and client - // e.g. login by e-mail 'user@example.com' on browser for generating the token will not - // allow to use the client token with the login name 'user'. + if (!is_null($user) && !$this->validateTokenLoginName($user, $dbToken)) { return false; } if (!$this->checkTokenCredentials($dbToken, $token)) { + $this->logger->warning('Session token credentials are invalid', [ + 'app' => 'core', + 'user' => $user, + ]); return false; } @@ -806,6 +778,27 @@ class Session implements IUserSession, Emitter { } /** + * Check if login names match + */ + private function validateTokenLoginName(?string $loginName, IToken $token): bool { + if (mb_strtolower($token->getLoginName()) !== mb_strtolower($loginName ?? '')) { + // TODO: this makes it impossible to use different login names on browser and client + // e.g. login by e-mail 'user@example.com' on browser for generating the token will not + // allow to use the client token with the login name 'user'. + $this->logger->error('App token login name does not match', [ + 'tokenLoginName' => $token->getLoginName(), + 'sessionLoginName' => $loginName, + 'app' => 'core', + 'user' => $token->getUID(), + ]); + + return false; + } + + return true; + } + + /** * Tries to login the user with auth token header * * @param IRequest $request @@ -814,15 +807,18 @@ class Session implements IUserSession, Emitter { */ public function tryTokenLogin(IRequest $request) { $authHeader = $request->getHeader('Authorization'); - if (strpos($authHeader, 'Bearer ') === 0) { + if (str_starts_with($authHeader, 'Bearer ')) { $token = substr($authHeader, 7); - } else { - // No auth header, let's try session id + } elseif ($request->getCookie($this->config->getSystemValueString('instanceid')) !== null) { + // No auth header, let's try session id, but only if this is an existing + // session and the request has a session cookie try { $token = $this->session->getId(); } catch (SessionNotAvailableException $ex) { return false; } + } else { + return false; } if (!$this->loginWithToken($token)) { @@ -839,9 +835,8 @@ class Session implements IUserSession, Emitter { return true; } - // Remember me tokens are not app_passwords - if ($dbToken->getRemember() === IToken::DO_NOT_REMEMBER) { - // Set the session variable so we know this is an app password + // Set the session variable so we know this is an app password + if ($dbToken instanceof PublicKeyToken && $dbToken->getType() === IToken::PERMANENT_TOKEN) { $this->session->set('app_password', $token); } @@ -869,20 +864,41 @@ class Session implements IUserSession, Emitter { $tokens = $this->config->getUserKeys($uid, 'login_token'); // test cookies token against stored tokens if (!in_array($currentToken, $tokens, true)) { + $this->logger->info('Tried to log in but could not verify token', [ + 'app' => 'core', + 'user' => $uid, + ]); return false; } // replace successfully used token with a new one $this->config->deleteUserValue($uid, 'login_token', $currentToken); $newToken = $this->random->generate(32); - $this->config->setUserValue($uid, 'login_token', $newToken, $this->timeFactory->getTime()); + $this->config->setUserValue($uid, 'login_token', $newToken, (string)$this->timeFactory->getTime()); + $this->logger->debug('Remember-me token replaced', [ + 'app' => 'core', + 'user' => $uid, + ]); try { $sessionId = $this->session->getId(); $token = $this->tokenProvider->renewSessionToken($oldSessionId, $sessionId); + $this->logger->debug('Session token replaced', [ + 'app' => 'core', + 'user' => $uid, + ]); } catch (SessionNotAvailableException $ex) { + $this->logger->critical('Could not renew session token for {uid} because the session is unavailable', [ + 'app' => 'core', + 'uid' => $uid, + 'user' => $uid, + ]); return false; } catch (InvalidTokenException $ex) { - \OC::$server->getLogger()->warning('Renewing session token failed', ['app' => 'core']); + $this->logger->error('Renewing session token failed: ' . $ex->getMessage(), [ + 'app' => 'core', + 'user' => $uid, + 'exception' => $ex, + ]); return false; } @@ -909,7 +925,7 @@ class Session implements IUserSession, Emitter { */ public function createRememberMeToken(IUser $user) { $token = $this->random->generate(32); - $this->config->setUserValue($user->getUID(), 'login_token', $token, $this->timeFactory->getTime()); + $this->config->setUserValue($user->getUID(), 'login_token', $token, (string)$this->timeFactory->getTime()); $this->setMagicInCookie($user->getUID(), $token); } @@ -921,10 +937,17 @@ class Session implements IUserSession, Emitter { $this->manager->emit('\OC\User', 'logout', [$user]); if ($user !== null) { try { - $this->tokenProvider->invalidateToken($this->session->getId()); + $token = $this->session->getId(); + $this->tokenProvider->invalidateToken($token); + $this->logger->debug('Session token invalidated before logout', [ + 'user' => $user->getUID(), + ]); } catch (SessionNotAvailableException $ex) { } } + $this->logger->debug('Logging out', [ + 'user' => $user === null ? null : $user->getUID(), + ]); $this->setUser(null); $this->setLoginName(null); $this->setToken(null); @@ -945,14 +968,15 @@ class Session implements IUserSession, Emitter { if ($webRoot === '') { $webRoot = '/'; } + $domain = $this->config->getSystemValueString('cookie_domain'); - $maxAge = $this->config->getSystemValue('remember_login_cookie_lifetime', 60 * 60 * 24 * 15); + $maxAge = $this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15); \OC\Http\CookieHelper::setCookie( 'nc_username', $username, $maxAge, $webRoot, - '', + $domain, $secureCookie, true, \OC\Http\CookieHelper::SAMESITE_LAX @@ -962,7 +986,7 @@ class Session implements IUserSession, Emitter { $token, $maxAge, $webRoot, - '', + $domain, $secureCookie, true, \OC\Http\CookieHelper::SAMESITE_LAX @@ -973,7 +997,7 @@ class Session implements IUserSession, Emitter { $this->session->getId(), $maxAge, $webRoot, - '', + $domain, $secureCookie, true, \OC\Http\CookieHelper::SAMESITE_LAX @@ -989,18 +1013,19 @@ class Session implements IUserSession, Emitter { public function unsetMagicInCookie() { //TODO: DI for cookies and IRequest $secureCookie = OC::$server->getRequest()->getServerProtocol() === 'https'; + $domain = $this->config->getSystemValueString('cookie_domain'); unset($_COOKIE['nc_username']); //TODO: DI unset($_COOKIE['nc_token']); unset($_COOKIE['nc_session_id']); - setcookie('nc_username', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, '', $secureCookie, true); - setcookie('nc_token', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, '', $secureCookie, true); - setcookie('nc_session_id', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, '', $secureCookie, true); + setcookie('nc_username', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, $domain, $secureCookie, true); + setcookie('nc_token', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, $domain, $secureCookie, true); + setcookie('nc_session_id', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT, $domain, $secureCookie, true); // old cookies might be stored under /webroot/ instead of /webroot // and Firefox doesn't like it! - setcookie('nc_username', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', '', $secureCookie, true); - setcookie('nc_token', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', '', $secureCookie, true); - setcookie('nc_session_id', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', '', $secureCookie, true); + setcookie('nc_username', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', $domain, $secureCookie, true); + setcookie('nc_token', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', $domain, $secureCookie, true); + setcookie('nc_session_id', '', $this->timeFactory->getTime() - 3600, OC::$WEBROOT . '/', $domain, $secureCookie, true); } /** diff --git a/lib/private/User/User.php b/lib/private/User/User.php index 24082926b0d..1f908918b20 100644 --- a/lib/private/User/User.php +++ b/lib/private/User/User.php @@ -1,116 +1,86 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Leon Klingele <leon@struktur.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.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 OC\User; +use InvalidArgumentException; use OC\Accounts\AccountManager; use OC\Avatar\AvatarManager; -use OC\Files\Cache\Storage; use OC\Hooks\Emitter; -use OC_Helper; +use OCP\Accounts\IAccountManager; +use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Group\Events\BeforeUserRemovedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\IAvatarManager; use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; use OCP\IImage; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserBackend; +use OCP\Notification\IManager as INotificationManager; +use OCP\User\Backend\IGetHomeBackend; +use OCP\User\Backend\IPasswordHashBackend; +use OCP\User\Backend\IProvideAvatarBackend; +use OCP\User\Backend\IProvideEnabledStateBackend; +use OCP\User\Backend\ISetDisplayNameBackend; +use OCP\User\Backend\ISetPasswordBackend; +use OCP\User\Events\BeforePasswordUpdatedEvent; use OCP\User\Events\BeforeUserDeletedEvent; +use OCP\User\Events\PasswordUpdatedEvent; +use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserDeletedEvent; use OCP\User\GetQuotaEvent; use OCP\UserInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; +use Psr\Log\LoggerInterface; + +use function json_decode; +use function json_encode; class User implements IUser { - /** @var string */ - private $uid; + private const CONFIG_KEY_MANAGERS = 'manager'; - /** @var string */ - private $displayName; + private IConfig $config; + private IURLGenerator $urlGenerator; - /** @var UserInterface|null */ - private $backend; - /** @var EventDispatcherInterface */ - private $legacyDispatcher; + /** @var IAccountManager */ + protected $accountManager; - /** @var IEventDispatcher */ - private $dispatcher; + /** @var string|null */ + private $displayName; - /** @var bool */ + /** @var bool|null */ private $enabled; - /** @var Emitter|Manager */ + /** @var Emitter|Manager|null */ private $emitter; /** @var string */ private $home; - /** @var int */ - private $lastLogin; - - /** @var \OCP\IConfig */ - private $config; + private ?int $lastLogin = null; + private ?int $firstLogin = null; /** @var IAvatarManager */ private $avatarManager; - /** @var IURLGenerator */ - private $urlGenerator; - - public function __construct(string $uid, ?UserInterface $backend, EventDispatcherInterface $dispatcher, $emitter = null, IConfig $config = null, $urlGenerator = null) { - $this->uid = $uid; - $this->backend = $backend; - $this->legacyDispatcher = $dispatcher; + public function __construct( + private string $uid, + private ?UserInterface $backend, + private IEventDispatcher $dispatcher, + $emitter = null, + ?IConfig $config = null, + $urlGenerator = null, + ) { $this->emitter = $emitter; - if (is_null($config)) { - $config = \OC::$server->getConfig(); - } - $this->config = $config; - $this->urlGenerator = $urlGenerator; - $enabled = $this->config->getUserValue($uid, 'core', 'enabled', 'true'); - $this->enabled = ($enabled === 'true'); - $this->lastLogin = $this->config->getUserValue($uid, 'login', 'lastLogin', 0); - if (is_null($this->urlGenerator)) { - $this->urlGenerator = \OC::$server->getURLGenerator(); - } - // TODO: inject - $this->dispatcher = \OC::$server->query(IEventDispatcher::class); + $this->config = $config ?? \OCP\Server::get(IConfig::class); + $this->urlGenerator = $urlGenerator ?? \OCP\Server::get(IURLGenerator::class); } /** @@ -128,7 +98,7 @@ class User implements IUser { * @return string */ public function getDisplayName() { - if (!isset($this->displayName)) { + if ($this->displayName === null) { $displayName = ''; if ($this->backend && $this->backend->implementsActions(Backend::GET_DISPLAYNAME)) { // get display name and strip whitespace from the beginning and end of it @@ -152,12 +122,17 @@ class User implements IUser { * * @param string $displayName * @return bool + * + * @since 25.0.0 Throw InvalidArgumentException + * @throws \InvalidArgumentException */ public function setDisplayName($displayName) { $displayName = trim($displayName); $oldDisplayName = $this->getDisplayName(); if ($this->backend->implementsActions(Backend::SET_DISPLAYNAME) && !empty($displayName) && $displayName !== $oldDisplayName) { - $result = $this->backend->setDisplayName($this->uid, $displayName); + /** @var ISetDisplayNameBackend $backend */ + $backend = $this->backend; + $result = $backend->setDisplayName($this->uid, $displayName); if ($result) { $this->displayName = $displayName; $this->triggerChange('displayName', $displayName, $oldDisplayName); @@ -168,42 +143,108 @@ class User implements IUser { } /** - * set the email address of the user - * - * @param string|null $mailAddress - * @return void - * @since 9.0.0 + * @inheritDoc */ public function setEMailAddress($mailAddress) { - $oldMailAddress = $this->getEMailAddress(); - if ($oldMailAddress !== $mailAddress) { - if ($mailAddress === '') { - $this->config->deleteUserValue($this->uid, 'settings', 'email'); - } else { - $this->config->setUserValue($this->uid, 'settings', 'email', $mailAddress); - } + $this->setSystemEMailAddress($mailAddress); + } + + /** + * @inheritDoc + */ + public function setSystemEMailAddress(string $mailAddress): void { + $oldMailAddress = $this->getSystemEMailAddress(); + $mailAddress = mb_strtolower(trim($mailAddress)); + + if ($mailAddress === '') { + $this->config->deleteUserValue($this->uid, 'settings', 'email'); + } else { + $this->config->setUserValue($this->uid, 'settings', 'email', $mailAddress); + } + + $primaryAddress = $this->getPrimaryEMailAddress(); + if ($primaryAddress === $mailAddress) { + // on match no dedicated primary settings is necessary + $this->setPrimaryEMailAddress(''); + } + + if ($oldMailAddress !== strtolower($mailAddress)) { $this->triggerChange('eMailAddress', $mailAddress, $oldMailAddress); } } /** + * @inheritDoc + */ + public function setPrimaryEMailAddress(string $mailAddress): void { + $mailAddress = mb_strtolower(trim($mailAddress)); + if ($mailAddress === '') { + $this->config->deleteUserValue($this->uid, 'settings', 'primary_email'); + return; + } + + $this->ensureAccountManager(); + $account = $this->accountManager->getAccount($this); + $property = $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL) + ->getPropertyByValue($mailAddress); + + if ($property === null || $property->getLocallyVerified() !== IAccountManager::VERIFIED) { + throw new InvalidArgumentException('Only verified emails can be set as primary'); + } + $this->config->setUserValue($this->uid, 'settings', 'primary_email', $mailAddress); + } + + private function ensureAccountManager() { + if (!$this->accountManager instanceof IAccountManager) { + $this->accountManager = \OC::$server->get(IAccountManager::class); + } + } + + /** * returns the timestamp of the user's last login or 0 if the user did never * login - * - * @return int */ - public function getLastLogin() { + public function getLastLogin(): int { + if ($this->lastLogin === null) { + $this->lastLogin = (int)$this->config->getUserValue($this->uid, 'login', 'lastLogin', 0); + } return $this->lastLogin; } /** + * returns the timestamp of the user's last login or 0 if the user did never + * login + */ + public function getFirstLogin(): int { + if ($this->firstLogin === null) { + $this->firstLogin = (int)$this->config->getUserValue($this->uid, 'login', 'firstLogin', 0); + } + return $this->firstLogin; + } + + /** * updates the timestamp of the most recent login of this user */ - public function updateLastLoginTimestamp() { - $firstTimeLogin = ($this->lastLogin === 0); - $this->lastLogin = time(); - $this->config->setUserValue( - $this->uid, 'login', 'lastLogin', $this->lastLogin); + public function updateLastLoginTimestamp(): bool { + $previousLogin = $this->getLastLogin(); + $firstLogin = $this->getFirstLogin(); + $now = time(); + $firstTimeLogin = $previousLogin === 0; + + if ($now - $previousLogin > 60) { + $this->lastLogin = $now; + $this->config->setUserValue($this->uid, 'login', 'lastLogin', (string)$this->lastLogin); + } + + if ($firstLogin === 0) { + if ($firstTimeLogin) { + $this->firstLogin = $now; + } else { + /* Unknown first login, most likely was before upgrade to Nextcloud 31 */ + $this->firstLogin = -1; + } + $this->config->setUserValue($this->uid, 'login', 'firstLogin', (string)$this->firstLogin); + } return $firstTimeLogin; } @@ -214,67 +255,85 @@ class User implements IUser { * @return bool */ public function delete() { - /** @deprecated 21.0.0 use BeforeUserDeletedEvent event with the IEventDispatcher instead */ - $this->legacyDispatcher->dispatch(IUser::class . '::preDelete', new GenericEvent($this)); + if ($this->backend === null) { + \OCP\Server::get(LoggerInterface::class)->error('Cannot delete user: No backend set'); + return false; + } + if ($this->emitter) { /** @deprecated 21.0.0 use BeforeUserDeletedEvent event with the IEventDispatcher instead */ $this->emitter->emit('\OC\User', 'preDelete', [$this]); } $this->dispatcher->dispatchTyped(new BeforeUserDeletedEvent($this)); - // get the home now because it won't return it after user deletion - $homePath = $this->getHome(); - $result = $this->backend->deleteUser($this->uid); - if ($result) { - - // FIXME: Feels like an hack - suggestions? - - $groupManager = \OC::$server->getGroupManager(); - // We have to delete the user from all groups - foreach ($groupManager->getUserGroupIds($this) as $groupId) { - $group = $groupManager->get($groupId); - if ($group) { - $this->dispatcher->dispatchTyped(new BeforeUserRemovedEvent($group, $this)); - $group->removeUser($this); - $this->dispatcher->dispatchTyped(new UserRemovedEvent($group, $this)); - } - } - // Delete the user's keys in preferences - \OC::$server->getConfig()->deleteAllUserValues($this->uid); - - // Delete user files in /data/ - if ($homePath !== false) { - // FIXME: this operates directly on FS, should use View instead... - // also this is not testable/mockable... - \OC_Helper::rmdirr($homePath); - } - // Delete the users entry in the storage table - Storage::remove('home::' . $this->uid); + // Set delete flag on the user - this is needed to ensure that the user data is removed if there happen any exception in the backend + // because we can not restore the user meaning we could not rollback to any stable state otherwise. + $this->config->setUserValue($this->uid, 'core', 'deleted', 'true'); + // We also need to backup the home path as this can not be reconstructed later if the original backend uses custom home paths + $this->config->setUserValue($this->uid, 'core', 'deleted.home-path', $this->getHome()); - \OC::$server->getCommentsManager()->deleteReferencesOfActor('users', $this->uid); - \OC::$server->getCommentsManager()->deleteReadMarksFromUser($this); - - /** @var IAvatarManager $avatarManager */ - $avatarManager = \OC::$server->query(AvatarManager::class); - $avatarManager->deleteUserAvatar($this->uid); + // Try to delete the user on the backend + $result = $this->backend->deleteUser($this->uid); + if ($result === false) { + // The deletion was aborted or something else happened, we are in a defined state, so remove the delete flag + $this->config->deleteUserValue($this->uid, 'core', 'deleted'); + return false; + } - $notification = \OC::$server->getNotificationManager()->createNotification(); - $notification->setUser($this->uid); - \OC::$server->getNotificationManager()->markProcessed($notification); + // We have to delete the user from all groups + $groupManager = \OCP\Server::get(IGroupManager::class); + foreach ($groupManager->getUserGroupIds($this) as $groupId) { + $group = $groupManager->get($groupId); + if ($group) { + $this->dispatcher->dispatchTyped(new BeforeUserRemovedEvent($group, $this)); + $group->removeUser($this); + $this->dispatcher->dispatchTyped(new UserRemovedEvent($group, $this)); + } + } - /** @var AccountManager $accountManager */ - $accountManager = \OC::$server->query(AccountManager::class); - $accountManager->deleteUser($this); + $commentsManager = \OCP\Server::get(ICommentsManager::class); + $commentsManager->deleteReferencesOfActor('users', $this->uid); + $commentsManager->deleteReadMarksFromUser($this); + + $avatarManager = \OCP\Server::get(AvatarManager::class); + $avatarManager->deleteUserAvatar($this->uid); + + $notificationManager = \OCP\Server::get(INotificationManager::class); + $notification = $notificationManager->createNotification(); + $notification->setUser($this->uid); + $notificationManager->markProcessed($notification); + + $accountManager = \OCP\Server::get(AccountManager::class); + $accountManager->deleteUser($this); + + $database = \OCP\Server::get(IDBConnection::class); + try { + // We need to create a transaction to make sure we are in a defined state + // because if all user values are removed also the flag is gone, but if an exception happens (e.g. database lost connection on the set operation) + // exactly here we are in an undefined state as the data is still present but the user does not exist on the system anymore. + $database->beginTransaction(); + // Remove all user settings + $this->config->deleteAllUserValues($this->uid); + // But again set flag that this user is about to be deleted + $this->config->setUserValue($this->uid, 'core', 'deleted', 'true'); + $this->config->setUserValue($this->uid, 'core', 'deleted.home-path', $this->getHome()); + // Commit the transaction so we are in a defined state: either the preferences are removed or an exception occurred but the delete flag is still present + $database->commit(); + } catch (\Throwable $e) { + $database->rollback(); + throw $e; + } + if ($this->emitter !== null) { /** @deprecated 21.0.0 use UserDeletedEvent event with the IEventDispatcher instead */ - $this->legacyDispatcher->dispatch(IUser::class . '::postDelete', new GenericEvent($this)); - if ($this->emitter) { - /** @deprecated 21.0.0 use UserDeletedEvent event with the IEventDispatcher instead */ - $this->emitter->emit('\OC\User', 'postDelete', [$this]); - } - $this->dispatcher->dispatchTyped(new UserDeletedEvent($this)); + $this->emitter->emit('\OC\User', 'postDelete', [$this]); } - return !($result === false); + $this->dispatcher->dispatchTyped(new UserDeletedEvent($this)); + + // Finally we can unset the delete flag and all other states + $this->config->deleteAllUserValues($this->uid); + + return true; } /** @@ -285,28 +344,42 @@ class User implements IUser { * @return bool */ public function setPassword($password, $recoveryPassword = null) { - $this->legacyDispatcher->dispatch(IUser::class . '::preSetPassword', new GenericEvent($this, [ - 'password' => $password, - 'recoveryPassword' => $recoveryPassword, - ])); + $this->dispatcher->dispatchTyped(new BeforePasswordUpdatedEvent($this, $password, $recoveryPassword)); if ($this->emitter) { $this->emitter->emit('\OC\User', 'preSetPassword', [$this, $password, $recoveryPassword]); } if ($this->backend->implementsActions(Backend::SET_PASSWORD)) { - $result = $this->backend->setPassword($this->uid, $password); - $this->legacyDispatcher->dispatch(IUser::class . '::postSetPassword', new GenericEvent($this, [ - 'password' => $password, - 'recoveryPassword' => $recoveryPassword, - ])); - if ($this->emitter) { - $this->emitter->emit('\OC\User', 'postSetPassword', [$this, $password, $recoveryPassword]); + /** @var ISetPasswordBackend $backend */ + $backend = $this->backend; + $result = $backend->setPassword($this->uid, $password); + + if ($result !== false) { + $this->dispatcher->dispatchTyped(new PasswordUpdatedEvent($this, $password, $recoveryPassword)); + if ($this->emitter) { + $this->emitter->emit('\OC\User', 'postSetPassword', [$this, $password, $recoveryPassword]); + } } + return !($result === false); } else { return false; } } + public function getPasswordHash(): ?string { + if (!($this->backend instanceof IPasswordHashBackend)) { + return null; + } + return $this->backend->getPasswordHash($this->uid); + } + + public function setPasswordHash(string $passwordHash): bool { + if (!($this->backend instanceof IPasswordHashBackend)) { + return false; + } + return $this->backend->setPasswordHash($this->uid, $passwordHash); + } + /** * get the users home folder to mount * @@ -314,12 +387,11 @@ class User implements IUser { */ public function getHome() { if (!$this->home) { - if ($this->backend->implementsActions(Backend::GET_HOME) and $home = $this->backend->getHome($this->uid)) { + /** @psalm-suppress UndefinedInterfaceMethod Once we get rid of the legacy implementsActions, psalm won't complain anymore */ + if (($this->backend instanceof IGetHomeBackend || $this->backend->implementsActions(Backend::GET_HOME)) && $home = $this->backend->getHome($this->uid)) { $this->home = $home; - } elseif ($this->config) { - $this->home = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/' . $this->uid; } else { - $this->home = \OC::$SERVERROOT . '/data/' . $this->uid; + $this->home = $this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data') . '/' . $this->uid; } } return $this->home; @@ -337,18 +409,20 @@ class User implements IUser { return get_class($this->backend); } - public function getBackend() { + public function getBackend(): ?UserInterface { return $this->backend; } /** - * check if the backend allows the user to change his avatar on Personal page + * Check if the backend allows the user to change their avatar on Personal page * * @return bool */ public function canChangeAvatar() { - if ($this->backend->implementsActions(Backend::PROVIDE_AVATAR)) { - return $this->backend->canChangeAvatar($this->uid); + if ($this->backend instanceof IProvideAvatarBackend || $this->backend->implementsActions(Backend::PROVIDE_AVATAR)) { + /** @var IProvideAvatarBackend $backend */ + $backend = $this->backend; + return $backend->canChangeAvatar($this->uid); } return true; } @@ -368,33 +442,63 @@ class User implements IUser { * @return bool */ public function canChangeDisplayName() { - if ($this->config->getSystemValue('allow_user_to_change_display_name') === false) { + if (!$this->config->getSystemValueBool('allow_user_to_change_display_name', true)) { return false; } return $this->backend->implementsActions(Backend::SET_DISPLAYNAME); } + public function canChangeEmail(): bool { + // Fallback to display name value to avoid changing behavior with the new option. + return $this->config->getSystemValueBool('allow_user_to_change_email', $this->config->getSystemValueBool('allow_user_to_change_display_name', true)); + } + /** * check if the user is enabled * * @return bool */ public function isEnabled() { - return $this->enabled; + $queryDatabaseValue = function (): bool { + if ($this->enabled === null) { + $enabled = $this->config->getUserValue($this->uid, 'core', 'enabled', 'true'); + $this->enabled = $enabled === 'true'; + } + return $this->enabled; + }; + if ($this->backend instanceof IProvideEnabledStateBackend) { + return $this->backend->isUserEnabled($this->uid, $queryDatabaseValue); + } else { + return $queryDatabaseValue(); + } } /** * set the enabled status for the user * - * @param bool $enabled + * @return void */ public function setEnabled(bool $enabled = true) { $oldStatus = $this->isEnabled(); - $this->enabled = $enabled; - if ($oldStatus !== $this->enabled) { - // TODO: First change the value, then trigger the event as done for all other properties. - $this->triggerChange('enabled', $enabled, $oldStatus); + $setDatabaseValue = function (bool $enabled): void { $this->config->setUserValue($this->uid, 'core', 'enabled', $enabled ? 'true' : 'false'); + $this->enabled = $enabled; + }; + if ($this->backend instanceof IProvideEnabledStateBackend) { + $queryDatabaseValue = function (): bool { + if ($this->enabled === null) { + $enabled = $this->config->getUserValue($this->uid, 'core', 'enabled', 'true'); + $this->enabled = $enabled === 'true'; + } + return $this->enabled; + }; + $enabled = $this->backend->setUserEnabled($this->uid, $enabled, $queryDatabaseValue, $setDatabaseValue); + if ($oldStatus !== $enabled) { + $this->triggerChange('enabled', $enabled, $oldStatus); + } + } elseif ($oldStatus !== $enabled) { + $setDatabaseValue($enabled); + $this->triggerChange('enabled', $enabled, $oldStatus); } } @@ -405,7 +509,23 @@ class User implements IUser { * @since 9.0.0 */ public function getEMailAddress() { - return $this->config->getUserValue($this->uid, 'settings', 'email', null); + return $this->getPrimaryEMailAddress() ?? $this->getSystemEMailAddress(); + } + + /** + * @inheritDoc + */ + public function getSystemEMailAddress(): ?string { + $email = $this->config->getUserValue($this->uid, 'settings', 'email', null); + return $email ? mb_strtolower(trim($email)) : null; + } + + /** + * @inheritDoc + */ + public function getPrimaryEMailAddress(): ?string { + $email = $this->config->getUserValue($this->uid, 'settings', 'primary_email', null); + return $email ? mb_strtolower(trim($email)) : null; } /** @@ -426,27 +546,78 @@ class User implements IUser { } if ($quota === 'default') { $quota = $this->config->getAppValue('files', 'default_quota', 'none'); + + // if unlimited quota is not allowed => avoid getting 'unlimited' as default_quota fallback value + // use the first preset instead + $allowUnlimitedQuota = $this->config->getAppValue('files', 'allow_unlimited_quota', '1') === '1'; + if (!$allowUnlimitedQuota) { + $presets = $this->config->getAppValue('files', 'quota_preset', '1 GB, 5 GB, 10 GB'); + $presets = array_filter(array_map('trim', explode(',', $presets))); + $quotaPreset = array_values(array_diff($presets, ['default', 'none'])); + if (count($quotaPreset) > 0) { + $quota = $this->config->getAppValue('files', 'default_quota', $quotaPreset[0]); + } + } } return $quota; } + public function getQuotaBytes(): int|float { + $quota = $this->getQuota(); + if ($quota === 'none') { + return \OCP\Files\FileInfo::SPACE_UNLIMITED; + } + + $bytes = \OCP\Util::computerFileSize($quota); + if ($bytes === false) { + return \OCP\Files\FileInfo::SPACE_UNKNOWN; + } + return $bytes; + } + /** * set the users' quota * * @param string $quota * @return void + * @throws InvalidArgumentException * @since 9.0.0 */ public function setQuota($quota) { $oldQuota = $this->config->getUserValue($this->uid, 'files', 'quota', ''); if ($quota !== 'none' and $quota !== 'default') { - $quota = OC_Helper::computerFileSize($quota); - $quota = OC_Helper::humanFileSize($quota); + $bytesQuota = \OCP\Util::computerFileSize($quota); + if ($bytesQuota === false) { + throw new InvalidArgumentException('Failed to set quota to invalid value ' . $quota); + } + $quota = \OCP\Util::humanFileSize($bytesQuota); } if ($quota !== $oldQuota) { $this->config->setUserValue($this->uid, 'files', 'quota', $quota); $this->triggerChange('quota', $quota, $oldQuota); } + \OC_Helper::clearStorageInfo('/' . $this->uid . '/files'); + } + + public function getManagerUids(): array { + $encodedUids = $this->config->getUserValue( + $this->uid, + 'settings', + self::CONFIG_KEY_MANAGERS, + '[]' + ); + return json_decode($encodedUids, false, 512, JSON_THROW_ON_ERROR); + } + + public function setManagerUids(array $uids): void { + $oldUids = $this->getManagerUids(); + $this->config->setUserValue( + $this->uid, + 'settings', + self::CONFIG_KEY_MANAGERS, + json_encode($uids, JSON_THROW_ON_ERROR) + ); + $this->triggerChange('managers', $uids, $oldUids); } /** @@ -459,11 +630,11 @@ class User implements IUser { public function getAvatarImage($size) { // delay the initialization if (is_null($this->avatarManager)) { - $this->avatarManager = \OC::$server->getAvatarManager(); + $this->avatarManager = \OC::$server->get(IAvatarManager::class); } $avatar = $this->avatarManager->getAvatar($this->uid); - $image = $avatar->get(-1); + $image = $avatar->get($size); if ($image) { return $image; } @@ -479,31 +650,24 @@ class User implements IUser { */ public function getCloudId() { $uid = $this->getUID(); - $server = $this->urlGenerator->getAbsoluteURL('/'); - $server = rtrim($this->removeProtocolFromUrl($server), '/'); - return \OC::$server->getCloudIdManager()->getCloudId($uid, $server)->getId(); + $server = rtrim($this->urlGenerator->getAbsoluteURL('/'), '/'); + if (str_ends_with($server, '/index.php')) { + $server = substr($server, 0, -10); + } + $server = $this->removeProtocolFromUrl($server); + return $uid . '@' . $server; } - /** - * @param string $url - * @return string - */ - private function removeProtocolFromUrl($url) { - if (strpos($url, 'https://') === 0) { + private function removeProtocolFromUrl(string $url): string { + if (str_starts_with($url, 'https://')) { return substr($url, strlen('https://')); - } elseif (strpos($url, 'http://') === 0) { - return substr($url, strlen('http://')); } return $url; } public function triggerChange($feature, $value = null, $oldValue = null) { - $this->legacyDispatcher->dispatch(IUser::class . '::changeUser', new GenericEvent($this, [ - 'feature' => $feature, - 'value' => $value, - 'oldValue' => $oldValue, - ])); + $this->dispatcher->dispatchTyped(new UserChangedEvent($this, $feature, $value, $oldValue)); if ($this->emitter) { $this->emitter->emit('\OC\User', 'changeUser', [$this, $feature, $value, $oldValue]); } |