diff options
Diffstat (limited to 'apps/settings/lib/Controller')
17 files changed, 2934 insertions, 0 deletions
diff --git a/apps/settings/lib/Controller/AISettingsController.php b/apps/settings/lib/Controller/AISettingsController.php new file mode 100644 index 00000000000..114cbf61514 --- /dev/null +++ b/apps/settings/lib/Controller/AISettingsController.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Settings\Controller; + +use OCA\Settings\Settings\Admin\ArtificialIntelligence; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\DataResponse; +use OCP\IAppConfig; +use OCP\IRequest; + +class AISettingsController extends Controller { + + public function __construct( + $appName, + IRequest $request, + private IAppConfig $appConfig, + ) { + parent::__construct($appName, $request); + } + + /** + * Sets the email settings + * + * @param array $settings + * @return DataResponse + */ + #[AuthorizedAdminSetting(settings: ArtificialIntelligence::class)] + public function update($settings) { + $keys = ['ai.stt_provider', 'ai.textprocessing_provider_preferences', 'ai.taskprocessing_provider_preferences','ai.taskprocessing_type_preferences', 'ai.translation_provider_preferences', 'ai.text2image_provider', 'ai.taskprocessing_guests']; + foreach ($keys as $key) { + if (!isset($settings[$key])) { + continue; + } + $this->appConfig->setValueString('core', $key, json_encode($settings[$key]), lazy: in_array($key, \OC\TaskProcessing\Manager::LAZY_CONFIG_KEYS, true)); + } + + return new DataResponse(); + } +} diff --git a/apps/settings/lib/Controller/AdminSettingsController.php b/apps/settings/lib/Controller/AdminSettingsController.php new file mode 100644 index 00000000000..15e2c392148 --- /dev/null +++ b/apps/settings/lib/Controller/AdminSettingsController.php @@ -0,0 +1,61 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Group\ISubAdmin; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Settings\IDeclarativeManager; +use OCP\Settings\IManager as ISettingsManager; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class AdminSettingsController extends Controller { + use CommonSettingsTrait; + + public function __construct( + $appName, + IRequest $request, + INavigationManager $navigationManager, + ISettingsManager $settingsManager, + IUserSession $userSession, + IGroupManager $groupManager, + ISubAdmin $subAdmin, + IDeclarativeManager $declarativeSettingsManager, + IInitialState $initialState, + ) { + parent::__construct($appName, $request); + $this->navigationManager = $navigationManager; + $this->settingsManager = $settingsManager; + $this->userSession = $userSession; + $this->groupManager = $groupManager; + $this->subAdmin = $subAdmin; + $this->declarativeSettingsManager = $declarativeSettingsManager; + $this->initialState = $initialState; + } + + /** + * @NoSubAdminRequired + * We are checking the permissions in the getSettings method. If there is no allowed + * settings for the given section. The user will be greeted by an error message. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(string $section): TemplateResponse { + return $this->getIndexResponse( + 'admin', + $section, + ); + } +} diff --git a/apps/settings/lib/Controller/AppSettingsController.php b/apps/settings/lib/Controller/AppSettingsController.php new file mode 100644 index 00000000000..a85ee8cc20a --- /dev/null +++ b/apps/settings/lib/Controller/AppSettingsController.php @@ -0,0 +1,689 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Settings\Controller; + +use OC\App\AppManager; +use OC\App\AppStore\Bundles\BundleFetcher; +use OC\App\AppStore\Fetcher\AppDiscoverFetcher; +use OC\App\AppStore\Fetcher\AppFetcher; +use OC\App\AppStore\Fetcher\CategoryFetcher; +use OC\App\AppStore\Version\VersionParser; +use OC\App\DependencyAnalyzer; +use OC\App\Platform; +use OC\Installer; +use OCA\AppAPI\Service\ExAppsPageService; +use OCP\App\AppPathNotFoundException; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Security\RateLimiting\ILimiter; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class AppSettingsController extends Controller { + + /** @var array */ + private $allApps = []; + + private IAppData $appData; + + public function __construct( + string $appName, + IRequest $request, + IAppDataFactory $appDataFactory, + private IL10N $l10n, + private IConfig $config, + private INavigationManager $navigationManager, + private AppManager $appManager, + private CategoryFetcher $categoryFetcher, + private AppFetcher $appFetcher, + private IFactory $l10nFactory, + private BundleFetcher $bundleFetcher, + private Installer $installer, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + private IInitialState $initialState, + private AppDiscoverFetcher $discoverFetcher, + private IClientService $clientService, + ) { + parent::__construct($appName, $request); + $this->appData = $appDataFactory->get('appstore'); + } + + /** + * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1 + * + * @return TemplateResponse + */ + #[NoCSRFRequired] + public function viewApps(): TemplateResponse { + $this->navigationManager->setActiveEntry('core_apps'); + + $this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true)); + $this->initialState->provideInitialState('appstoreBundles', $this->getBundles()); + $this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual')); + $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates())); + + if ($this->appManager->isEnabledForAnyone('app_api')) { + try { + Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState); + } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) { + } + } + + $policy = new ContentSecurityPolicy(); + $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com'); + + $templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]); + $templateResponse->setContentSecurityPolicy($policy); + + Util::addStyle('settings', 'settings'); + Util::addScript('settings', 'vue-settings-apps-users-management'); + + return $templateResponse; + } + + /** + * Get all active entries for the app discover section + */ + #[NoCSRFRequired] + public function getAppDiscoverJSON(): JSONResponse { + $data = $this->discoverFetcher->get(true); + return new JSONResponse(array_values($data)); + } + + /** + * Get a image for the app discover section - this is proxied for privacy and CSP reasons + * + * @param string $image + * @throws \Exception + */ + #[NoCSRFRequired] + public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response { + $getEtag = $this->discoverFetcher->getETag() ?? date('Y-m'); + $etag = trim($getEtag, '"'); + + $folder = null; + try { + $folder = $this->appData->getFolder('app-discover-cache'); + $this->cleanUpImageCache($folder, $etag); + } catch (\Throwable $e) { + $folder = $this->appData->newFolder('app-discover-cache'); + } + + // Get the current cache folder + try { + $folder = $folder->getFolder($etag); + } catch (NotFoundException $e) { + $folder = $folder->newFolder($etag); + } + + $info = pathinfo($fileName); + $hashName = md5($fileName); + $allFiles = $folder->getDirectoryListing(); + // Try to find the file + $file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) { + return str_starts_with($file->getName(), $hashName); + }); + // Get the first entry + $file = reset($file); + // If not found request from Web + if ($file === false) { + $user = $session->getUser(); + // this route is not public thus we can assume a user is logged-in + assert($user !== null); + // Register a user request to throttle fetching external data + // this will prevent using the server for DoS of other systems. + $limiter->registerUserRequest( + 'settings-discover-media', + // allow up to 24 media requests per hour + // this should be a sane default when a completely new section is loaded + // keep in mind browsers request all files from a source-set + 24, + 60 * 60, + $user, + ); + + if (!$this->checkCanDownloadMedia($fileName)) { + $this->logger->warning('Tried to load media files for app discover section from untrusted source'); + return new NotFoundResponse(Http::STATUS_BAD_REQUEST); + } + + try { + $client = $this->clientService->newClient(); + $fileResponse = $client->get($fileName); + $contentType = $fileResponse->getHeader('Content-Type'); + $extension = $info['extension'] ?? ''; + $file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody()); + } catch (\Throwable $e) { + $this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]); + return new NotFoundResponse(); + } + } else { + // File was found so we can get the content type from the file name + $contentType = base64_decode(explode('.', $file->getName())[1] ?? ''); + } + + $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]); + // cache for 7 days + $response->cacheFor(604800, false, true); + return $response; + } + + private function checkCanDownloadMedia(string $filename): bool { + $urlInfo = parse_url($filename); + if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) { + return false; + } + + // Always allowed hosts + if ($urlInfo['host'] === 'nextcloud.com') { + return true; + } + + // Hosts that need further verification + // Github is only allowed if from our organization + $ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com']; + if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) { + return false; + } + + if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) { + return true; + } + + return false; + } + + /** + * Remove orphaned folders from the image cache that do not match the current etag + * @param ISimpleFolder $folder The folder to clear + * @param string $etag The etag (directory name) to keep + */ + private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void { + // Cleanup old cache folders + $allFiles = $folder->getDirectoryListing(); + foreach ($allFiles as $dir) { + try { + if ($dir->getName() !== $etag) { + $dir->delete(); + } + } catch (NotPermittedException $e) { + // ignore folder for now + } + } + } + + private function getAppsWithUpdates() { + $appClass = new \OC_App(); + $apps = $appClass->listAllApps(); + foreach ($apps as $key => $app) { + $newVersion = $this->installer->isUpdateAvailable($app['id']); + if ($newVersion === false) { + unset($apps[$key]); + } + } + return $apps; + } + + private function getBundles() { + $result = []; + $bundles = $this->bundleFetcher->getBundles(); + foreach ($bundles as $bundle) { + $result[] = [ + 'name' => $bundle->getName(), + 'id' => $bundle->getIdentifier(), + 'appIdentifiers' => $bundle->getAppIdentifiers() + ]; + } + return $result; + } + + /** + * Get all available categories + * + * @return JSONResponse + */ + public function listCategories(): JSONResponse { + return new JSONResponse($this->getAllCategories()); + } + + private function getAllCategories() { + $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2); + + $categories = $this->categoryFetcher->get(); + return array_map(fn ($category) => [ + 'id' => $category['id'], + 'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'], + ], $categories); + } + + /** + * Convert URL to proxied URL so CSP is no problem + */ + private function createProxyPreviewUrl(string $url): string { + if ($url === '') { + return ''; + } + return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url); + } + + private function fetchApps() { + $appClass = new \OC_App(); + $apps = $appClass->listAllApps(); + foreach ($apps as $app) { + $app['installed'] = true; + + if (isset($app['screenshot'][0])) { + $appScreenshot = $app['screenshot'][0] ?? null; + if (is_array($appScreenshot)) { + // Screenshot with thumbnail + $appScreenshot = $appScreenshot['@value']; + } + + $app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot); + } + $this->allApps[$app['id']] = $app; + } + + $apps = $this->getAppsForCategory(''); + $supportedApps = $appClass->getSupportedApps(); + foreach ($apps as $app) { + $app['appstore'] = true; + if (!array_key_exists($app['id'], $this->allApps)) { + $this->allApps[$app['id']] = $app; + } else { + $this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]); + } + + if (in_array($app['id'], $supportedApps)) { + $this->allApps[$app['id']]['level'] = \OC_App::supportedApp; + } + } + + // add bundle information + $bundles = $this->bundleFetcher->getBundles(); + foreach ($bundles as $bundle) { + foreach ($bundle->getAppIdentifiers() as $identifier) { + foreach ($this->allApps as &$app) { + if ($app['id'] === $identifier) { + $app['bundleIds'][] = $bundle->getIdentifier(); + continue; + } + } + } + } + } + + private function getAllApps() { + return $this->allApps; + } + + /** + * Get all available apps in a category + * + * @return JSONResponse + * @throws \Exception + */ + public function listApps(): JSONResponse { + $this->fetchApps(); + $apps = $this->getAllApps(); + + $dependencyAnalyzer = new DependencyAnalyzer(new Platform($this->config), $this->l10n); + + $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); + if (!is_array($ignoreMaxApps)) { + $this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...'); + $ignoreMaxApps = []; + } + + // Extend existing app details + $apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) { + if (isset($appData['appstoreData'])) { + $appstoreData = $appData['appstoreData']; + $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? ''); + $appData['category'] = $appstoreData['categories']; + $appData['releases'] = $appstoreData['releases']; + } + + $newVersion = $this->installer->isUpdateAvailable($appData['id']); + if ($newVersion) { + $appData['update'] = $newVersion; + } + + // fix groups to be an array + $groups = []; + if (is_string($appData['groups'])) { + $groups = json_decode($appData['groups']); + // ensure 'groups' is an array + if (!is_array($groups)) { + $groups = [$groups]; + } + } + $appData['groups'] = $groups; + $appData['canUnInstall'] = !$appData['active'] && $appData['removable']; + + // fix licence vs license + if (isset($appData['license']) && !isset($appData['licence'])) { + $appData['licence'] = $appData['license']; + } + + $ignoreMax = in_array($appData['id'], $ignoreMaxApps); + + // analyse dependencies + $missing = $dependencyAnalyzer->analyze($appData, $ignoreMax); + $appData['canInstall'] = empty($missing); + $appData['missingDependencies'] = $missing; + + $appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']); + $appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']); + $appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData); + + return $appData; + }, $apps); + + usort($apps, [$this, 'sortApps']); + + return new JSONResponse(['apps' => $apps, 'status' => 'success']); + } + + /** + * Get all apps for a category from the app store + * + * @param string $requestedCategory + * @return array + * @throws \Exception + */ + private function getAppsForCategory($requestedCategory = ''): array { + $versionParser = new VersionParser(); + $formattedApps = []; + $apps = $this->appFetcher->get(); + foreach ($apps as $app) { + // Skip all apps not in the requested category + if ($requestedCategory !== '') { + $isInCategory = false; + foreach ($app['categories'] as $category) { + if ($category === $requestedCategory) { + $isInCategory = true; + } + } + if (!$isInCategory) { + continue; + } + } + + if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) { + continue; + } + $nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']); + $nextCloudVersionDependencies = []; + if ($nextCloudVersion->getMinimumVersion() !== '') { + $nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion(); + } + if ($nextCloudVersion->getMaximumVersion() !== '') { + $nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion(); + } + $phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']); + + try { + $this->appManager->getAppPath($app['id']); + $existsLocally = true; + } catch (AppPathNotFoundException) { + $existsLocally = false; + } + + $phpDependencies = []; + if ($phpVersion->getMinimumVersion() !== '') { + $phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion(); + } + if ($phpVersion->getMaximumVersion() !== '') { + $phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion(); + } + if (isset($app['releases'][0]['minIntSize'])) { + $phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize']; + } + $authors = ''; + foreach ($app['authors'] as $key => $author) { + $authors .= $author['name']; + if ($key !== count($app['authors']) - 1) { + $authors .= ', '; + } + } + + $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2); + $enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no'); + $groups = null; + if ($enabledValue !== 'no' && $enabledValue !== 'yes') { + $groups = $enabledValue; + } + + $currentVersion = ''; + if ($this->appManager->isEnabledForAnyone($app['id'])) { + $currentVersion = $this->appManager->getAppVersion($app['id']); + } else { + $currentVersion = $app['releases'][0]['version']; + } + + $formattedApps[] = [ + 'id' => $app['id'], + 'app_api' => false, + 'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'], + 'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'], + 'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'], + 'license' => $app['releases'][0]['licenses'], + 'author' => $authors, + 'shipped' => $this->appManager->isShipped($app['id']), + 'version' => $currentVersion, + 'default_enable' => '', + 'types' => [], + 'documentation' => [ + 'admin' => $app['adminDocs'], + 'user' => $app['userDocs'], + 'developer' => $app['developerDocs'] + ], + 'website' => $app['website'], + 'bugs' => $app['issueTracker'], + 'detailpage' => $app['website'], + 'dependencies' => array_merge( + $nextCloudVersionDependencies, + $phpDependencies + ), + 'level' => ($app['isFeatured'] === true) ? 200 : 100, + 'missingMaxOwnCloudVersion' => false, + 'missingMinOwnCloudVersion' => false, + 'canInstall' => true, + 'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '', + 'score' => $app['ratingOverall'], + 'ratingNumOverall' => $app['ratingNumOverall'], + 'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5, + 'removable' => $existsLocally, + 'active' => $this->appManager->isEnabledForUser($app['id']), + 'needsDownload' => !$existsLocally, + 'groups' => $groups, + 'fromAppStore' => true, + 'appstoreData' => $app, + ]; + } + + return $formattedApps; + } + + /** + * @param string $appId + * @param array $groups + * @return JSONResponse + */ + #[PasswordConfirmationRequired] + public function enableApp(string $appId, array $groups = []): JSONResponse { + return $this->enableApps([$appId], $groups); + } + + /** + * Enable one or more apps + * + * apps will be enabled for specific groups only if $groups is defined + * + * @param array $appIds + * @param array $groups + * @return JSONResponse + */ + #[PasswordConfirmationRequired] + public function enableApps(array $appIds, array $groups = []): JSONResponse { + try { + $updateRequired = false; + + foreach ($appIds as $appId) { + $appId = $this->appManager->cleanAppId($appId); + + // Check if app is already downloaded + /** @var Installer $installer */ + $installer = Server::get(Installer::class); + $isDownloaded = $installer->isDownloaded($appId); + + if (!$isDownloaded) { + $installer->downloadApp($appId); + } + + $installer->installApp($appId); + + if (count($groups) > 0) { + $this->appManager->enableAppForGroups($appId, $this->getGroupList($groups)); + } else { + $this->appManager->enableApp($appId); + } + if (\OC_App::shouldUpgrade($appId)) { + $updateRequired = true; + } + } + return new JSONResponse(['data' => ['update_required' => $updateRequired]]); + } catch (\Throwable $e) { + $this->logger->error('could not enable apps', ['exception' => $e]); + return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + private function getGroupList(array $groups) { + $groupManager = Server::get(IGroupManager::class); + $groupsList = []; + foreach ($groups as $group) { + $groupItem = $groupManager->get($group); + if ($groupItem instanceof IGroup) { + $groupsList[] = $groupManager->get($group); + } + } + return $groupsList; + } + + /** + * @param string $appId + * @return JSONResponse + */ + #[PasswordConfirmationRequired] + public function disableApp(string $appId): JSONResponse { + return $this->disableApps([$appId]); + } + + /** + * @param array $appIds + * @return JSONResponse + */ + #[PasswordConfirmationRequired] + public function disableApps(array $appIds): JSONResponse { + try { + foreach ($appIds as $appId) { + $appId = $this->appManager->cleanAppId($appId); + $this->appManager->disableApp($appId); + } + return new JSONResponse([]); + } catch (\Exception $e) { + $this->logger->error('could not disable app', ['exception' => $e]); + return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @param string $appId + * @return JSONResponse + */ + #[PasswordConfirmationRequired] + public function uninstallApp(string $appId): JSONResponse { + $appId = $this->appManager->cleanAppId($appId); + $result = $this->installer->removeApp($appId); + if ($result !== false) { + // If this app was force enabled, remove the force-enabled-state + $this->appManager->removeOverwriteNextcloudRequirement($appId); + $this->appManager->clearAppsCache(); + return new JSONResponse(['data' => ['appid' => $appId]]); + } + return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + /** + * @param string $appId + * @return JSONResponse + */ + public function updateApp(string $appId): JSONResponse { + $appId = $this->appManager->cleanAppId($appId); + + $this->config->setSystemValue('maintenance', true); + try { + $result = $this->installer->updateAppstoreApp($appId); + $this->config->setSystemValue('maintenance', false); + } catch (\Exception $ex) { + $this->config->setSystemValue('maintenance', false); + return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($result !== false) { + return new JSONResponse(['data' => ['appid' => $appId]]); + } + return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + private function sortApps($a, $b) { + $a = (string)$a['name']; + $b = (string)$b['name']; + if ($a === $b) { + return 0; + } + return ($a < $b) ? -1 : 1; + } + + public function force(string $appId): JSONResponse { + $appId = $this->appManager->cleanAppId($appId); + $this->appManager->overwriteNextcloudRequirement($appId); + return new JSONResponse(); + } +} diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php new file mode 100644 index 00000000000..8652a49fb1d --- /dev/null +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -0,0 +1,285 @@ +<?php + +/** + * 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 BadMethodCallException; +use OC\Authentication\Exceptions\InvalidTokenException as OcInvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Token\INamedToken; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\RemoteWipe; +use OCA\Settings\Activity\Provider; +use OCP\Activity\IManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Session\Exceptions\SessionNotAvailableException; +use Psr\Log\LoggerInterface; + +class AuthSettingsController extends Controller { + /** @var IProvider */ + private $tokenProvider; + + /** @var RemoteWipe */ + private $remoteWipe; + + /** + * @param string $appName + * @param IRequest $request + * @param IProvider $tokenProvider + * @param ISession $session + * @param ISecureRandom $random + * @param string|null $userId + * @param IUserSession $userSession + * @param IManager $activityManager + * @param RemoteWipe $remoteWipe + * @param LoggerInterface $logger + */ + public function __construct( + string $appName, + IRequest $request, + IProvider $tokenProvider, + private ISession $session, + private ISecureRandom $random, + private ?string $userId, + private IUserSession $userSession, + private IManager $activityManager, + RemoteWipe $remoteWipe, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + $this->tokenProvider = $tokenProvider; + $this->remoteWipe = $remoteWipe; + } + + /** + * @NoSubAdminRequired + * + * @param string $name + * @return JSONResponse + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function create($name) { + if ($this->checkAppToken()) { + return $this->getServiceNotAvailableResponse(); + } + + try { + $sessionId = $this->session->getId(); + } catch (SessionNotAvailableException $ex) { + return $this->getServiceNotAvailableResponse(); + } + if ($this->userSession->getImpersonatingUserID() !== null) { + return $this->getServiceNotAvailableResponse(); + } + + try { + $sessionToken = $this->tokenProvider->getToken($sessionId); + $loginName = $sessionToken->getLoginName(); + try { + $password = $this->tokenProvider->getPassword($sessionToken, $sessionId); + } catch (PasswordlessTokenException $ex) { + $password = null; + } + } catch (InvalidTokenException $ex) { + return $this->getServiceNotAvailableResponse(); + } + + if (mb_strlen($name) > 128) { + $name = mb_substr($name, 0, 120) . '…'; + } + + $token = $this->generateRandomDeviceToken(); + $deviceToken = $this->tokenProvider->generateToken($token, $this->userId, $loginName, $password, $name, IToken::PERMANENT_TOKEN); + $tokenData = $deviceToken->jsonSerialize(); + $tokenData['canDelete'] = true; + $tokenData['canRename'] = true; + + $this->publishActivity(Provider::APP_TOKEN_CREATED, $deviceToken->getId(), ['name' => $deviceToken->getName()]); + + return new JSONResponse([ + 'token' => $token, + 'loginName' => $loginName, + 'deviceToken' => $tokenData, + ]); + } + + /** + * @return JSONResponse + */ + private function getServiceNotAvailableResponse() { + $resp = new JSONResponse(); + $resp->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + return $resp; + } + + /** + * Return a 25 digit device password + * + * Example: AbCdE-fGhJk-MnPqR-sTwXy-23456 + * + * @return string + */ + private function generateRandomDeviceToken() { + $groups = []; + for ($i = 0; $i < 5; $i++) { + $groups[] = $this->random->generate(5, ISecureRandom::CHAR_HUMAN_READABLE); + } + return implode('-', $groups); + } + + private function checkAppToken(): bool { + return $this->session->exists('app_password'); + } + + /** + * @NoSubAdminRequired + * + * @param int $id + * @return array|JSONResponse + */ + #[NoAdminRequired] + public function destroy($id) { + if ($this->checkAppToken()) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + try { + $token = $this->findTokenByIdAndUser($id); + } catch (WipeTokenException $e) { + //continue as we can destroy tokens in wipe + $token = $e->getToken(); + } catch (InvalidTokenException $e) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $this->tokenProvider->invalidateTokenById($this->userId, $token->getId()); + $this->publishActivity(Provider::APP_TOKEN_DELETED, $token->getId(), ['name' => $token->getName()]); + return []; + } + + /** + * @NoSubAdminRequired + * + * @param int $id + * @param array $scope + * @param string $name + * @return array|JSONResponse + */ + #[NoAdminRequired] + public function update($id, array $scope, string $name) { + if ($this->checkAppToken()) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + try { + $token = $this->findTokenByIdAndUser($id); + } catch (InvalidTokenException $e) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $currentName = $token->getName(); + + if ($scope !== $token->getScopeAsArray()) { + $token->setScope([IToken::SCOPE_FILESYSTEM => $scope[IToken::SCOPE_FILESYSTEM]]); + $this->publishActivity($scope[IToken::SCOPE_FILESYSTEM] ? Provider::APP_TOKEN_FILESYSTEM_GRANTED : Provider::APP_TOKEN_FILESYSTEM_REVOKED, $token->getId(), ['name' => $currentName]); + } + + if (mb_strlen($name) > 128) { + $name = mb_substr($name, 0, 120) . '…'; + } + + if ($token instanceof INamedToken && $name !== $currentName) { + $token->setName($name); + $this->publishActivity(Provider::APP_TOKEN_RENAMED, $token->getId(), ['name' => $currentName, 'newName' => $name]); + } + + $this->tokenProvider->updateToken($token); + return []; + } + + /** + * @param string $subject + * @param int $id + * @param array $parameters + */ + private function publishActivity(string $subject, int $id, array $parameters = []): void { + $event = $this->activityManager->generateEvent(); + $event->setApp('settings') + ->setType('security') + ->setAffectedUser($this->userId) + ->setAuthor($this->userId) + ->setSubject($subject, $parameters) + ->setObject('app_token', $id, 'App Password'); + + try { + $this->activityManager->publish($event); + } catch (BadMethodCallException $e) { + $this->logger->warning('could not publish activity', ['exception' => $e]); + } + } + + /** + * Find a token by given id and check if uid for current session belongs to this token + * + * @param int $id + * @return IToken + * @throws InvalidTokenException + */ + private function findTokenByIdAndUser(int $id): IToken { + try { + $token = $this->tokenProvider->getTokenById($id); + } catch (ExpiredTokenException $e) { + $token = $e->getToken(); + } + if ($token->getUID() !== $this->userId) { + /** @psalm-suppress DeprecatedClass We have to throw the OC version so both OC and OCP catches catch it */ + throw new OcInvalidTokenException('This token does not belong to you!'); + } + return $token; + } + + /** + * @NoSubAdminRequired + * + * @param int $id + * @return JSONResponse + * @throws InvalidTokenException + * @throws ExpiredTokenException + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function wipe(int $id): JSONResponse { + if ($this->checkAppToken()) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + try { + $token = $this->findTokenByIdAndUser($id); + } catch (InvalidTokenException $e) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + if (!$this->remoteWipe->markTokenForWipe($token)) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse([]); + } +} diff --git a/apps/settings/lib/Controller/AuthorizedGroupController.php b/apps/settings/lib/Controller/AuthorizedGroupController.php new file mode 100644 index 00000000000..82a1ca4703e --- /dev/null +++ b/apps/settings/lib/Controller/AuthorizedGroupController.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OC\Settings\AuthorizedGroup; +use OCA\Settings\Service\AuthorizedGroupService; +use OCA\Settings\Service\NotFoundException; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\DB\Exception; +use OCP\IRequest; + +class AuthorizedGroupController extends Controller { + public function __construct( + string $AppName, + IRequest $request, + private AuthorizedGroupService $authorizedGroupService, + ) { + parent::__construct($AppName, $request); + } + + /** + * @throws NotFoundException + * @throws Exception + */ + public function saveSettings(array $newGroups, string $class): DataResponse { + $currentGroups = $this->authorizedGroupService->findExistingGroupsForClass($class); + + foreach ($currentGroups as $group) { + /** @var AuthorizedGroup $group */ + $removed = true; + foreach ($newGroups as $groupData) { + if ($groupData['gid'] === $group->getGroupId()) { + $removed = false; + break; + } + } + if ($removed) { + $this->authorizedGroupService->delete($group->getId()); + } + } + + foreach ($newGroups as $groupData) { + $added = true; + foreach ($currentGroups as $group) { + /** @var AuthorizedGroup $group */ + if ($groupData['gid'] === $group->getGroupId()) { + $added = false; + break; + } + } + if ($added) { + $this->authorizedGroupService->create($groupData['gid'], $class); + } + } + + return new DataResponse(['valid' => true]); + } +} diff --git a/apps/settings/lib/Controller/ChangePasswordController.php b/apps/settings/lib/Controller/ChangePasswordController.php new file mode 100644 index 00000000000..a874a47c16a --- /dev/null +++ b/apps/settings/lib/Controller/ChangePasswordController.php @@ -0,0 +1,224 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// FIXME: disabled for now to be able to inject IGroupManager and also use +// getSubAdmin() +//declare(strict_types=1); + +namespace OCA\Settings\Controller; + +use OC\Group\Manager as GroupManager; +use OC\User\Session; +use OCA\Encryption\KeyManager; +use OCA\Encryption\Recovery; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\HintException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Server; + +class ChangePasswordController extends Controller { + private Session $userSession; + + public function __construct( + string $appName, + IRequest $request, + private ?string $userId, + private IUserManager $userManager, + IUserSession $userSession, + private GroupManager $groupManager, + private IAppManager $appManager, + private IL10N $l, + ) { + parent::__construct($appName, $request); + $this->userSession = $userSession; + } + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[BruteForceProtection(action: 'changePersonalPassword')] + public function changePersonalPassword(string $oldpassword = '', ?string $newpassword = null): JSONResponse { + $loginName = $this->userSession->getLoginName(); + /** @var IUser $user */ + $user = $this->userManager->checkPassword($loginName, $oldpassword); + if ($user === false) { + $response = new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Wrong password'), + ], + ]); + $response->throttle(); + return $response; + } + + try { + if ($newpassword === null || strlen($newpassword) > IUserManager::MAX_PASSWORD_LENGTH || $user->setPassword($newpassword) === false) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Unable to change personal password'), + ], + ]); + } + // password policy app throws exception + } catch (HintException $e) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $e->getHint(), + ], + ]); + } + + $this->userSession->updateSessionTokenPassword($newpassword); + + return new JSONResponse([ + 'status' => 'success', + 'data' => [ + 'message' => $this->l->t('Saved'), + ], + ]); + } + + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function changeUserPassword(?string $username = null, ?string $password = null, ?string $recoveryPassword = null): JSONResponse { + if ($username === null) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('No Login supplied'), + ], + ]); + } + + if ($password === null) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Unable to change password'), + ], + ]); + } + + if (strlen($password) > IUserManager::MAX_PASSWORD_LENGTH) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Unable to change password. Password too long.'), + ], + ]); + } + + $currentUser = $this->userSession->getUser(); + $targetUser = $this->userManager->get($username); + if ($currentUser === null || $targetUser === null + || !($this->groupManager->isAdmin($this->userId) + || $this->groupManager->getSubAdmin()->isUserAccessible($currentUser, $targetUser)) + ) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Authentication error'), + ], + ]); + } + + if ($this->appManager->isEnabledForUser('encryption')) { + //handle the recovery case + $keyManager = Server::get(KeyManager::class); + $recovery = Server::get(Recovery::class); + $recoveryAdminEnabled = $recovery->isRecoveryKeyEnabled(); + + $validRecoveryPassword = false; + $recoveryEnabledForUser = false; + if ($recoveryAdminEnabled) { + $validRecoveryPassword = $keyManager->checkRecoveryPassword($recoveryPassword); + $recoveryEnabledForUser = $recovery->isRecoveryEnabledForUser($username); + } + + if ($recoveryEnabledForUser && $recoveryPassword === '') { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Please provide an admin recovery password; otherwise, all account data will be lost.'), + ] + ]); + } elseif ($recoveryEnabledForUser && ! $validRecoveryPassword) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Wrong admin recovery password. Please check the password and try again.'), + ] + ]); + } else { // now we know that everything is fine regarding the recovery password, let's try to change the password + try { + $result = $targetUser->setPassword($password, $recoveryPassword); + // password policy app throws exception + } catch (HintException $e) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $e->getHint(), + ], + ]); + } + if (!$result && $recoveryEnabledForUser) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Backend does not support password change, but the encryption of the account key was updated.'), + ] + ]); + } elseif (!$result && !$recoveryEnabledForUser) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Unable to change password'), + ] + ]); + } + } + } else { + try { + if ($targetUser->setPassword($password) === false) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $this->l->t('Unable to change password'), + ], + ]); + } + // password policy app throws exception + } catch (HintException $e) { + return new JSONResponse([ + 'status' => 'error', + 'data' => [ + 'message' => $e->getHint(), + ], + ]); + } + } + + return new JSONResponse([ + 'status' => 'success', + 'data' => [ + 'username' => $username, + ], + ]); + } +} diff --git a/apps/settings/lib/Controller/CheckSetupController.php b/apps/settings/lib/Controller/CheckSetupController.php new file mode 100644 index 00000000000..2a189a37ce6 --- /dev/null +++ b/apps/settings/lib/Controller/CheckSetupController.php @@ -0,0 +1,138 @@ +<?php + +/** + * 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 OC\AppFramework\Http; +use OC\IntegrityCheck\Checker; +use OCA\Settings\Settings\Admin\Overview; +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\DataDisplayResponse; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\SetupCheck\ISetupCheckManager; +use Psr\Log\LoggerInterface; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class CheckSetupController extends Controller { + /** @var Checker */ + private $checker; + + public function __construct( + $AppName, + IRequest $request, + private IConfig $config, + private IURLGenerator $urlGenerator, + private IL10N $l10n, + Checker $checker, + private LoggerInterface $logger, + private ISetupCheckManager $setupCheckManager, + ) { + parent::__construct($AppName, $request); + $this->checker = $checker; + } + + /** + * @return DataResponse + */ + #[NoCSRFRequired] + #[NoAdminRequired] + public function setupCheckManager(): DataResponse { + return new DataResponse($this->setupCheckManager->runAll()); + } + + /** + * @return RedirectResponse + */ + #[NoCSRFRequired] + #[AuthorizedAdminSetting(settings: Overview::class)] + public function rescanFailedIntegrityCheck(): RedirectResponse { + $this->checker->runInstanceVerification(); + return new RedirectResponse( + $this->urlGenerator->linkToRoute('settings.AdminSettings.index', ['section' => 'overview']) + ); + } + + #[NoCSRFRequired] + #[AuthorizedAdminSetting(settings: Overview::class)] + public function getFailedIntegrityCheckFiles(): DataDisplayResponse { + if (!$this->checker->isCodeCheckEnforced()) { + return new DataDisplayResponse('Integrity checker has been disabled. Integrity cannot be verified.'); + } + + $completeResults = $this->checker->getResults(); + + if ($completeResults === null) { + return new DataDisplayResponse('Integrity checker has not been run. Integrity information not available.'); + } + + if (!empty($completeResults)) { + $formattedTextResponse = 'Technical information +===================== +The following list covers which files have failed the integrity check. Please read +the previous linked documentation to learn more about the errors and how to fix +them. + +Results +======= +'; + foreach ($completeResults as $context => $contextResult) { + $formattedTextResponse .= "- $context\n"; + + foreach ($contextResult as $category => $result) { + $formattedTextResponse .= "\t- $category\n"; + if ($category !== 'EXCEPTION') { + foreach ($result as $key => $results) { + $formattedTextResponse .= "\t\t- $key\n"; + } + } else { + foreach ($result as $key => $results) { + $formattedTextResponse .= "\t\t- $results\n"; + } + } + } + } + + $formattedTextResponse .= ' +Raw output +========== +'; + $formattedTextResponse .= print_r($completeResults, true); + } else { + $formattedTextResponse = 'No errors have been found.'; + } + + + return new DataDisplayResponse( + $formattedTextResponse, + Http::STATUS_OK, + [ + 'Content-Type' => 'text/plain', + ] + ); + } + + /** + * @return DataResponse + */ + #[AuthorizedAdminSetting(settings: Overview::class)] + public function check() { + return new DataResponse( + [ + 'generic' => $this->setupCheckManager->runAll(), + ] + ); + } +} diff --git a/apps/settings/lib/Controller/CommonSettingsTrait.php b/apps/settings/lib/Controller/CommonSettingsTrait.php new file mode 100644 index 00000000000..75d2b1f2f9e --- /dev/null +++ b/apps/settings/lib/Controller/CommonSettingsTrait.php @@ -0,0 +1,191 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Controller; + +use InvalidArgumentException; +use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; +use OCA\Settings\AppInfo\Application; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Group\ISubAdmin; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IUserSession; +use OCP\Settings\IDeclarativeManager; +use OCP\Settings\IDeclarativeSettingsForm; +use OCP\Settings\IIconSection; +use OCP\Settings\IManager as ISettingsManager; +use OCP\Settings\ISettings; +use OCP\Util; + +/** + * @psalm-import-type DeclarativeSettingsFormSchemaWithValues from IDeclarativeSettingsForm + * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm + */ +trait CommonSettingsTrait { + + /** @var ISettingsManager */ + private $settingsManager; + + /** @var INavigationManager */ + private $navigationManager; + + /** @var IUserSession */ + private $userSession; + + /** @var IGroupManager */ + private $groupManager; + + /** @var ISubAdmin */ + private $subAdmin; + + private IDeclarativeManager $declarativeSettingsManager; + + /** @var IInitialState */ + private $initialState; + + /** + * @return array{forms: array{personal: array, admin: array}} + */ + private function getNavigationParameters(string $currentType, string $currentSection): array { + return [ + 'forms' => [ + 'personal' => $this->formatPersonalSections($currentType, $currentSection), + 'admin' => $this->formatAdminSections($currentType, $currentSection), + ], + ]; + } + + /** + * @param IIconSection[][] $sections + * @psalm-param 'admin'|'personal' $type + * @return list<array{anchor: string, section-name: string, active: bool, icon: string}> + */ + protected function formatSections(array $sections, string $currentSection, string $type, string $currentType): array { + $templateParameters = []; + foreach ($sections as $prioritizedSections) { + foreach ($prioritizedSections as $section) { + if ($type === 'admin') { + $settings = $this->settingsManager->getAllowedAdminSettings($section->getID(), $this->userSession->getUser()); + } elseif ($type === 'personal') { + $settings = $this->settingsManager->getPersonalSettings($section->getID()); + } + + /** @psalm-suppress PossiblyNullArgument */ + $declarativeFormIDs = $this->declarativeSettingsManager->getFormIDs($this->userSession->getUser(), $type, $section->getID()); + + if (empty($settings) && empty($declarativeFormIDs)) { + continue; + } + + $icon = $section->getIcon(); + + $active = $section->getID() === $currentSection + && $type === $currentType; + + $templateParameters[] = [ + 'anchor' => $section->getID(), + 'section-name' => $section->getName(), + 'active' => $active, + 'icon' => $icon, + ]; + } + } + return $templateParameters; + } + + protected function formatPersonalSections(string $currentType, string $currentSection): array { + $sections = $this->settingsManager->getPersonalSections(); + return $this->formatSections($sections, $currentSection, 'personal', $currentType); + } + + protected function formatAdminSections(string $currentType, string $currentSection): array { + $sections = $this->settingsManager->getAdminSections(); + return $this->formatSections($sections, $currentSection, 'admin', $currentType); + } + + /** + * @param list<ISettings> $settings + * @param list<DeclarativeSettingsFormSchemaWithValues> $declarativeSettings + * @return array{content: string} + */ + private function formatSettings(array $settings, array $declarativeSettings): array { + $settings = array_merge($settings, $declarativeSettings); + + usort($settings, function ($first, $second) { + $priorityOne = $first instanceof ISettings ? $first->getPriority() : $first['priority']; + $priorityTwo = $second instanceof ISettings ? $second->getPriority() : $second['priority']; + return $priorityOne - $priorityTwo; + }); + + $html = ''; + foreach ($settings as $setting) { + if ($setting instanceof ISettings) { + $form = $setting->getForm(); + $html .= $form->renderAs('')->render(); + } else { + $html .= '<div id="' . $setting['app'] . '_' . $setting['id'] . '"></div>'; + } + } + return ['content' => $html]; + } + + /** + * @psalm-param 'admin'|'personal' $type + */ + private function getIndexResponse(string $type, string $section): TemplateResponse { + $user = $this->userSession->getUser(); + assert($user !== null, 'No user logged in for settings'); + + $this->declarativeSettingsManager->loadSchemas(); + $declarativeSettings = $this->declarativeSettingsManager->getFormsWithValues($user, $type, $section); + + foreach ($declarativeSettings as &$form) { + foreach ($form['fields'] as &$field) { + if (isset($field['sensitive']) && $field['sensitive'] === true && !empty($field['value'])) { + $field['value'] = 'dummySecret'; + } + } + } + + if ($type === 'personal') { + $settings = array_values($this->settingsManager->getPersonalSettings($section)); + if ($section === 'theming') { + $this->navigationManager->setActiveEntry('accessibility_settings'); + } else { + $this->navigationManager->setActiveEntry('settings'); + } + } elseif ($type === 'admin') { + $settings = array_values($this->settingsManager->getAllowedAdminSettings($section, $user)); + if (empty($settings) && empty($declarativeSettings)) { + throw new NotAdminException('Logged in user does not have permission to access these settings.'); + } + $this->navigationManager->setActiveEntry('admin_settings'); + } else { + throw new InvalidArgumentException('$type must be either "admin" or "personal"'); + } + + if (!empty($declarativeSettings)) { + Util::addScript(Application::APP_ID, 'declarative-settings-forms'); + $this->initialState->provideInitialState('declarative-settings-forms', $declarativeSettings); + } + + $settings = array_merge(...$settings); + $templateParams = $this->formatSettings($settings, $declarativeSettings); + $templateParams = array_merge($templateParams, $this->getNavigationParameters($type, $section)); + + $activeSection = $this->settingsManager->getSection($type, $section); + if ($activeSection) { + $templateParams['pageTitle'] = $activeSection->getName(); + $templateParams['activeSectionId'] = $activeSection->getID(); + $templateParams['activeSectionType'] = $type; + } + + return new TemplateResponse('settings', 'settings/frame', $templateParams); + } +} diff --git a/apps/settings/lib/Controller/DeclarativeSettingsController.php b/apps/settings/lib/Controller/DeclarativeSettingsController.php new file mode 100644 index 00000000000..4e4bee4043c --- /dev/null +++ b/apps/settings/lib/Controller/DeclarativeSettingsController.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Settings\Controller; + +use Exception; +use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; +use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OCA\Settings\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Settings\IDeclarativeManager; +use Psr\Log\LoggerInterface; + +/** + * @psalm-import-type SettingsDeclarativeForm from ResponseDefinitions + */ +class DeclarativeSettingsController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IUserSession $userSession, + private IDeclarativeManager $declarativeManager, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * Sets a declarative settings value + * + * @param string $app ID of the app + * @param string $formId ID of the form + * @param string $fieldId ID of the field + * @param mixed $value Value to be saved + * @return DataResponse<Http::STATUS_OK, null, array{}> + * @throws NotLoggedInException Not logged in or not an admin user + * @throws NotAdminException Not logged in or not an admin user + * @throws OCSBadRequestException Invalid arguments to save value + * + * 200: Value set successfully + */ + #[NoAdminRequired] + public function setValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse { + return $this->saveValue($app, $formId, $fieldId, $value); + } + + /** + * Sets a declarative settings value. + * Password confirmation is required for sensitive values. + * + * @param string $app ID of the app + * @param string $formId ID of the form + * @param string $fieldId ID of the field + * @param mixed $value Value to be saved + * @return DataResponse<Http::STATUS_OK, null, array{}> + * @throws NotLoggedInException Not logged in or not an admin user + * @throws NotAdminException Not logged in or not an admin user + * @throws OCSBadRequestException Invalid arguments to save value + * + * 200: Value set successfully + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function setSensitiveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse { + return $this->saveValue($app, $formId, $fieldId, $value); + } + + /** + * Sets a declarative settings value. + * + * @param string $app ID of the app + * @param string $formId ID of the form + * @param string $fieldId ID of the field + * @param mixed $value Value to be saved + * @return DataResponse<Http::STATUS_OK, null, array{}> + * @throws NotLoggedInException Not logged in or not an admin user + * @throws NotAdminException Not logged in or not an admin user + * @throws OCSBadRequestException Invalid arguments to save value + * + * 200: Value set successfully + */ + private function saveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new NotLoggedInException(); + } + + try { + $this->declarativeManager->loadSchemas(); + $this->declarativeManager->setValue($user, $app, $formId, $fieldId, $value); + return new DataResponse(null); + } catch (NotAdminException $e) { + throw $e; + } catch (Exception $e) { + $this->logger->error('Failed to set declarative settings value: ' . $e->getMessage()); + throw new OCSBadRequestException(); + } + } + + /** + * Gets all declarative forms with the values prefilled. + * + * @return DataResponse<Http::STATUS_OK, list<SettingsDeclarativeForm>, array{}> + * @throws NotLoggedInException + * @NoSubAdminRequired + * + * 200: Forms returned + */ + #[NoAdminRequired] + public function getForms(): DataResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new NotLoggedInException(); + } + $this->declarativeManager->loadSchemas(); + return new DataResponse($this->declarativeManager->getFormsWithValues($user, null, null)); + } +} diff --git a/apps/settings/lib/Controller/HelpController.php b/apps/settings/lib/Controller/HelpController.php new file mode 100644 index 00000000000..05bff158ee6 --- /dev/null +++ b/apps/settings/lib/Controller/HelpController.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IURLGenerator; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class HelpController extends Controller { + + public function __construct( + string $appName, + IRequest $request, + private INavigationManager $navigationManager, + private IURLGenerator $urlGenerator, + /** @var string */ + private ?string $userId, + private IGroupManager $groupManager, + private IL10N $l10n, + private IConfig $config, + private IAppConfig $appConfig, + ) { + parent::__construct($appName, $request); + } + + /** + * @return TemplateResponse + * + * @NoSubAdminRequired + */ + #[NoCSRFRequired] + #[NoAdminRequired] + public function help(string $mode = 'user'): TemplateResponse { + $this->navigationManager->setActiveEntry('help'); + $pageTitle = $this->l10n->t('Administrator documentation'); + if ($mode !== 'admin') { + $pageTitle = $this->l10n->t('User documentation'); + $mode = 'user'; + } + + $documentationUrl = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkTo('', 'core/doc/' . $mode . '/index.html') + ); + + $urlUserDocs = $this->urlGenerator->linkToRoute('settings.Help.help', ['mode' => 'user']); + $urlAdminDocs = $this->urlGenerator->linkToRoute('settings.Help.help', ['mode' => 'admin']); + + $knowledgebaseEmbedded = $this->config->getSystemValueBool('knowledgebase.embedded', false); + if (!$knowledgebaseEmbedded) { + $pageTitle = $this->l10n->t('Nextcloud help overview'); + $urlUserDocs = $this->urlGenerator->linkToDocs('user'); + $urlAdminDocs = $this->urlGenerator->linkToDocs('admin'); + } + + $legalNoticeUrl = $this->appConfig->getValueString('theming', 'imprintUrl'); + $privacyUrl = $this->appConfig->getValueString('theming', 'privacyUrl'); + + $response = new TemplateResponse('settings', 'help', [ + 'admin' => $this->groupManager->isAdmin($this->userId), + 'url' => $documentationUrl, + 'urlUserDocs' => $urlUserDocs, + 'urlAdminDocs' => $urlAdminDocs, + 'mode' => $mode, + 'pageTitle' => $pageTitle, + 'knowledgebaseEmbedded' => $knowledgebaseEmbedded, + 'legalNoticeUrl' => $legalNoticeUrl, + 'privacyUrl' => $privacyUrl, + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedFrameDomain('\'self\''); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/apps/settings/lib/Controller/LogSettingsController.php b/apps/settings/lib/Controller/LogSettingsController.php new file mode 100644 index 00000000000..90cf4549d2f --- /dev/null +++ b/apps/settings/lib/Controller/LogSettingsController.php @@ -0,0 +1,50 @@ +<?php + +/** + * 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 OC\Log; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\StreamResponse; +use OCP\IRequest; + +class LogSettingsController extends Controller { + + /** @var Log */ + private $log; + + public function __construct(string $appName, IRequest $request, Log $logger) { + parent::__construct($appName, $request); + $this->log = $logger; + } + + /** + * download logfile + * + * @return StreamResponse<Http::STATUS_OK, array{Content-Type: 'application/octet-stream', 'Content-Disposition': 'attachment; filename="nextcloud.log"'}> + * + * 200: Logfile returned + */ + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)] + public function download() { + if (!$this->log instanceof Log) { + throw new \UnexpectedValueException('Log file not available'); + } + return new StreamResponse( + $this->log->getLogPath(), + Http::STATUS_OK, + [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="nextcloud.log"', + ], + ); + } +} diff --git a/apps/settings/lib/Controller/MailSettingsController.php b/apps/settings/lib/Controller/MailSettingsController.php new file mode 100644 index 00000000000..f1e3b8032dc --- /dev/null +++ b/apps/settings/lib/Controller/MailSettingsController.php @@ -0,0 +1,155 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Settings\Controller; + +use OCA\Settings\Settings\Admin\Overview; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Mail\IMailer; + +class MailSettingsController extends Controller { + + /** + * @param string $appName + * @param IRequest $request + * @param IL10N $l10n + * @param IConfig $config + * @param IUserSession $userSession + * @param IURLGenerator $urlGenerator, + * @param IMailer $mailer + */ + public function __construct( + $appName, + IRequest $request, + private IL10N $l10n, + private IConfig $config, + private IUserSession $userSession, + private IURLGenerator $urlGenerator, + private IMailer $mailer, + ) { + parent::__construct($appName, $request); + } + + /** + * Sets the email settings + */ + #[AuthorizedAdminSetting(settings: Overview::class)] + #[PasswordConfirmationRequired] + public function setMailSettings( + string $mail_domain, + string $mail_from_address, + string $mail_smtpmode, + string $mail_smtpsecure, + string $mail_smtphost, + ?string $mail_smtpauth, + string $mail_smtpport, + string $mail_sendmailmode, + ): DataResponse { + $mail_smtpauth = $mail_smtpauth == '1'; + + $configs = [ + 'mail_domain' => $mail_domain, + 'mail_from_address' => $mail_from_address, + 'mail_smtpmode' => $mail_smtpmode, + 'mail_smtpsecure' => $mail_smtpsecure, + 'mail_smtphost' => $mail_smtphost, + 'mail_smtpauth' => $mail_smtpauth, + 'mail_smtpport' => $mail_smtpport, + 'mail_sendmailmode' => $mail_sendmailmode, + ]; + foreach ($configs as $key => $value) { + $configs[$key] = empty($value) ? null : $value; + } + + // Delete passwords from config in case no auth is specified + if (!$mail_smtpauth) { + $configs['mail_smtpname'] = null; + $configs['mail_smtppassword'] = null; + } + + $this->config->setSystemValues($configs); + + $this->config->setAppValue('core', 'emailTestSuccessful', '0'); + + return new DataResponse(); + } + + /** + * Store the credentials used for SMTP in the config + * + * @param string $mail_smtpname + * @param string $mail_smtppassword + * @return DataResponse + */ + #[AuthorizedAdminSetting(settings: Overview::class)] + #[PasswordConfirmationRequired] + public function storeCredentials($mail_smtpname, $mail_smtppassword) { + if ($mail_smtppassword === '********') { + return new DataResponse($this->l10n->t('Invalid SMTP password.'), Http::STATUS_BAD_REQUEST); + } + + $this->config->setSystemValues([ + 'mail_smtpname' => $mail_smtpname, + 'mail_smtppassword' => $mail_smtppassword, + ]); + + $this->config->setAppValue('core', 'emailTestSuccessful', '0'); + + return new DataResponse(); + } + + /** + * Send a mail to test the settings + * @return DataResponse + */ + #[AuthorizedAdminSetting(settings: Overview::class)] + public function sendTestMail() { + $email = $this->config->getUserValue($this->userSession->getUser()->getUID(), $this->appName, 'email', ''); + if (!empty($email)) { + try { + $displayName = $this->userSession->getUser()->getDisplayName(); + + $template = $this->mailer->createEMailTemplate('settings.TestEmail', [ + 'displayname' => $displayName, + ]); + + $template->setSubject($this->l10n->t('Email setting test')); + $template->addHeader(); + $template->addHeading($this->l10n->t('Well done, %s!', [$displayName])); + $template->addBodyText($this->l10n->t('If you received this email, the email configuration seems to be correct.')); + $template->addFooter(); + + $message = $this->mailer->createMessage(); + $message->setTo([$email => $displayName]); + $message->useTemplate($template); + $errors = $this->mailer->send($message); + if (!empty($errors)) { + $this->config->setAppValue('core', 'emailTestSuccessful', '0'); + throw new \RuntimeException($this->l10n->t('Email could not be sent. Check your mail server log')); + } + // Store the successful config in the app config + $this->config->setAppValue('core', 'emailTestSuccessful', '1'); + return new DataResponse(); + } catch (\Exception $e) { + $this->config->setAppValue('core', 'emailTestSuccessful', '0'); + return new DataResponse($this->l10n->t('A problem occurred while sending the email. Please revise your settings. (Error: %s)', [$e->getMessage()]), Http::STATUS_BAD_REQUEST); + } + } + + $this->config->setAppValue('core', 'emailTestSuccessful', '0'); + return new DataResponse($this->l10n->t('You need to set your account email before being able to send test emails. Go to %s for that.', [$this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index')]), Http::STATUS_BAD_REQUEST); + } +} diff --git a/apps/settings/lib/Controller/PersonalSettingsController.php b/apps/settings/lib/Controller/PersonalSettingsController.php new file mode 100644 index 00000000000..340ca3f93eb --- /dev/null +++ b/apps/settings/lib/Controller/PersonalSettingsController.php @@ -0,0 +1,59 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\Group\ISubAdmin; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Settings\IDeclarativeManager; +use OCP\Settings\IManager as ISettingsManager; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class PersonalSettingsController extends Controller { + use CommonSettingsTrait; + + public function __construct( + $appName, + IRequest $request, + INavigationManager $navigationManager, + ISettingsManager $settingsManager, + IUserSession $userSession, + IGroupManager $groupManager, + ISubAdmin $subAdmin, + IDeclarativeManager $declarativeSettingsManager, + IInitialState $initialState, + ) { + parent::__construct($appName, $request); + $this->navigationManager = $navigationManager; + $this->settingsManager = $settingsManager; + $this->userSession = $userSession; + $this->subAdmin = $subAdmin; + $this->groupManager = $groupManager; + $this->declarativeSettingsManager = $declarativeSettingsManager; + $this->initialState = $initialState; + } + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(string $section): TemplateResponse { + return $this->getIndexResponse( + 'personal', + $section, + ); + } +} diff --git a/apps/settings/lib/Controller/ReasonsController.php b/apps/settings/lib/Controller/ReasonsController.php new file mode 100644 index 00000000000..91d0a8640d1 --- /dev/null +++ b/apps/settings/lib/Controller/ReasonsController.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\DataDisplayResponse; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class ReasonsController extends Controller { + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function getPdf() { + $data = file_get_contents(__DIR__ . '/../../data/Reasons to use Nextcloud.pdf'); + + $resp = new DataDisplayResponse($data); + $resp->addHeader('Content-Type', 'application/pdf'); + + return $resp; + } +} diff --git a/apps/settings/lib/Controller/TwoFactorSettingsController.php b/apps/settings/lib/Controller/TwoFactorSettingsController.php new file mode 100644 index 00000000000..e08fca8ec6c --- /dev/null +++ b/apps/settings/lib/Controller/TwoFactorSettingsController.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OC\Authentication\TwoFactorAuth\EnforcementState; +use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +class TwoFactorSettingsController extends Controller { + + /** @var MandatoryTwoFactor */ + private $mandatoryTwoFactor; + + public function __construct(string $appName, + IRequest $request, + MandatoryTwoFactor $mandatoryTwoFactor) { + parent::__construct($appName, $request); + + $this->mandatoryTwoFactor = $mandatoryTwoFactor; + } + + public function index(): JSONResponse { + return new JSONResponse($this->mandatoryTwoFactor->getState()); + } + + public function update(bool $enforced, array $enforcedGroups = [], array $excludedGroups = []): JSONResponse { + $this->mandatoryTwoFactor->setState( + new EnforcementState($enforced, $enforcedGroups, $excludedGroups) + ); + + return new JSONResponse($this->mandatoryTwoFactor->getState()); + } +} 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); + } +} diff --git a/apps/settings/lib/Controller/WebAuthnController.php b/apps/settings/lib/Controller/WebAuthnController.php new file mode 100644 index 00000000000..495b58e6a4b --- /dev/null +++ b/apps/settings/lib/Controller/WebAuthnController.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Settings\Controller; + +use OC\Authentication\WebAuthn\Manager; +use OCA\Settings\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +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\UseSession; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Webauthn\PublicKeyCredentialCreationOptions; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class WebAuthnController extends Controller { + private const WEBAUTHN_REGISTRATION = 'webauthn_registration'; + + public function __construct( + IRequest $request, + private LoggerInterface $logger, + private Manager $manager, + private IUserSession $userSession, + private ISession $session, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + #[UseSession] + #[NoCSRFRequired] + public function startRegistration(): JSONResponse { + $this->logger->debug('Starting WebAuthn registration'); + + $credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost()); + + // Set this in the session since we need it on finish + $this->session->set(self::WEBAUTHN_REGISTRATION, $credentialOptions); + + return new JSONResponse($credentialOptions); + } + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + #[UseSession] + public function finishRegistration(string $name, string $data): JSONResponse { + $this->logger->debug('Finishing WebAuthn registration'); + + if (!$this->session->exists(self::WEBAUTHN_REGISTRATION)) { + $this->logger->debug('Trying to finish WebAuthn registration without session data'); + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + // Obtain the publicKeyCredentialOptions from when we started the registration + $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($this->session->get(self::WEBAUTHN_REGISTRATION)); + + $this->session->remove(self::WEBAUTHN_REGISTRATION); + + return new JSONResponse($this->manager->finishRegister($publicKeyCredentialCreationOptions, $name, $data)); + } + + /** + * @NoSubAdminRequired + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function deleteRegistration(int $id): JSONResponse { + $this->logger->debug('Finishing WebAuthn registration'); + + $this->manager->deleteRegistration($this->userSession->getUser(), $id); + + return new JSONResponse([]); + } +} |