diff options
Diffstat (limited to 'apps/settings/lib/Controller/UsersController.php')
-rw-r--r-- | apps/settings/lib/Controller/UsersController.php | 584 |
1 files changed, 584 insertions, 0 deletions
diff --git a/apps/settings/lib/Controller/UsersController.php b/apps/settings/lib/Controller/UsersController.php new file mode 100644 index 00000000000..8efd3eeb8ca --- /dev/null +++ b/apps/settings/lib/Controller/UsersController.php @@ -0,0 +1,584 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Settings\Controller; + +use InvalidArgumentException; +use OC\AppFramework\Http; +use OC\Encryption\Exceptions\ModuleDoesNotExistsException; +use OC\ForbiddenException; +use OC\Group\MetaData; +use OC\KnownUser\KnownUserService; +use OC\Security\IdentityProof\Manager; +use OC\User\Manager as UserManager; +use OCA\Settings\BackgroundJobs\VerifyUserData; +use OCA\Settings\Events\BeforeTemplateRenderedEvent; +use OCA\Settings\Settings\Admin\Users; +use OCA\User_LDAP\User_Proxy; +use OCP\Accounts\IAccount; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\BackgroundJob\IJobList; +use OCP\Encryption\IManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Group\ISubAdmin; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Util; +use function in_array; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class UsersController extends Controller { + /** Limit for counting users for subadmins, to avoid spending too much time */ + private const COUNT_LIMIT_FOR_SUBADMINS = 999; + + public function __construct( + string $appName, + IRequest $request, + private UserManager $userManager, + private IGroupManager $groupManager, + private IUserSession $userSession, + private IConfig $config, + private IL10N $l10n, + private IMailer $mailer, + private IFactory $l10nFactory, + private IAppManager $appManager, + private IAccountManager $accountManager, + private Manager $keyManager, + private IJobList $jobList, + private IManager $encryptionManager, + private KnownUserService $knownUserService, + private IEventDispatcher $dispatcher, + private IInitialState $initialState, + ) { + parent::__construct($appName, $request); + } + + + /** + * Display users list template + * + * @return TemplateResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function usersListByGroup(INavigationManager $navigationManager, ISubAdmin $subAdmin): TemplateResponse { + return $this->usersList($navigationManager, $subAdmin); + } + + /** + * Display users list template + * + * @return TemplateResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function usersList(INavigationManager $navigationManager, ISubAdmin $subAdmin): TemplateResponse { + $user = $this->userSession->getUser(); + $uid = $user->getUID(); + $isAdmin = $this->groupManager->isAdmin($uid); + $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($uid); + + $navigationManager->setActiveEntry('core_users'); + + /* SORT OPTION: SORT_USERCOUNT or SORT_GROUPNAME */ + $sortGroupsBy = MetaData::SORT_USERCOUNT; + $isLDAPUsed = false; + if ($this->config->getSystemValueBool('sort_groups_by_name', false)) { + $sortGroupsBy = MetaData::SORT_GROUPNAME; + } else { + if ($this->appManager->isEnabledForUser('user_ldap')) { + $isLDAPUsed + = $this->groupManager->isBackendUsed('\OCA\User_LDAP\Group_Proxy'); + if ($isLDAPUsed) { + // LDAP user count can be slow, so we sort by group name here + $sortGroupsBy = MetaData::SORT_GROUPNAME; + } + } + } + + $canChangePassword = $this->canAdminChangeUserPasswords(); + + /* GROUPS */ + $groupsInfo = new MetaData( + $uid, + $isAdmin, + $isDelegatedAdmin, + $this->groupManager, + $this->userSession + ); + + $adminGroup = $this->groupManager->get('admin'); + $adminGroupData = [ + 'id' => $adminGroup->getGID(), + 'name' => $adminGroup->getDisplayName(), + 'usercount' => $sortGroupsBy === MetaData::SORT_USERCOUNT ? $adminGroup->count() : 0, + 'disabled' => $adminGroup->countDisabled(), + 'canAdd' => $adminGroup->canAddUser(), + 'canRemove' => $adminGroup->canRemoveUser(), + ]; + + if (!$isLDAPUsed && $this->appManager->isEnabledForUser('user_ldap')) { + $isLDAPUsed = (bool)array_reduce($this->userManager->getBackends(), function ($ldapFound, $backend) { + return $ldapFound || $backend instanceof User_Proxy; + }); + } + + $disabledUsers = -1; + $userCount = 0; + + if (!$isLDAPUsed) { + if ($isAdmin || $isDelegatedAdmin) { + $disabledUsers = $this->userManager->countDisabledUsers(); + $userCount = array_reduce($this->userManager->countUsers(), function ($v, $w) { + return $v + (int)$w; + }, 0); + } else { + // User is subadmin ! + [$userCount,$disabledUsers] = $this->userManager->countUsersAndDisabledUsersOfGroups($groupsInfo->getGroups(), self::COUNT_LIMIT_FOR_SUBADMINS); + } + + if ($disabledUsers > 0) { + $userCount -= $disabledUsers; + } + } + + $recentUsersGroup = [ + 'id' => '__nc_internal_recent', + 'name' => $this->l10n->t('Recently active'), + 'usercount' => $this->userManager->countSeenUsers(), + ]; + + $disabledUsersGroup = [ + 'id' => 'disabled', + 'name' => $this->l10n->t('Disabled accounts'), + 'usercount' => $disabledUsers + ]; + + if (!$isAdmin && !$isDelegatedAdmin) { + $subAdminGroups = array_map( + fn (IGroup $group) => ['id' => $group->getGID(), 'name' => $group->getDisplayName()], + $subAdmin->getSubAdminsGroups($user), + ); + $subAdminGroups = array_values($subAdminGroups); + } + + /* QUOTAS PRESETS */ + $quotaPreset = $this->parseQuotaPreset($this->config->getAppValue('files', 'quota_preset', '1 GB, 5 GB, 10 GB')); + $allowUnlimitedQuota = $this->config->getAppValue('files', 'allow_unlimited_quota', '1') === '1'; + if (!$allowUnlimitedQuota && count($quotaPreset) > 0) { + $defaultQuota = $this->config->getAppValue('files', 'default_quota', $quotaPreset[0]); + } else { + $defaultQuota = $this->config->getAppValue('files', 'default_quota', 'none'); + } + + $event = new BeforeTemplateRenderedEvent(); + $this->dispatcher->dispatch('OC\Settings\Users::loadAdditionalScripts', $event); + $this->dispatcher->dispatchTyped($event); + + /* LANGUAGES */ + $languages = $this->l10nFactory->getLanguages(); + + /** Using LDAP or admins (system config) can enfore sorting by group name, in this case the frontend setting is overwritten */ + $forceSortGroupByName = $sortGroupsBy === MetaData::SORT_GROUPNAME; + + /* FINAL DATA */ + $serverData = []; + // groups + $serverData['systemGroups'] = [$adminGroupData, $recentUsersGroup, $disabledUsersGroup]; + $serverData['subAdminGroups'] = $subAdminGroups ?? []; + // Various data + $serverData['isAdmin'] = $isAdmin; + $serverData['isDelegatedAdmin'] = $isDelegatedAdmin; + $serverData['sortGroups'] = $forceSortGroupByName + ? MetaData::SORT_GROUPNAME + : (int)$this->config->getAppValue('core', 'group.sortBy', (string)MetaData::SORT_USERCOUNT); + $serverData['forceSortGroupByName'] = $forceSortGroupByName; + $serverData['quotaPreset'] = $quotaPreset; + $serverData['allowUnlimitedQuota'] = $allowUnlimitedQuota; + $serverData['userCount'] = $userCount; + $serverData['languages'] = $languages; + $serverData['defaultLanguage'] = $this->config->getSystemValue('default_language', 'en'); + $serverData['forceLanguage'] = $this->config->getSystemValue('force_language', false); + // Settings + $serverData['defaultQuota'] = $defaultQuota; + $serverData['canChangePassword'] = $canChangePassword; + $serverData['newUserGenerateUserID'] = $this->config->getAppValue('core', 'newUser.generateUserID', 'no') === 'yes'; + $serverData['newUserRequireEmail'] = $this->config->getAppValue('core', 'newUser.requireEmail', 'no') === 'yes'; + $serverData['newUserSendEmail'] = $this->config->getAppValue('core', 'newUser.sendEmail', 'yes') === 'yes'; + + $this->initialState->provideInitialState('usersSettings', $serverData); + + Util::addStyle('settings', 'settings'); + Util::addScript('settings', 'vue-settings-apps-users-management'); + + return new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]); + } + + /** + * @param string $key + * @param string $value + * + * @return JSONResponse + */ + #[AuthorizedAdminSetting(settings:Users::class)] + public function setPreference(string $key, string $value): JSONResponse { + $allowed = ['newUser.sendEmail', 'group.sortBy']; + if (!in_array($key, $allowed, true)) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->config->setAppValue('core', $key, $value); + + return new JSONResponse([]); + } + + /** + * Parse the app value for quota_present + * + * @param string $quotaPreset + * @return array + */ + protected function parseQuotaPreset(string $quotaPreset): array { + // 1 GB, 5 GB, 10 GB => [1 GB, 5 GB, 10 GB] + $presets = array_filter(array_map('trim', explode(',', $quotaPreset))); + // Drop default and none, Make array indexes numerically + return array_values(array_diff($presets, ['default', 'none'])); + } + + /** + * check if the admin can change the users password + * + * The admin can change the passwords if: + * + * - no encryption module is loaded and encryption is disabled + * - encryption module is loaded but it doesn't require per user keys + * + * The admin can not change the passwords if: + * + * - an encryption module is loaded and it uses per-user keys + * - encryption is enabled but no encryption modules are loaded + * + * @return bool + */ + protected function canAdminChangeUserPasswords(): bool { + $isEncryptionEnabled = $this->encryptionManager->isEnabled(); + try { + $noUserSpecificEncryptionKeys = !$this->encryptionManager->getEncryptionModule()->needDetailedAccessList(); + $isEncryptionModuleLoaded = true; + } catch (ModuleDoesNotExistsException $e) { + $noUserSpecificEncryptionKeys = true; + $isEncryptionModuleLoaded = false; + } + $canChangePassword = ($isEncryptionModuleLoaded && $noUserSpecificEncryptionKeys) + || (!$isEncryptionModuleLoaded && !$isEncryptionEnabled); + + return $canChangePassword; + } + + /** + * @NoSubAdminRequired + * + * @param string|null $avatarScope + * @param string|null $displayname + * @param string|null $displaynameScope + * @param string|null $phone + * @param string|null $phoneScope + * @param string|null $email + * @param string|null $emailScope + * @param string|null $website + * @param string|null $websiteScope + * @param string|null $address + * @param string|null $addressScope + * @param string|null $twitter + * @param string|null $twitterScope + * @param string|null $bluesky + * @param string|null $blueskyScope + * @param string|null $fediverse + * @param string|null $fediverseScope + * @param string|null $birthdate + * @param string|null $birthdateScope + * + * @return DataResponse + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + #[UserRateLimit(limit: 5, period: 60)] + public function setUserSettings(?string $avatarScope = null, + ?string $displayname = null, + ?string $displaynameScope = null, + ?string $phone = null, + ?string $phoneScope = null, + ?string $email = null, + ?string $emailScope = null, + ?string $website = null, + ?string $websiteScope = null, + ?string $address = null, + ?string $addressScope = null, + ?string $twitter = null, + ?string $twitterScope = null, + ?string $bluesky = null, + ?string $blueskyScope = null, + ?string $fediverse = null, + ?string $fediverseScope = null, + ?string $birthdate = null, + ?string $birthdateScope = null, + ?string $pronouns = null, + ?string $pronounsScope = null, + ) { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse( + [ + 'status' => 'error', + 'data' => [ + 'message' => $this->l10n->t('Invalid account') + ] + ], + Http::STATUS_UNAUTHORIZED + ); + } + + $email = !is_null($email) ? strtolower($email) : $email; + if (!empty($email) && !$this->mailer->validateMailAddress($email)) { + return new DataResponse( + [ + 'status' => 'error', + 'data' => [ + 'message' => $this->l10n->t('Invalid mail address') + ] + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + $userAccount = $this->accountManager->getAccount($user); + $oldPhoneValue = $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(); + + $updatable = [ + IAccountManager::PROPERTY_AVATAR => ['value' => null, 'scope' => $avatarScope], + IAccountManager::PROPERTY_DISPLAYNAME => ['value' => $displayname, 'scope' => $displaynameScope], + IAccountManager::PROPERTY_EMAIL => ['value' => $email, 'scope' => $emailScope], + IAccountManager::PROPERTY_WEBSITE => ['value' => $website, 'scope' => $websiteScope], + IAccountManager::PROPERTY_ADDRESS => ['value' => $address, 'scope' => $addressScope], + IAccountManager::PROPERTY_PHONE => ['value' => $phone, 'scope' => $phoneScope], + IAccountManager::PROPERTY_TWITTER => ['value' => $twitter, 'scope' => $twitterScope], + IAccountManager::PROPERTY_BLUESKY => ['value' => $bluesky, 'scope' => $blueskyScope], + IAccountManager::PROPERTY_FEDIVERSE => ['value' => $fediverse, 'scope' => $fediverseScope], + IAccountManager::PROPERTY_BIRTHDATE => ['value' => $birthdate, 'scope' => $birthdateScope], + IAccountManager::PROPERTY_PRONOUNS => ['value' => $pronouns, 'scope' => $pronounsScope], + ]; + $allowUserToChangeDisplayName = $this->config->getSystemValueBool('allow_user_to_change_display_name', true); + foreach ($updatable as $property => $data) { + if ($allowUserToChangeDisplayName === false + && in_array($property, [IAccountManager::PROPERTY_DISPLAYNAME, IAccountManager::PROPERTY_EMAIL], true)) { + continue; + } + $property = $userAccount->getProperty($property); + if ($data['value'] !== null) { + $property->setValue($data['value']); + } + if ($data['scope'] !== null) { + $property->setScope($data['scope']); + } + } + + try { + $this->saveUserSettings($userAccount); + if ($oldPhoneValue !== $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue()) { + $this->knownUserService->deleteByContactUserId($user->getUID()); + } + return new DataResponse( + [ + 'status' => 'success', + 'data' => [ + 'userId' => $user->getUID(), + 'avatarScope' => $userAccount->getProperty(IAccountManager::PROPERTY_AVATAR)->getScope(), + 'displayname' => $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue(), + 'displaynameScope' => $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getScope(), + 'phone' => $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(), + 'phoneScope' => $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getScope(), + 'email' => $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(), + 'emailScope' => $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(), + 'website' => $userAccount->getProperty(IAccountManager::PROPERTY_WEBSITE)->getValue(), + 'websiteScope' => $userAccount->getProperty(IAccountManager::PROPERTY_WEBSITE)->getScope(), + 'address' => $userAccount->getProperty(IAccountManager::PROPERTY_ADDRESS)->getValue(), + 'addressScope' => $userAccount->getProperty(IAccountManager::PROPERTY_ADDRESS)->getScope(), + 'twitter' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getValue(), + 'twitterScope' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getScope(), + 'bluesky' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getValue(), + 'blueskyScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BLUESKY)->getScope(), + 'fediverse' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getValue(), + 'fediverseScope' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getScope(), + 'birthdate' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getValue(), + 'birthdateScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getScope(), + 'pronouns' => $userAccount->getProperty(IAccountManager::PROPERTY_PRONOUNS)->getValue(), + 'pronounsScope' => $userAccount->getProperty(IAccountManager::PROPERTY_PRONOUNS)->getScope(), + 'message' => $this->l10n->t('Settings saved'), + ], + ], + Http::STATUS_OK + ); + } catch (ForbiddenException|InvalidArgumentException|PropertyDoesNotExistException $e) { + return new DataResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $e->getMessage() + ], + ]); + } + } + /** + * update account manager with new user data + * + * @throws ForbiddenException + * @throws InvalidArgumentException + */ + protected function saveUserSettings(IAccount $userAccount): void { + // keep the user back-end up-to-date with the latest display name and email + // address + $oldDisplayName = $userAccount->getUser()->getDisplayName(); + if ($oldDisplayName !== $userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue()) { + $result = $userAccount->getUser()->setDisplayName($userAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME)->getValue()); + if ($result === false) { + throw new ForbiddenException($this->l10n->t('Unable to change full name')); + } + } + + $oldEmailAddress = $userAccount->getUser()->getSystemEMailAddress(); + $oldEmailAddress = strtolower((string)$oldEmailAddress); + if ($oldEmailAddress !== strtolower($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue())) { + // this is the only permission a backend provides and is also used + // for the permission of setting a email address + if (!$userAccount->getUser()->canChangeDisplayName()) { + throw new ForbiddenException($this->l10n->t('Unable to change email address')); + } + $userAccount->getUser()->setSystemEMailAddress($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue()); + } + + try { + $this->accountManager->updateAccount($userAccount); + } catch (InvalidArgumentException $e) { + if ($e->getMessage() === IAccountManager::PROPERTY_PHONE) { + throw new InvalidArgumentException($this->l10n->t('Unable to set invalid phone number')); + } + if ($e->getMessage() === IAccountManager::PROPERTY_WEBSITE) { + throw new InvalidArgumentException($this->l10n->t('Unable to set invalid website')); + } + throw new InvalidArgumentException($this->l10n->t('Some account data was invalid')); + } + } + + /** + * Set the mail address of a user + * + * @NoSubAdminRequired + * + * @param string $account + * @param bool $onlyVerificationCode only return verification code without updating the data + * @return DataResponse + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function getVerificationCode(string $account, bool $onlyVerificationCode): DataResponse { + $user = $this->userSession->getUser(); + + if ($user === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $userAccount = $this->accountManager->getAccount($user); + $cloudId = $user->getCloudId(); + $message = 'Use my Federated Cloud ID to share with me: ' . $cloudId; + $signature = $this->signMessage($user, $message); + + $code = $message . ' ' . $signature; + $codeMd5 = $message . ' ' . md5($signature); + + switch ($account) { + case 'verify-twitter': + $msg = $this->l10n->t('In order to verify your Twitter account, post the following tweet on Twitter (please make sure to post it without any line breaks):'); + $code = $codeMd5; + $type = IAccountManager::PROPERTY_TWITTER; + break; + case 'verify-website': + $msg = $this->l10n->t('In order to verify your Website, store the following content in your web-root at \'.well-known/CloudIdVerificationCode.txt\' (please make sure that the complete text is in one line):'); + $type = IAccountManager::PROPERTY_WEBSITE; + break; + default: + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $userProperty = $userAccount->getProperty($type); + $userProperty + ->setVerified(IAccountManager::VERIFICATION_IN_PROGRESS) + ->setVerificationData($signature); + + if ($onlyVerificationCode === false) { + $this->accountManager->updateAccount($userAccount); + + $this->jobList->add(VerifyUserData::class, + [ + 'verificationCode' => $code, + 'data' => $userProperty->getValue(), + 'type' => $type, + 'uid' => $user->getUID(), + 'try' => 0, + 'lastRun' => $this->getCurrentTime() + ] + ); + } + + return new DataResponse(['msg' => $msg, 'code' => $code]); + } + + /** + * get current timestamp + * + * @return int + */ + protected function getCurrentTime(): int { + return time(); + } + + /** + * sign message with users private key + * + * @param IUser $user + * @param string $message + * + * @return string base64 encoded signature + */ + protected function signMessage(IUser $user, string $message): string { + $privateKey = $this->keyManager->getKey($user)->getPrivate(); + openssl_sign(json_encode($message), $signature, $privateKey, OPENSSL_ALGO_SHA512); + return base64_encode($signature); + } +} |