diff options
Diffstat (limited to 'apps/federation/lib')
19 files changed, 1539 insertions, 763 deletions
diff --git a/apps/federation/lib/AppInfo/Application.php b/apps/federation/lib/AppInfo/Application.php new file mode 100644 index 00000000000..358e3f68d50 --- /dev/null +++ b/apps/federation/lib/AppInfo/Application.php @@ -0,0 +1,32 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\AppInfo; + +use OCA\DAV\Events\SabrePluginAuthInitEvent; +use OCA\Federation\Listener\SabrePluginAuthInitListener; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +class Application extends App implements IBootstrap { + + /** + * @param array $urlParams + */ + public function __construct($urlParams = []) { + parent::__construct('federation', $urlParams); + } + + public function register(IRegistrationContext $context): void { + $context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/apps/federation/lib/BackgroundJob/GetSharedSecret.php b/apps/federation/lib/BackgroundJob/GetSharedSecret.php new file mode 100644 index 00000000000..dc57db9fd62 --- /dev/null +++ b/apps/federation/lib/BackgroundJob/GetSharedSecret.php @@ -0,0 +1,176 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\BackgroundJob; + +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\RequestException; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\Job; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\OCS\IDiscoveryService; +use Psr\Log\LoggerInterface; + +/** + * Class GetSharedSecret + * + * Request shared secret from remote Nextcloud + * + * @package OCA\Federation\Backgroundjob + */ +class GetSharedSecret extends Job { + private IClient $httpClient; + protected bool $retainJob = false; + private string $defaultEndPoint = '/ocs/v2.php/apps/federation/api/v1/shared-secret'; + /** 30 day = 2592000sec */ + private int $maxLifespan = 2592000; + + public function __construct( + IClientService $httpClientService, + private IURLGenerator $urlGenerator, + private IJobList $jobList, + private TrustedServers $trustedServers, + private LoggerInterface $logger, + private IDiscoveryService $ocsDiscoveryService, + ITimeFactory $timeFactory, + private IConfig $config, + ) { + parent::__construct($timeFactory); + $this->httpClient = $httpClientService->newClient(); + } + + /** + * Run the job, then remove it from the joblist + */ + public function start(IJobList $jobList): void { + $target = $this->argument['url']; + // only execute if target is still in the list of trusted domains + if ($this->trustedServers->isTrustedServer($target)) { + $this->parentStart($jobList); + } + + $jobList->remove($this, $this->argument); + + if ($this->retainJob) { + $this->reAddJob($this->argument); + } + } + + protected function parentStart(IJobList $jobList): void { + parent::start($jobList); + } + + protected function run($argument) { + $target = $argument['url']; + $created = isset($argument['created']) ? (int)$argument['created'] : $this->time->getTime(); + $currentTime = $this->time->getTime(); + $source = $this->urlGenerator->getAbsoluteURL('/'); + $source = rtrim($source, '/'); + $token = $argument['token']; + + // kill job after 30 days of trying + $deadline = $currentTime - $this->maxLifespan; + if ($created < $deadline) { + $this->logger->warning("The job to get the shared secret job is too old and gets stopped now without retention. Setting server status of '{$target}' to failure."); + $this->retainJob = false; + $this->trustedServers->setServerStatus($target, TrustedServers::STATUS_FAILURE); + return; + } + + $endPoints = $this->ocsDiscoveryService->discover($target, 'FEDERATED_SHARING'); + $endPoint = $endPoints['shared-secret'] ?? $this->defaultEndPoint; + + // make sure that we have a well formatted url + $url = rtrim($target, '/') . '/' . trim($endPoint, '/'); + + $result = null; + try { + $result = $this->httpClient->get( + $url, + [ + 'query' => [ + 'url' => $source, + 'token' => $token, + 'format' => 'json', + ], + 'timeout' => 3, + 'connect_timeout' => 3, + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ] + ); + + $status = $result->getStatusCode(); + } catch (ClientException $e) { + $status = $e->getCode(); + if ($status === Http::STATUS_FORBIDDEN) { + $this->logger->info($target . ' refused to exchange a shared secret with you.'); + } else { + $this->logger->info($target . ' responded with a ' . $status . ' containing: ' . $e->getMessage()); + } + } catch (RequestException $e) { + $status = -1; // There is no status code if we could not connect + $this->logger->info('Could not connect to ' . $target, [ + 'exception' => $e, + ]); + } catch (\Throwable $e) { + $status = Http::STATUS_INTERNAL_SERVER_ERROR; + $this->logger->error($e->getMessage(), [ + 'exception' => $e, + ]); + } + + // if we received a unexpected response we try again later + if ( + $status !== Http::STATUS_OK + && $status !== Http::STATUS_FORBIDDEN + ) { + $this->retainJob = true; + } + + if ($status === Http::STATUS_OK && $result instanceof IResponse) { + $body = $result->getBody(); + $result = json_decode($body, true); + if (isset($result['ocs']['data']['sharedSecret'])) { + $this->trustedServers->addSharedSecret( + $target, + $result['ocs']['data']['sharedSecret'] + ); + } else { + $this->logger->error( + 'remote server "' . $target . '"" does not return a valid shared secret. Received data: ' . $body + ); + $this->trustedServers->setServerStatus($target, TrustedServers::STATUS_FAILURE); + } + } + } + + /** + * Re-add background job + * + * @param array $argument + */ + protected function reAddJob(array $argument): void { + $url = $argument['url']; + $created = $argument['created'] ?? $this->time->getTime(); + $token = $argument['token']; + $this->jobList->add( + GetSharedSecret::class, + [ + 'url' => $url, + 'token' => $token, + 'created' => $created + ] + ); + } +} diff --git a/apps/federation/lib/BackgroundJob/RequestSharedSecret.php b/apps/federation/lib/BackgroundJob/RequestSharedSecret.php new file mode 100644 index 00000000000..4d57d1f6aef --- /dev/null +++ b/apps/federation/lib/BackgroundJob/RequestSharedSecret.php @@ -0,0 +1,173 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\BackgroundJob; + +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\RequestException; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\Job; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\OCS\IDiscoveryService; +use Psr\Log\LoggerInterface; + +/** + * Class RequestSharedSecret + * + * Ask remote Nextcloud to request a sharedSecret from this server + * + * @package OCA\Federation\Backgroundjob + */ +class RequestSharedSecret extends Job { + private IClient $httpClient; + + protected bool $retainJob = false; + + private string $defaultEndPoint = '/ocs/v2.php/apps/federation/api/v1/request-shared-secret'; + + /** @var int 30 day = 2592000sec */ + private int $maxLifespan = 2592000; + + public function __construct( + IClientService $httpClientService, + private IURLGenerator $urlGenerator, + private IJobList $jobList, + private TrustedServers $trustedServers, + private IDiscoveryService $ocsDiscoveryService, + private LoggerInterface $logger, + ITimeFactory $timeFactory, + private IConfig $config, + ) { + parent::__construct($timeFactory); + $this->httpClient = $httpClientService->newClient(); + } + + + /** + * run the job, then remove it from the joblist + */ + public function start(IJobList $jobList): void { + $target = $this->argument['url']; + // only execute if target is still in the list of trusted domains + if ($this->trustedServers->isTrustedServer($target)) { + $this->parentStart($jobList); + } + + $jobList->remove($this, $this->argument); + + if ($this->retainJob) { + $this->reAddJob($this->argument); + } + } + + /** + * Call start() method of parent + * Useful for unit tests + */ + protected function parentStart(IJobList $jobList): void { + parent::start($jobList); + } + + /** + * @param array $argument + * @return void + */ + protected function run($argument) { + $target = $argument['url']; + $created = isset($argument['created']) ? (int)$argument['created'] : $this->time->getTime(); + $currentTime = $this->time->getTime(); + $source = $this->urlGenerator->getAbsoluteURL('/'); + $source = rtrim($source, '/'); + $token = $argument['token']; + + // kill job after 30 days of trying + $deadline = $currentTime - $this->maxLifespan; + if ($created < $deadline) { + $this->logger->warning("The job to request the shared secret job is too old and gets stopped now without retention. Setting server status of '{$target}' to failure."); + $this->retainJob = false; + $this->trustedServers->setServerStatus($target, TrustedServers::STATUS_FAILURE); + return; + } + + $endPoints = $this->ocsDiscoveryService->discover($target, 'FEDERATED_SHARING'); + $endPoint = $endPoints['shared-secret'] ?? $this->defaultEndPoint; + + // make sure that we have a well formatted url + $url = rtrim($target, '/') . '/' . trim($endPoint, '/'); + + try { + $result = $this->httpClient->post( + $url, + [ + 'body' => [ + 'url' => $source, + 'token' => $token, + 'format' => 'json', + ], + 'timeout' => 3, + 'connect_timeout' => 3, + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ] + ); + + $status = $result->getStatusCode(); + } catch (ClientException $e) { + $status = $e->getCode(); + if ($status === Http::STATUS_FORBIDDEN) { + $this->logger->info($target . ' refused to ask for a shared secret.'); + } else { + $this->logger->info($target . ' responded with a ' . $status . ' containing: ' . $e->getMessage()); + } + } catch (RequestException $e) { + $status = -1; // There is no status code if we could not connect + $this->logger->info('Could not connect to ' . $target); + } catch (\Throwable $e) { + $status = Http::STATUS_INTERNAL_SERVER_ERROR; + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + + // if we received a unexpected response we try again later + if ( + $status !== Http::STATUS_OK + && ($status !== Http::STATUS_FORBIDDEN || $this->getAttempt($argument) < 5) + ) { + $this->retainJob = true; + } + } + + /** + * re-add background job + */ + protected function reAddJob(array $argument): void { + $url = $argument['url']; + $created = isset($argument['created']) ? (int)$argument['created'] : $this->time->getTime(); + $token = $argument['token']; + $attempt = $this->getAttempt($argument) + 1; + + $this->jobList->add( + RequestSharedSecret::class, + [ + 'url' => $url, + 'token' => $token, + 'created' => $created, + 'attempt' => $attempt + ] + ); + } + + protected function getAttempt(array $argument): int { + return $argument['attempt'] ?? 0; + } +} diff --git a/apps/federation/lib/Command/SyncFederationAddressBooks.php b/apps/federation/lib/Command/SyncFederationAddressBooks.php new file mode 100644 index 00000000000..36cb99473f7 --- /dev/null +++ b/apps/federation/lib/Command/SyncFederationAddressBooks.php @@ -0,0 +1,45 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\Command; + +use OCA\Federation\SyncFederationAddressBooks as SyncService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class SyncFederationAddressBooks extends Command { + public function __construct( + private SyncService $syncService, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('federation:sync-addressbooks') + ->setDescription('Synchronizes addressbooks of all federated clouds'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $progress = new ProgressBar($output); + $progress->start(); + $this->syncService->syncThemAll(function ($url, $ex) use ($progress, $output): void { + if ($ex instanceof \Exception) { + $output->writeln("Error while syncing $url : " . $ex->getMessage()); + } else { + $progress->advance(); + } + }); + + $progress->finish(); + $output->writeln(''); + + return 0; + } +} diff --git a/apps/federation/lib/Controller/OCSAuthAPIController.php b/apps/federation/lib/Controller/OCSAuthAPIController.php new file mode 100644 index 00000000000..16b401be251 --- /dev/null +++ b/apps/federation/lib/Controller/OCSAuthAPIController.php @@ -0,0 +1,169 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\Controller; + +use OCA\Federation\DbHandler; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; + +/** + * Class OCSAuthAPI + * + * OCS API end-points to exchange shared secret between two connected Nextclouds + * + * @package OCA\Federation\Controller + */ +#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)] +class OCSAuthAPIController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private ISecureRandom $secureRandom, + private IJobList $jobList, + private TrustedServers $trustedServers, + private DbHandler $dbHandler, + private LoggerInterface $logger, + private ITimeFactory $timeFactory, + private IThrottler $throttler, + ) { + parent::__construct($appName, $request); + } + + /** + * Request received to ask remote server for a shared secret, for legacy end-points + * + * @param string $url URL of the server + * @param string $token Token of the server + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSForbiddenException Requesting shared secret is not allowed + * + * 200: Shared secret requested successfully + */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'federationSharedSecret')] + public function requestSharedSecretLegacy(string $url, string $token): DataResponse { + return $this->requestSharedSecret($url, $token); + } + + + /** + * Create shared secret and return it, for legacy end-points + * + * @param string $url URL of the server + * @param string $token Token of the server + * @return DataResponse<Http::STATUS_OK, array{sharedSecret: string}, array{}> + * @throws OCSForbiddenException Getting shared secret is not allowed + * + * 200: Shared secret returned + */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'federationSharedSecret')] + public function getSharedSecretLegacy(string $url, string $token): DataResponse { + return $this->getSharedSecret($url, $token); + } + + /** + * Request received to ask remote server for a shared secret + * + * @param string $url URL of the server + * @param string $token Token of the server + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSForbiddenException Requesting shared secret is not allowed + * + * 200: Shared secret requested successfully + */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'federationSharedSecret')] + public function requestSharedSecret(string $url, string $token): DataResponse { + if ($this->trustedServers->isTrustedServer($url) === false) { + $this->throttler->registerAttempt('federationSharedSecret', $this->request->getRemoteAddress()); + $this->logger->error('remote server not trusted (' . $url . ') while requesting shared secret'); + throw new OCSForbiddenException(); + } + + // if both server initiated the exchange of the shared secret the greater + // token wins + $localToken = $this->dbHandler->getToken($url); + if (strcmp($localToken, $token) > 0) { + $this->logger->info( + 'remote server (' . $url . ') presented lower token. We will initiate the exchange of the shared secret.' + ); + throw new OCSForbiddenException(); + } + + $this->jobList->add( + 'OCA\Federation\BackgroundJob\GetSharedSecret', + [ + 'url' => $url, + 'token' => $token, + 'created' => $this->timeFactory->getTime() + ] + ); + + return new DataResponse(); + } + + /** + * Create shared secret and return it + * + * @param string $url URL of the server + * @param string $token Token of the server + * @return DataResponse<Http::STATUS_OK, array{sharedSecret: string}, array{}> + * @throws OCSForbiddenException Getting shared secret is not allowed + * + * 200: Shared secret returned + */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'federationSharedSecret')] + public function getSharedSecret(string $url, string $token): DataResponse { + if ($this->trustedServers->isTrustedServer($url) === false) { + $this->throttler->registerAttempt('federationSharedSecret', $this->request->getRemoteAddress()); + $this->logger->error('remote server not trusted (' . $url . ') while getting shared secret'); + throw new OCSForbiddenException(); + } + + if ($this->isValidToken($url, $token) === false) { + $this->throttler->registerAttempt('federationSharedSecret', $this->request->getRemoteAddress()); + $expectedToken = $this->dbHandler->getToken($url); + $this->logger->error( + 'remote server (' . $url . ') didn\'t send a valid token (got "' . $token . '" but expected "' . $expectedToken . '") while getting shared secret' + ); + throw new OCSForbiddenException(); + } + + $sharedSecret = $this->secureRandom->generate(32); + + $this->trustedServers->addSharedSecret($url, $sharedSecret); + + return new DataResponse([ + 'sharedSecret' => $sharedSecret + ]); + } + + protected function isValidToken(string $url, string $token): bool { + $storedToken = $this->dbHandler->getToken($url); + return hash_equals($storedToken, $token); + } +} diff --git a/apps/federation/lib/Controller/SettingsController.php b/apps/federation/lib/Controller/SettingsController.php new file mode 100644 index 00000000000..27341eba815 --- /dev/null +++ b/apps/federation/lib/Controller/SettingsController.php @@ -0,0 +1,125 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\Controller; + +use OCA\Federation\Settings\Admin; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +class SettingsController extends OCSController { + public function __construct( + string $AppName, + IRequest $request, + private IL10N $l, + private TrustedServers $trustedServers, + private LoggerInterface $logger, + ) { + parent::__construct($AppName, $request); + } + + + /** + * Add server to the list of trusted Nextcloud servers + * + * @param string $url The URL of the server to add + * @return DataResponse<Http::STATUS_OK, array{id: int, message: string, url: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_CONFLICT, array{message: string}, array{}> + * + * 200: Server added successfully + * 404: Server not found at the given URL + * 409: Server is already in the list of trusted servers + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + #[ApiRoute(verb: 'POST', url: '/trusted-servers')] + public function addServer(string $url): DataResponse { + $this->checkServer(trim($url)); + + // Add the server to the list of trusted servers, all is well + $id = $this->trustedServers->addServer(trim($url)); + return new DataResponse([ + 'url' => $url, + 'id' => $id, + 'message' => $this->l->t('Added to the list of trusted servers') + ]); + } + + /** + * Add server to the list of trusted Nextcloud servers + * + * @param int $id The ID of the trusted server to remove + * @return DataResponse<Http::STATUS_OK, array{id: int}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 200: Server removed successfully + * 404: Server not found at the given ID + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + #[ApiRoute(verb: 'DELETE', url: '/trusted-servers/{id}', requirements: ['id' => '\d+'])] + public function removeServer(int $id): DataResponse { + try { + $this->trustedServers->getServer($id); + } catch (\Exception $e) { + throw new OCSNotFoundException($this->l->t('No server found with ID: %s', [$id])); + } + + try { + $this->trustedServers->removeServer($id); + return new DataResponse(['id' => $id]); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['e' => $e]); + throw new OCSException($this->l->t('Could not remove server'), Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * List all trusted servers + * + * @return DataResponse<Http::STATUS_OK, list<array{id: int, status: int, url: string}>, array{}> + * + * 200: List of trusted servers + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + #[ApiRoute(verb: 'GET', url: '/trusted-servers')] + public function getServers(): DataResponse { + $servers = $this->trustedServers->getServers(); + + // obfuscate the shared secret + $servers = array_map(function ($server) { + return [ + 'url' => $server['url'], + 'id' => $server['id'], + 'status' => $server['status'], + ]; + }, $servers); + + // return the list of trusted servers + return new DataResponse($servers); + } + + + /** + * Check if the server should be added to the list of trusted servers or not. + */ + #[AuthorizedAdminSetting(settings: Admin::class)] + protected function checkServer(string $url): void { + if ($this->trustedServers->isTrustedServer($url) === true) { + throw new OCSException($this->l->t('Server is already in the list of trusted servers.'), Http::STATUS_CONFLICT); + } + + if ($this->trustedServers->isNextcloudServer($url) === false) { + throw new OCSNotFoundException($this->l->t('No server to federate with found')); + } + } +} diff --git a/apps/federation/lib/DAV/FedAuth.php b/apps/federation/lib/DAV/FedAuth.php new file mode 100644 index 00000000000..45bf422c104 --- /dev/null +++ b/apps/federation/lib/DAV/FedAuth.php @@ -0,0 +1,52 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation\DAV; + +use OCA\Federation\DbHandler; +use OCP\Defaults; +use Sabre\DAV\Auth\Backend\AbstractBasic; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class FedAuth extends AbstractBasic { + + /** + * FedAuth constructor. + * + * @param DbHandler $db + */ + public function __construct( + private DbHandler $db, + ) { + $this->principalPrefix = 'principals/system/'; + + // setup realm + $defaults = new Defaults(); + $this->realm = $defaults->getName(); + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * @return bool + */ + protected function validateUserPass($username, $password) { + return $this->db->auth($username, $password); + } + + /** + * @inheritdoc + */ + public function challenge(RequestInterface $request, ResponseInterface $response) { + } +} diff --git a/apps/federation/lib/DbHandler.php b/apps/federation/lib/DbHandler.php new file mode 100644 index 00000000000..877663b058a --- /dev/null +++ b/apps/federation/lib/DbHandler.php @@ -0,0 +1,268 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors* + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation; + +use OC\Files\Filesystem; +use OCP\DB\Exception as DBException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\HintException; +use OCP\IDBConnection; +use OCP\IL10N; + +/** + * Class DbHandler + * + * Handles all database calls for the federation app + * + * @todo Port to QBMapper + * + * @group DB + * @package OCA\Federation + */ +class DbHandler { + private string $dbTable = 'trusted_servers'; + + public function __construct( + private IDBConnection $connection, + private IL10N $IL10N, + ) { + } + + /** + * Add server to the list of trusted servers + * + * @throws HintException + */ + public function addServer(string $url): int { + $hash = $this->hash($url); + $url = rtrim($url, '/'); + $query = $this->connection->getQueryBuilder(); + $query->insert($this->dbTable) + ->values([ + 'url' => $query->createParameter('url'), + 'url_hash' => $query->createParameter('url_hash'), + ]) + ->setParameter('url', $url) + ->setParameter('url_hash', $hash); + + $result = $query->executeStatement(); + + if ($result) { + return $query->getLastInsertId(); + } + + $message = 'Internal failure, Could not add trusted server: ' . $url; + $message_t = $this->IL10N->t('Could not add server'); + throw new HintException($message, $message_t); + return -1; + } + + /** + * Remove server from the list of trusted servers + */ + public function removeServer(int $id): void { + $query = $this->connection->getQueryBuilder(); + $query->delete($this->dbTable) + ->where($query->expr()->eq('id', $query->createParameter('id'))) + ->setParameter('id', $id); + $query->executeStatement(); + } + + /** + * Get trusted server with given ID + * + * @return array{id: int, url: string, url_hash: string, token: ?string, shared_secret: ?string, status: int, sync_token: ?string} + * @throws \Exception + */ + public function getServerById(int $id): array { + $query = $this->connection->getQueryBuilder(); + $query->select('*')->from($this->dbTable) + ->where($query->expr()->eq('id', $query->createParameter('id'))) + ->setParameter('id', $id, IQueryBuilder::PARAM_INT); + + $qResult = $query->executeQuery(); + $result = $qResult->fetchAll(); + $qResult->closeCursor(); + + if (empty($result)) { + throw new \Exception('No Server found with ID: ' . $id); + } + + return $result[0]; + } + + /** + * Get all trusted servers + * + * @return list<array{id: int, url: string, url_hash: string, shared_secret: ?string, status: int, sync_token: ?string}> + * @throws DBException + */ + public function getAllServer(): array { + $query = $this->connection->getQueryBuilder(); + $query->select(['url', 'url_hash', 'id', 'status', 'shared_secret', 'sync_token']) + ->from($this->dbTable); + $statement = $query->executeQuery(); + $result = $statement->fetchAll(); + $statement->closeCursor(); + return $result; + } + + /** + * Check if server already exists in the database table + */ + public function serverExists(string $url): bool { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->select('url') + ->from($this->dbTable) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash); + $statement = $query->executeQuery(); + $result = $statement->fetchAll(); + $statement->closeCursor(); + + return !empty($result); + } + + /** + * Write token to database. Token is used to exchange the secret + */ + public function addToken(string $url, string $token): void { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->update($this->dbTable) + ->set('token', $query->createParameter('token')) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash) + ->setParameter('token', $token); + $query->executeStatement(); + } + + /** + * Get token stored in database + * @throws \Exception + */ + public function getToken(string $url): string { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->select('token')->from($this->dbTable) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash); + + $statement = $query->executeQuery(); + $result = $statement->fetch(); + $statement->closeCursor(); + + if (!isset($result['token'])) { + throw new \Exception('No token found for: ' . $url); + } + + return $result['token']; + } + + /** + * Add shared Secret to database + */ + public function addSharedSecret(string $url, string $sharedSecret): void { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->update($this->dbTable) + ->set('shared_secret', $query->createParameter('sharedSecret')) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash) + ->setParameter('sharedSecret', $sharedSecret); + $query->executeStatement(); + } + + /** + * Get shared secret from database + */ + public function getSharedSecret(string $url): string { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->select('shared_secret')->from($this->dbTable) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash); + + $statement = $query->executeQuery(); + $result = $statement->fetch(); + $statement->closeCursor(); + return (string)$result['shared_secret']; + } + + /** + * Set server status + */ + public function setServerStatus(string $url, int $status, ?string $token = null): void { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->update($this->dbTable) + ->set('status', $query->createNamedParameter($status)) + ->where($query->expr()->eq('url_hash', $query->createNamedParameter($hash))); + if (!is_null($token)) { + $query->set('sync_token', $query->createNamedParameter($token)); + } + $query->executeStatement(); + } + + /** + * Get server status + */ + public function getServerStatus(string $url): int { + $hash = $this->hash($url); + $query = $this->connection->getQueryBuilder(); + $query->select('status')->from($this->dbTable) + ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) + ->setParameter('url_hash', $hash); + + $statement = $query->executeQuery(); + $result = $statement->fetch(); + $statement->closeCursor(); + return (int)$result['status']; + } + + /** + * Create hash from URL + */ + protected function hash(string $url): string { + $normalized = $this->normalizeUrl($url); + return sha1($normalized); + } + + /** + * Normalize URL, used to create the sha1 hash + */ + protected function normalizeUrl(string $url): string { + $normalized = $url; + + if (strpos($url, 'https://') === 0) { + $normalized = substr($url, strlen('https://')); + } elseif (strpos($url, 'http://') === 0) { + $normalized = substr($url, strlen('http://')); + } + + $normalized = Filesystem::normalizePath($normalized); + $normalized = trim($normalized, '/'); + + return $normalized; + } + + public function auth(string $username, string $password): bool { + if ($username !== 'system') { + return false; + } + $query = $this->connection->getQueryBuilder(); + $query->select('url')->from($this->dbTable) + ->where($query->expr()->eq('shared_secret', $query->createNamedParameter($password))); + + $statement = $query->executeQuery(); + $result = $statement->fetch(); + $statement->closeCursor(); + return !empty($result); + } +} diff --git a/apps/federation/lib/Listener/SabrePluginAuthInitListener.php b/apps/federation/lib/Listener/SabrePluginAuthInitListener.php new file mode 100644 index 00000000000..514a893fb39 --- /dev/null +++ b/apps/federation/lib/Listener/SabrePluginAuthInitListener.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Federation\Listener; + +use OCA\DAV\Events\SabrePluginAuthInitEvent; +use OCA\Federation\DAV\FedAuth; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Sabre\DAV\Auth\Plugin; + +/** + * @since 20.0.0 + * @template-implements IEventListener<SabrePluginAuthInitEvent> + */ +class SabrePluginAuthInitListener implements IEventListener { + public function __construct( + private FedAuth $fedAuth, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof SabrePluginAuthInitEvent)) { + return; + } + + $server = $event->getServer(); + $authPlugin = $server->getPlugin('auth'); + if ($authPlugin instanceof Plugin) { + $authPlugin->addBackend($this->fedAuth); + } + } +} diff --git a/apps/federation/lib/Migration/Version1010Date20200630191302.php b/apps/federation/lib/Migration/Version1010Date20200630191302.php new file mode 100644 index 00000000000..c1a7c38cfc7 --- /dev/null +++ b/apps/federation/lib/Migration/Version1010Date20200630191302.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Federation\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1010Date20200630191302 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('trusted_servers')) { + $table = $schema->createTable('trusted_servers'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('url', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('url_hash', Types::STRING, [ + 'notnull' => true, + 'default' => '', + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => false, + 'length' => 128, + ]); + $table->addColumn('shared_secret', Types::STRING, [ + 'notnull' => false, + 'length' => 256, + ]); + $table->addColumn('status', Types::INTEGER, [ + 'notnull' => true, + 'length' => 4, + 'default' => 2, + ]); + $table->addColumn('sync_token', Types::STRING, [ + 'notnull' => false, + 'length' => 512, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['url_hash'], 'url_hash'); + } + return $schema; + } +} diff --git a/apps/federation/lib/Settings/Admin.php b/apps/federation/lib/Settings/Admin.php new file mode 100644 index 00000000000..5cf5346bb85 --- /dev/null +++ b/apps/federation/lib/Settings/Admin.php @@ -0,0 +1,57 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Federation\Settings; + +use OCA\Federation\TrustedServers; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IL10N; +use OCP\Settings\IDelegatedSettings; + +class Admin implements IDelegatedSettings { + public function __construct( + private TrustedServers $trustedServers, + private IL10N $l, + ) { + } + + /** + * @return TemplateResponse + */ + public function getForm() { + $parameters = [ + 'trustedServers' => $this->trustedServers->getServers(), + ]; + + return new TemplateResponse('federation', 'settings-admin', $parameters, ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'sharing'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority() { + return 30; + } + + public function getName(): ?string { + return $this->l->t('Trusted servers'); + } + + public function getAuthorizedAppConfig(): array { + return []; // Handled by custom controller + } +} diff --git a/apps/federation/lib/SyncFederationAddressBooks.php b/apps/federation/lib/SyncFederationAddressBooks.php new file mode 100644 index 00000000000..d11f92b76ef --- /dev/null +++ b/apps/federation/lib/SyncFederationAddressBooks.php @@ -0,0 +1,94 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation; + +use OC\OCS\DiscoveryService; +use OCA\DAV\CardDAV\SyncService; +use OCP\AppFramework\Http; +use OCP\OCS\IDiscoveryService; +use Psr\Log\LoggerInterface; + +class SyncFederationAddressBooks { + private DiscoveryService $ocsDiscoveryService; + + public function __construct( + protected DbHandler $dbHandler, + private SyncService $syncService, + IDiscoveryService $ocsDiscoveryService, + private LoggerInterface $logger, + ) { + $this->ocsDiscoveryService = $ocsDiscoveryService; + } + + /** + * @param \Closure $callback + */ + public function syncThemAll(\Closure $callback) { + $trustedServers = $this->dbHandler->getAllServer(); + foreach ($trustedServers as $trustedServer) { + $url = $trustedServer['url']; + $callback($url, null); + $sharedSecret = $trustedServer['shared_secret']; + $oldSyncToken = $trustedServer['sync_token']; + + $endPoints = $this->ocsDiscoveryService->discover($url, 'FEDERATED_SHARING'); + $cardDavUser = $endPoints['carddav-user'] ?? 'system'; + $addressBookUrl = isset($endPoints['system-address-book']) ? trim($endPoints['system-address-book'], '/') : 'remote.php/dav/addressbooks/system/system/system'; + + if (is_null($sharedSecret)) { + $this->logger->debug("Shared secret for $url is null"); + continue; + } + $targetBookId = $trustedServer['url_hash']; + $targetPrincipal = 'principals/system/system'; + $targetBookProperties = [ + '{DAV:}displayname' => $url + ]; + + try { + $syncToken = $oldSyncToken; + + do { + [$syncToken, $truncated] = $this->syncService->syncRemoteAddressBook( + $url, + $cardDavUser, + $addressBookUrl, + $sharedSecret, + $syncToken, + $targetBookId, + $targetPrincipal, + $targetBookProperties + ); + } while ($truncated); + + if ($syncToken !== $oldSyncToken) { + $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_OK, $syncToken); + } else { + $this->logger->debug("Sync Token for $url unchanged from previous sync"); + // The server status might have been changed to a failure status in previous runs. + if ($this->dbHandler->getServerStatus($url) !== TrustedServers::STATUS_OK) { + $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_OK); + } + } + } catch (\Exception $ex) { + if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { + $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_ACCESS_REVOKED); + $this->logger->error("Server sync for $url failed because of revoked access.", [ + 'exception' => $ex, + ]); + } else { + $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_FAILURE); + $this->logger->error("Server sync for $url failed.", [ + 'exception' => $ex, + ]); + } + $callback($url, $ex); + } + } + } +} diff --git a/apps/federation/lib/SyncJob.php b/apps/federation/lib/SyncJob.php new file mode 100644 index 00000000000..b802dfa9308 --- /dev/null +++ b/apps/federation/lib/SyncJob.php @@ -0,0 +1,35 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; + +class SyncJob extends TimedJob { + public function __construct( + protected SyncFederationAddressBooks $syncService, + protected LoggerInterface $logger, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); + // Run once a day + $this->setInterval(24 * 60 * 60); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument) { + $this->syncService->syncThemAll(function ($url, $ex): void { + if ($ex instanceof \Exception) { + $this->logger->error("Error while syncing $url.", [ + 'exception' => $ex, + ]); + } + }); + } +} diff --git a/apps/federation/lib/TrustedServers.php b/apps/federation/lib/TrustedServers.php new file mode 100644 index 00000000000..3d15cfac448 --- /dev/null +++ b/apps/federation/lib/TrustedServers.php @@ -0,0 +1,209 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Federation; + +use OCA\Federation\BackgroundJob\RequestSharedSecret; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\Events\TrustedServerRemovedEvent; +use OCP\HintException; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; + +class TrustedServers { + + /** after a user list was exchanged at least once successfully */ + public const STATUS_OK = 1; + /** waiting for shared secret or initial user list exchange */ + public const STATUS_PENDING = 2; + /** something went wrong, misconfigured server, software bug,... user interaction needed */ + public const STATUS_FAILURE = 3; + /** remote server revoked access */ + public const STATUS_ACCESS_REVOKED = 4; + + /** @var list<array{id: int, url: string, url_hash: string, shared_secret: ?string, status: int, sync_token: ?string}>|null */ + private ?array $trustedServersCache = null; + + public function __construct( + private DbHandler $dbHandler, + private IClientService $httpClientService, + private LoggerInterface $logger, + private IJobList $jobList, + private ISecureRandom $secureRandom, + private IConfig $config, + private IEventDispatcher $dispatcher, + private ITimeFactory $timeFactory, + ) { + } + + /** + * Add server to the list of trusted servers + */ + public function addServer(string $url): int { + $url = $this->updateProtocol($url); + $result = $this->dbHandler->addServer($url); + if ($result) { + $token = $this->secureRandom->generate(16); + $this->dbHandler->addToken($url, $token); + $this->jobList->add( + RequestSharedSecret::class, + [ + 'url' => $url, + 'token' => $token, + 'created' => $this->timeFactory->getTime() + ] + ); + } + + return $result; + } + + /** + * Get shared secret for the given server + */ + public function getSharedSecret(string $url): string { + return $this->dbHandler->getSharedSecret($url); + } + + /** + * Add shared secret for the given server + */ + public function addSharedSecret(string $url, string $sharedSecret): void { + $this->dbHandler->addSharedSecret($url, $sharedSecret); + } + + /** + * Remove server from the list of trusted servers + */ + public function removeServer(int $id): void { + $server = $this->dbHandler->getServerById($id); + $this->dbHandler->removeServer($id); + $this->dispatcher->dispatchTyped(new TrustedServerRemovedEvent($server['url_hash'])); + + } + + /** + * Get all trusted servers + * + * @return list<array{id: int, url: string, url_hash: string, shared_secret: ?string, status: int, sync_token: ?string}> + * @throws \Exception + */ + public function getServers(): ?array { + if ($this->trustedServersCache === null) { + $this->trustedServersCache = $this->dbHandler->getAllServer(); + } + return $this->trustedServersCache; + } + + /** + * Get a trusted server + * + * @return array{id: int, url: string, url_hash: string, shared_secret: ?string, status: int, sync_token: ?string} + * @throws Exception + */ + public function getServer(int $id): ?array { + if ($this->trustedServersCache === null) { + $this->trustedServersCache = $this->dbHandler->getAllServer(); + } + + foreach ($this->trustedServersCache as $server) { + if ($server['id'] === $id) { + return $server; + } + } + + throw new \Exception('No server found with ID: ' . $id); + } + + /** + * Check if given server is a trusted Nextcloud server + */ + public function isTrustedServer(string $url): bool { + return $this->dbHandler->serverExists($url); + } + + /** + * Set server status + */ + public function setServerStatus(string $url, int $status): void { + $this->dbHandler->setServerStatus($url, $status); + } + + /** + * Get server status + */ + public function getServerStatus(string $url): int { + return $this->dbHandler->getServerStatus($url); + } + + /** + * Check if URL point to a ownCloud/Nextcloud server + */ + public function isNextcloudServer(string $url): bool { + $isValidNextcloud = false; + $client = $this->httpClientService->newClient(); + try { + $result = $client->get( + $url . '/status.php', + [ + 'timeout' => 3, + 'connect_timeout' => 3, + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ] + ); + if ($result->getStatusCode() === Http::STATUS_OK) { + $body = $result->getBody(); + if (is_resource($body)) { + $body = stream_get_contents($body) ?: ''; + } + $isValidNextcloud = $this->checkNextcloudVersion($body); + } + } catch (\Exception $e) { + $this->logger->error('No Nextcloud server.', [ + 'exception' => $e, + ]); + return false; + } + + return $isValidNextcloud; + } + + /** + * Check if ownCloud/Nextcloud version is >= 9.0 + * @throws HintException + */ + protected function checkNextcloudVersion(string $status): bool { + $decoded = json_decode($status, true); + if (!empty($decoded) && isset($decoded['version'])) { + if (!version_compare($decoded['version'], '9.0.0', '>=')) { + throw new HintException('Remote server version is too low. 9.0 is required.'); + } + return true; + } + return false; + } + + /** + * Check if the URL contain a protocol, if not add https + */ + protected function updateProtocol(string $url): string { + if ( + strpos($url, 'https://') === 0 + || strpos($url, 'http://') === 0 + ) { + return $url; + } + + return 'https://' . $url; + } +} diff --git a/apps/federation/lib/dbhandler.php b/apps/federation/lib/dbhandler.php deleted file mode 100644 index 8720560efc6..00000000000 --- a/apps/federation/lib/dbhandler.php +++ /dev/null @@ -1,318 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - - -namespace OCA\Federation; - - -use OC\Files\Filesystem; -use OC\HintException; -use OCP\IDBConnection; -use OCP\IL10N; - -/** - * Class DbHandler - * - * handles all database calls for the federation app - * - * @group DB - * @package OCA\Federation - */ -class DbHandler { - - /** @var IDBConnection */ - private $connection; - - /** @var IL10N */ - private $l; - - /** @var string */ - private $dbTable = 'trusted_servers'; - - /** - * @param IDBConnection $connection - * @param IL10N $il10n - */ - public function __construct( - IDBConnection $connection, - IL10N $il10n - ) { - $this->connection = $connection; - $this->IL10N = $il10n; - } - - /** - * add server to the list of trusted ownCloud servers - * - * @param string $url - * @return int - * @throws HintException - */ - public function addServer($url) { - $hash = $this->hash($url); - $url = rtrim($url, '/'); - $query = $this->connection->getQueryBuilder(); - $query->insert($this->dbTable) - ->values( - [ - 'url' => $query->createParameter('url'), - 'url_hash' => $query->createParameter('url_hash'), - ] - ) - ->setParameter('url', $url) - ->setParameter('url_hash', $hash); - - $result = $query->execute(); - - if ($result) { - return (int)$this->connection->lastInsertId('*PREFIX*'.$this->dbTable); - } else { - $message = 'Internal failure, Could not add ownCloud as trusted server: ' . $url; - $message_t = $this->l->t('Could not add server'); - throw new HintException($message, $message_t); - } - } - - /** - * remove server from the list of trusted ownCloud servers - * - * @param int $id - */ - public function removeServer($id) { - $query = $this->connection->getQueryBuilder(); - $query->delete($this->dbTable) - ->where($query->expr()->eq('id', $query->createParameter('id'))) - ->setParameter('id', $id); - $query->execute(); - } - - /** - * get trusted server with given ID - * - * @param int $id - * @return array - * @throws \Exception - */ - public function getServerById($id) { - $query = $this->connection->getQueryBuilder(); - $query->select('*')->from($this->dbTable) - ->where($query->expr()->eq('id', $query->createParameter('id'))) - ->setParameter('id', $id); - $query->execute(); - $result = $query->execute()->fetchAll(); - - if (empty($result)) { - throw new \Exception('No Server found with ID: ' . $id); - } - - return $result[0]; - } - - /** - * get all trusted servers - * - * @return array - */ - public function getAllServer() { - $query = $this->connection->getQueryBuilder(); - $query->select(['url', 'url_hash', 'id', 'status', 'shared_secret', 'sync_token'])->from($this->dbTable); - $result = $query->execute()->fetchAll(); - return $result; - } - - /** - * check if server already exists in the database table - * - * @param string $url - * @return bool - */ - public function serverExists($url) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->select('url')->from($this->dbTable) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash); - $result = $query->execute()->fetchAll(); - - return !empty($result); - } - - /** - * write token to database. Token is used to exchange the secret - * - * @param string $url - * @param string $token - */ - public function addToken($url, $token) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->update($this->dbTable) - ->set('token', $query->createParameter('token')) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash) - ->setParameter('token', $token); - $query->execute(); - } - - /** - * get token stored in database - * - * @param string $url - * @return string - * @throws \Exception - */ - public function getToken($url) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->select('token')->from($this->dbTable) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash); - - $result = $query->execute()->fetch(); - - if (!isset($result['token'])) { - throw new \Exception('No token found for: ' . $url); - } - - return $result['token']; - } - - /** - * add shared Secret to database - * - * @param string $url - * @param string $sharedSecret - */ - public function addSharedSecret($url, $sharedSecret) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->update($this->dbTable) - ->set('shared_secret', $query->createParameter('sharedSecret')) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash) - ->setParameter('sharedSecret', $sharedSecret); - $query->execute(); - } - - /** - * get shared secret from database - * - * @param string $url - * @return string - */ - public function getSharedSecret($url) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->select('shared_secret')->from($this->dbTable) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash); - - $result = $query->execute()->fetch(); - return $result['shared_secret']; - } - - /** - * set server status - * - * @param string $url - * @param int $status - * @param string|null $token - */ - public function setServerStatus($url, $status, $token = null) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->update($this->dbTable) - ->set('status', $query->createNamedParameter($status)) - ->where($query->expr()->eq('url_hash', $query->createNamedParameter($hash))); - if (!is_null($token)) { - $query->set('sync_token', $query->createNamedParameter($token)); - } - $query->execute(); - } - - /** - * get server status - * - * @param string $url - * @return int - */ - public function getServerStatus($url) { - $hash = $this->hash($url); - $query = $this->connection->getQueryBuilder(); - $query->select('status')->from($this->dbTable) - ->where($query->expr()->eq('url_hash', $query->createParameter('url_hash'))) - ->setParameter('url_hash', $hash); - - $result = $query->execute()->fetch(); - return (int)$result['status']; - } - - /** - * create hash from URL - * - * @param string $url - * @return string - */ - protected function hash($url) { - $normalized = $this->normalizeUrl($url); - return sha1($normalized); - } - - /** - * normalize URL, used to create the sha1 hash - * - * @param string $url - * @return string - */ - protected function normalizeUrl($url) { - $normalized = $url; - - if (strpos($url, 'https://') === 0) { - $normalized = substr($url, strlen('https://')); - } else if (strpos($url, 'http://') === 0) { - $normalized = substr($url, strlen('http://')); - } - - $normalized = Filesystem::normalizePath($normalized); - $normalized = trim($normalized, '/'); - - return $normalized; - } - - /** - * @param $username - * @param $password - * @return bool - */ - public function auth($username, $password) { - if ($username !== 'system') { - return false; - } - $query = $this->connection->getQueryBuilder(); - $query->select('url')->from($this->dbTable) - ->where($query->expr()->eq('shared_secret', $query->createNamedParameter($password))); - - $result = $query->execute()->fetch(); - return !empty($result); - } - -} diff --git a/apps/federation/lib/hooks.php b/apps/federation/lib/hooks.php deleted file mode 100644 index b7f63d27f55..00000000000 --- a/apps/federation/lib/hooks.php +++ /dev/null @@ -1,50 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - - -namespace OCA\Federation; - - - -class Hooks { - - /** @var TrustedServers */ - private $trustedServers; - - public function __construct(TrustedServers $trustedServers) { - $this->trustedServers = $trustedServers; - } - - /** - * add servers to the list of trusted servers once a federated share was established - * - * @param array $params - */ - public function addServerHook($params) { - if ( - $this->trustedServers->getAutoAddServers() === true && - $this->trustedServers->isTrustedServer($params['server']) === false - ) { - $this->trustedServers->addServer($params['server']); - } - } - -} diff --git a/apps/federation/lib/syncfederationaddressbooks.php b/apps/federation/lib/syncfederationaddressbooks.php deleted file mode 100644 index 209094266ca..00000000000 --- a/apps/federation/lib/syncfederationaddressbooks.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\Federation; - -use OCA\DAV\CardDAV\SyncService; -use OCP\AppFramework\Http; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; - -class SyncFederationAddressBooks { - - /** @var DbHandler */ - protected $dbHandler; - - /** @var SyncService */ - private $syncService; - - /** - * @param DbHandler $dbHandler - * @param SyncService $syncService - */ - function __construct(DbHandler $dbHandler, SyncService $syncService) { - $this->syncService = $syncService; - $this->dbHandler = $dbHandler; - } - - /** - * @param \Closure $callback - */ - public function syncThemAll(\Closure $callback) { - - $trustedServers = $this->dbHandler->getAllServer(); - foreach ($trustedServers as $trustedServer) { - $url = $trustedServer['url']; - $callback($url, null); - $sharedSecret = $trustedServer['shared_secret']; - $syncToken = $trustedServer['sync_token']; - - if (is_null($sharedSecret)) { - continue; - } - $targetBookId = $trustedServer['url_hash']; - $targetPrincipal = "principals/system/system"; - $targetBookProperties = [ - '{DAV:}displayname' => $url - ]; - try { - $newToken = $this->syncService->syncRemoteAddressBook($url, 'system', $sharedSecret, $syncToken, $targetBookId, $targetPrincipal, $targetBookProperties); - if ($newToken !== $syncToken) { - $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_OK, $newToken); - } - } catch (\Exception $ex) { - if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { - $this->dbHandler->setServerStatus($url, TrustedServers::STATUS_ACCESS_REVOKED); - } - $callback($url, $ex); - } - } - } -} diff --git a/apps/federation/lib/syncjob.php b/apps/federation/lib/syncjob.php deleted file mode 100644 index 2b904813b92..00000000000 --- a/apps/federation/lib/syncjob.php +++ /dev/null @@ -1,43 +0,0 @@ -<?php -/** - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Federation; - -use OC\BackgroundJob\TimedJob; -use OCA\Federation\AppInfo\Application; - -class SyncJob extends TimedJob { - - public function __construct() { - // Run once a day - $this->setInterval(24 * 60 * 60); - } - - protected function run($argument) { - $app = new Application(); - $ss = $app->getSyncService(); - $ss->syncThemAll(function($url, $ex) { - if ($ex instanceof \Exception) { - \OC::$server->getLogger()->error("Error while syncing $url : " . $ex->getMessage(), ['app' => 'fed-sync']); - } - }); - } -} diff --git a/apps/federation/lib/trustedservers.php b/apps/federation/lib/trustedservers.php deleted file mode 100644 index 3b356ea2a49..00000000000 --- a/apps/federation/lib/trustedservers.php +++ /dev/null @@ -1,270 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - - -namespace OCA\Federation; - -use OC\HintException; -use OCP\AppFramework\Http; -use OCP\BackgroundJob\IJobList; -use OCP\Http\Client\IClientService; -use OCP\IConfig; -use OCP\ILogger; -use OCP\Security\ISecureRandom; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; - -class TrustedServers { - - /** after a user list was exchanged at least once successfully */ - const STATUS_OK = 1; - /** waiting for shared secret or initial user list exchange */ - const STATUS_PENDING = 2; - /** something went wrong, misconfigured server, software bug,... user interaction needed */ - const STATUS_FAILURE = 3; - /** remote server revoked access */ - const STATUS_ACCESS_REVOKED = 4; - - /** @var dbHandler */ - private $dbHandler; - - /** @var IClientService */ - private $httpClientService; - - /** @var ILogger */ - private $logger; - - /** @var IJobList */ - private $jobList; - - /** @var ISecureRandom */ - private $secureRandom; - - /** @var IConfig */ - private $config; - - /** @var EventDispatcherInterface */ - private $dispatcher; - - /** - * @param DbHandler $dbHandler - * @param IClientService $httpClientService - * @param ILogger $logger - * @param IJobList $jobList - * @param ISecureRandom $secureRandom - * @param IConfig $config - * @param EventDispatcherInterface $dispatcher - */ - public function __construct( - DbHandler $dbHandler, - IClientService $httpClientService, - ILogger $logger, - IJobList $jobList, - ISecureRandom $secureRandom, - IConfig $config, - EventDispatcherInterface $dispatcher - ) { - $this->dbHandler = $dbHandler; - $this->httpClientService = $httpClientService; - $this->logger = $logger; - $this->jobList = $jobList; - $this->secureRandom = $secureRandom; - $this->config = $config; - $this->dispatcher = $dispatcher; - } - - /** - * add server to the list of trusted ownCloud servers - * - * @param $url - * @return int server id - */ - public function addServer($url) { - $url = $this->updateProtocol($url); - $result = $this->dbHandler->addServer($url); - if ($result) { - $token = $this->secureRandom->generate(16); - $this->dbHandler->addToken($url, $token); - $this->jobList->add( - 'OCA\Federation\BackgroundJob\RequestSharedSecret', - [ - 'url' => $url, - 'token' => $token - ] - ); - } - - return $result; - } - - /** - * enable/disable to automatically add servers to the list of trusted servers - * once a federated share was created and accepted successfully - * - * @param bool $status - */ - public function setAutoAddServers($status) { - $value = $status ? '1' : '0'; - $this->config->setAppValue('federation', 'autoAddServers', $value); - } - - /** - * return if we automatically add servers to the list of trusted servers - * once a federated share was created and accepted successfully - * - * @return bool - */ - public function getAutoAddServers() { - $value = $this->config->getAppValue('federation', 'autoAddServers', '1'); - return $value === '1'; - } - - /** - * get shared secret for the given server - * - * @param string $url - * @return string - */ - public function getSharedSecret($url) { - return $this->dbHandler->getSharedSecret($url); - } - - /** - * add shared secret for the given server - * - * @param string $url - * @param $sharedSecret - */ - public function addSharedSecret($url, $sharedSecret) { - $this->dbHandler->addSharedSecret($url, $sharedSecret); - } - - /** - * remove server from the list of trusted ownCloud servers - * - * @param int $id - */ - public function removeServer($id) { - $server = $this->dbHandler->getServerById($id); - $this->dbHandler->removeServer($id); - $event = new GenericEvent($server['url_hash']); - $this->dispatcher->dispatch('OCP\Federation\TrustedServerEvent::remove', $event); - } - - /** - * get all trusted servers - * - * @return array - */ - public function getServers() { - return $this->dbHandler->getAllServer(); - } - - /** - * check if given server is a trusted ownCloud server - * - * @param string $url - * @return bool - */ - public function isTrustedServer($url) { - return $this->dbHandler->serverExists($url); - } - - /** - * set server status - * - * @param string $url - * @param int $status - */ - public function setServerStatus($url, $status) { - $this->dbHandler->setServerStatus($url, $status); - } - - /** - * @param string $url - * @return int - */ - public function getServerStatus($url) { - return $this->dbHandler->getServerStatus($url); - } - - /** - * check if URL point to a ownCloud server - * - * @param string $url - * @return bool - */ - public function isOwnCloudServer($url) { - $isValidOwnCloud = false; - $client = $this->httpClientService->newClient(); - $result = $client->get( - $url . '/status.php', - [ - 'timeout' => 3, - 'connect_timeout' => 3, - ] - ); - if ($result->getStatusCode() === Http::STATUS_OK) { - $isValidOwnCloud = $this->checkOwnCloudVersion($result->getBody()); - } - - return $isValidOwnCloud; - } - - /** - * check if ownCloud version is >= 9.0 - * - * @param $status - * @return bool - * @throws HintException - */ - protected function checkOwnCloudVersion($status) { - $decoded = json_decode($status, true); - if (!empty($decoded) && isset($decoded['version'])) { - if (!version_compare($decoded['version'], '9.0.0', '>=')) { - throw new HintException('Remote server version is too low. ownCloud 9.0 is required.'); - } - return true; - } - return false; - } - - /** - * check if the URL contain a protocol, if not add https - * - * @param string $url - * @return string - */ - protected function updateProtocol($url) { - if ( - strpos($url, 'https://') === 0 - || strpos($url, 'http://') === 0 - ) { - - return $url; - - } - - return 'https://' . $url; - } -} |