diff options
Diffstat (limited to 'apps/cloud_federation_api/lib')
9 files changed, 962 insertions, 0 deletions
diff --git a/apps/cloud_federation_api/lib/AppInfo/Application.php b/apps/cloud_federation_api/lib/AppInfo/Application.php new file mode 100644 index 00000000000..e34b2f2dc3d --- /dev/null +++ b/apps/cloud_federation_api/lib/AppInfo/Application.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\CloudFederationAPI\AppInfo; + +use OCA\CloudFederationAPI\Capabilities; +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 { + public const APP_ID = 'cloud_federation_api'; + + public function __construct() { + parent::__construct(self::APP_ID); + } + + public function register(IRegistrationContext $context): void { + $context->registerCapability(Capabilities::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php new file mode 100644 index 00000000000..599733123b3 --- /dev/null +++ b/apps/cloud_federation_api/lib/Capabilities.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI; + +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use OC\OCM\OCMSignatoryManager; +use OCP\Capabilities\ICapability; +use OCP\Capabilities\IInitialStateExcludedCapability; +use OCP\IAppConfig; +use OCP\IURLGenerator; +use OCP\OCM\Exceptions\OCMArgumentException; +use OCP\OCM\ICapabilityAwareOCMProvider; +use Psr\Log\LoggerInterface; + +class Capabilities implements ICapability, IInitialStateExcludedCapability { + public const API_VERSION = '1.1.0'; + + public function __construct( + private IURLGenerator $urlGenerator, + private IAppConfig $appConfig, + private ICapabilityAwareOCMProvider $provider, + private readonly OCMSignatoryManager $ocmSignatoryManager, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Function an app uses to return the capabilities + * + * @return array<string, array<string, mixed>> + * @throws OCMArgumentException + */ + public function getCapabilities() { + $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); + $pos = strrpos($url, '/'); + if ($pos === false) { + throw new OCMArgumentException('generated route should contain a slash character'); + } + + $this->provider->setEnabled(true); + $this->provider->setApiVersion(self::API_VERSION); + $this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']); + + $this->provider->setEndPoint(substr($url, 0, $pos)); + + $resource = $this->provider->createNewResourceType(); + $resource->setName('file') + ->setShareTypes(['user', 'group']) + ->setProtocols(['webdav' => '/public.php/webdav/']); + + $this->provider->addResourceType($resource); + + // Adding a public key to the ocm discovery + try { + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + /** + * @experimental 31.0.0 + * @psalm-suppress UndefinedInterfaceMethod + */ + $this->provider->setSignatory($this->ocmSignatoryManager->getLocalSignatory()); + } else { + $this->logger->debug('ocm public key feature disabled'); + } + } catch (SignatoryException|IdentityNotFoundException $e) { + $this->logger->warning('cannot generate local signatory', ['exception' => $e]); + } + + return ['ocm' => $this->provider->jsonSerialize()]; + } +} diff --git a/apps/cloud_federation_api/lib/Config.php b/apps/cloud_federation_api/lib/Config.php new file mode 100644 index 00000000000..23788c26dc9 --- /dev/null +++ b/apps/cloud_federation_api/lib/Config.php @@ -0,0 +1,42 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\CloudFederationAPI; + +use OCP\Federation\ICloudFederationProviderManager; +use Psr\Log\LoggerInterface; + +/** + * Class config + * + * handles all the config parameters + * + * @package OCA\CloudFederationAPI + */ +class Config { + + public function __construct( + private ICloudFederationProviderManager $cloudFederationProviderManager, + private LoggerInterface $logger, + ) { + } + + /** + * get a list of supported share types + * + * @param string $resourceType + * @return array + */ + public function getSupportedShareTypes($resourceType) { + try { + $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); + return $provider->getSupportedShareTypes(); + } catch (\Exception $e) { + $this->logger->error('Failed to create federation provider', ['exception' => $e]); + return []; + } + } +} diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php new file mode 100644 index 00000000000..a76b1884a0b --- /dev/null +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -0,0 +1,574 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI\Controller; + +use NCU\Federation\ISignedCloudFederationProvider; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\ISignatureManager; +use OC\OCM\OCMSignatoryManager; +use OCA\CloudFederationAPI\Config; +use OCA\CloudFederationAPI\Db\FederatedInviteMapper; +use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; +use OCA\CloudFederationAPI\ResponseDefinitions; +use OCA\FederatedFileSharing\AddressHandler; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +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\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\Exceptions\ActionNotSupportedException; +use OCP\Federation\Exceptions\AuthenticationFailedException; +use OCP\Federation\Exceptions\BadRequestException; +use OCP\Federation\Exceptions\ProviderCouldNotAddShareException; +use OCP\Federation\Exceptions\ProviderDoesNotExistsException; +use OCP\Federation\ICloudFederationFactory; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\Federation\ICloudIdManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** + * Open-Cloud-Mesh-API + * + * @package OCA\CloudFederationAPI\Controller + * + * @psalm-import-type CloudFederationAPIAddShare from ResponseDefinitions + * @psalm-import-type CloudFederationAPIValidationError from ResponseDefinitions + * @psalm-import-type CloudFederationAPIError from ResponseDefinitions + */ +#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)] +class RequestHandlerController extends Controller { + public function __construct( + string $appName, + IRequest $request, + private LoggerInterface $logger, + private IUserManager $userManager, + private IGroupManager $groupManager, + private IURLGenerator $urlGenerator, + private ICloudFederationProviderManager $cloudFederationProviderManager, + private Config $config, + private IEventDispatcher $dispatcher, + private FederatedInviteMapper $federatedInviteMapper, + private readonly AddressHandler $addressHandler, + private readonly IAppConfig $appConfig, + private ICloudFederationFactory $factory, + private ICloudIdManager $cloudIdManager, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private ITimeFactory $timeFactory, + ) { + parent::__construct($appName, $request); + } + + /** + * Add share + * + * @param string $shareWith The user who the share will be shared with + * @param string $name The resource name (e.g. document.odt) + * @param string|null $description Share description + * @param string $providerId Resource UID on the provider side + * @param string $owner Provider specific UID of the user who owns the resource + * @param string|null $ownerDisplayName Display name of the user who shared the item + * @param string|null $sharedBy Provider specific UID of the user who shared the resource + * @param string|null $sharedByDisplayName Display name of the user who shared the resource + * @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]] + * @param string $shareType 'group' or 'user' share + * @param string $resourceType 'file', 'calendar',... + * + * @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}> + * + * 201: The notification was successfully received. The display name of the recipient might be returned in the body + * 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing + * 501: Share type or the resource type is not supported + */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'receiveFederatedShare')] + public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) { + try { + // if request is signed and well signed, no exception are thrown + // if request is not signed and host is known for not supporting signed request, no exception are thrown + $signedRequest = $this->getSignedRequest(); + $this->confirmSignedOrigin($signedRequest, 'owner', $owner); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + + // check if all required parameters are set + if ( + $shareWith === null + || $name === null + || $providerId === null + || $resourceType === null + || $shareType === null + || !is_array($protocol) + || !isset($protocol['name']) + || !isset($protocol['options']) + || !is_array($protocol['options']) + || !isset($protocol['options']['sharedSecret']) + ) { + return new JSONResponse( + [ + 'message' => 'Missing arguments', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + + $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType); + if (!in_array($shareType, $supportedShareTypes)) { + return new JSONResponse( + ['message' => 'Share type "' . $shareType . '" not implemented'], + Http::STATUS_NOT_IMPLEMENTED + ); + } + + $cloudId = $this->cloudIdManager->resolveCloudId($shareWith); + $shareWith = $cloudId->getUser(); + + if ($shareType === 'user') { + $shareWith = $this->mapUid($shareWith); + + if (!$this->userManager->userExists($shareWith)) { + $response = new JSONResponse( + [ + 'message' => 'User "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + $response->throttle(); + return $response; + } + } + + if ($shareType === 'group') { + if (!$this->groupManager->groupExists($shareWith)) { + $response = new JSONResponse( + [ + 'message' => 'Group "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + $response->throttle(); + return $response; + } + } + + // if no explicit display name is given, we use the uid as display name + $ownerDisplayName = $ownerDisplayName === null ? $owner : $ownerDisplayName; + $sharedByDisplayName = $sharedByDisplayName === null ? $sharedBy : $sharedByDisplayName; + + // sharedBy* parameter is optional, if nothing is set we assume that it is the same user as the owner + if ($sharedBy === null) { + $sharedBy = $owner; + $sharedByDisplayName = $ownerDisplayName; + } + + try { + $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); + $share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); + $share->setProtocol($protocol); + $provider->shareReceived($share); + } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) { + return new JSONResponse( + ['message' => $e->getMessage()], + Http::STATUS_NOT_IMPLEMENTED + ); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse( + [ + 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + + $responseData = ['recipientDisplayName' => '']; + if ($shareType === 'user') { + $user = $this->userManager->get($shareWith); + if ($user) { + $responseData = [ + 'recipientDisplayName' => $user->getDisplayName(), + 'recipientUserId' => $user->getUID(), + ]; + } + } + + return new JSONResponse($responseData, Http::STATUS_CREATED); + } + + /** + * Inform the sender that an invitation was accepted to start sharing + * + * Inform about an accepted invitation so the user on the sender provider's side + * can initiate the OCM share creation. To protect the identity of the parties, + * for shares created following an OCM invitation, the user id MAY be hashed, + * and recipients implementing the OCM invitation workflow MAY refuse to process + * shares coming from unknown parties. + * @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post + * + * @param string $recipientProvider The address of the recipent's provider + * @param string $token The token used for the invitation + * @param string $userId The userId of the recipient at the recipient's provider + * @param string $email The email address of the recipient + * @param string $name The display name of the recipient + * + * @return JSONResponse<Http::STATUS_OK, array{userID: string, email: string, name: string}, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT, array{message: string, error: true}, array{}> + * + * Note: Not implementing 404 Invitation token does not exist, instead using 400 + * 200: Invitation accepted + * 400: Invalid token + * 403: Invitation token does not exist + * 409: User is already known by the OCM provider + */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'inviteAccepted')] + public function inviteAccepted(string $recipientProvider, string $token, string $userId, string $email, string $name): JSONResponse { + $this->logger->debug('Processing share invitation for ' . $userId . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name); + + $updated = $this->timeFactory->getTime(); + + if ($token === '') { + $response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + try { + $invitation = $this->federatedInviteMapper->findByToken($token); + } catch (DoesNotExistException) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + + if ($invitation->isAccepted() === true) { + $response = ['message' => 'Invite already accepted', 'error' => true]; + $status = Http::STATUS_CONFLICT; + return new JSONResponse($response, $status); + } + + if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) { + $response = ['message' => 'Invitation expired', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + return new JSONResponse($response, $status); + } + $localUser = $this->userManager->get($invitation->getUserId()); + if ($localUser === null) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + + $sharedFromEmail = $localUser->getEMailAddress(); + if ($sharedFromEmail === null) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + $sharedFromDisplayName = $localUser->getDisplayName(); + + $response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName]; + $status = Http::STATUS_OK; + + $invitation->setAccepted(true); + $invitation->setRecipientEmail($email); + $invitation->setRecipientName($name); + $invitation->setRecipientProvider($recipientProvider); + $invitation->setRecipientUserId($userId); + $invitation->setAcceptedAt($updated); + $invitation = $this->federatedInviteMapper->update($invitation); + + $event = new FederatedInviteAcceptedEvent($invitation); + $this->dispatcher->dispatchTyped($event); + + return new JSONResponse($response, $status); + } + + /** + * Send a notification about an existing share + * + * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED + * @param string $resourceType calendar, file, contact,... + * @param string|null $providerId ID of the share + * @param array<string, mixed>|null $notification The actual payload of the notification + * + * @return JSONResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}> + * + * 201: The notification was successfully received + * 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing + * 403: Getting resource is not allowed + * 501: The resource type is not supported + */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'receiveFederatedShareNotification')] + public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) { + // check if all required parameters are set + if ( + $notificationType === null + || $resourceType === null + || $providerId === null + || !is_array($notification) + ) { + return new JSONResponse( + [ + 'message' => 'Missing arguments', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + + try { + // if request is signed and well signed, no exception are thrown + // if request is not signed and host is known for not supporting signed request, no exception are thrown + $signedRequest = $this->getSignedRequest(); + $this->confirmNotificationIdentity($signedRequest, $resourceType, $notification); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + + try { + $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); + $result = $provider->notificationReceived($notificationType, $providerId, $notification); + } catch (ProviderDoesNotExistsException $e) { + return new JSONResponse( + [ + 'message' => $e->getMessage(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } catch (ShareNotFound $e) { + $response = new JSONResponse( + [ + 'message' => $e->getMessage(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + $response->throttle(); + return $response; + } catch (ActionNotSupportedException $e) { + return new JSONResponse( + ['message' => $e->getMessage()], + Http::STATUS_NOT_IMPLEMENTED + ); + } catch (BadRequestException $e) { + return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST); + } catch (AuthenticationFailedException $e) { + $response = new JSONResponse(['message' => 'RESOURCE_NOT_FOUND'], Http::STATUS_FORBIDDEN); + $response->throttle(); + return $response; + } catch (\Exception $e) { + $this->logger->warning('incoming notification exception', ['exception' => $e]); + return new JSONResponse( + [ + 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(), + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse($result, Http::STATUS_CREATED); + } + + /** + * map login name to internal LDAP UID if a LDAP backend is in use + * + * @param string $uid + * @return string mixed + */ + private function mapUid($uid) { + // FIXME this should be a method in the user management instead + $this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]); + Util::emitHook( + '\OCA\Files_Sharing\API\Server2Server', + 'preLoginNameUsedAsUserName', + ['uid' => &$uid] + ); + $this->logger->debug('shareWith after, ' . $uid, ['app' => $this->appName]); + + return $uid; + } + + + /** + * returns signed request if available. + * throw an exception: + * - if request is signed, but wrongly signed + * - if request is not signed but instance is configured to only accept signed ocm request + * + * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request + * @throws IncomingRequestException + */ + private function getSignedRequest(): ?IIncomingSignedRequest { + try { + $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager); + $this->logger->debug('signed request available', ['signedRequest' => $signedRequest]); + return $signedRequest; + } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { + $this->logger->debug('remote does not support signed request', ['exception' => $e]); + // remote does not support signed request. + // currently we still accept unsigned request until lazy appconfig + // core.enforce_signed_ocm_request is set to true (default: false) + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { + $this->logger->notice('ignored unsigned request', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request'); + } + } catch (SignatureException $e) { + $this->logger->warning('wrongly signed request', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature'); + } + return null; + } + + + /** + * confirm that the value related to $key entry from the payload is in format userid@hostname + * and compare hostname with the origin of the signed request. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $key entry from data available in data + * @param string $value value itself used in case request is not signed + * + * @throws IncomingRequestException + */ + private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void { + if ($signedRequest === null) { + $instance = $this->getHostFromFederationId($value); + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } + + $body = json_decode($signedRequest->getBody(), true) ?? []; + $entry = trim($body[$key] ?? '', '@'); + if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']'); + } + } + + /** + * confirm identity of the remote instance on notification, based on the share token. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $resourceType + * @param string $sharedSecret + * + * @throws IncomingRequestException + * @throws BadRequestException + */ + private function confirmNotificationIdentity( + ?IIncomingSignedRequest $signedRequest, + string $resourceType, + array $notification, + ): void { + $sharedSecret = $notification['sharedSecret'] ?? ''; + if ($sharedSecret === '') { + throw new BadRequestException(['sharedSecret']); + } + + try { + $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); + if ($provider instanceof ISignedCloudFederationProvider) { + $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification); + } else { + $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]); + return; + } + } catch (\Exception $e) { + throw new IncomingRequestException($e->getMessage()); + } + + $this->confirmNotificationEntry($signedRequest, $identity); + } + + + /** + * @param IIncomingSignedRequest|null $signedRequest + * @param string $entry + * + * @return void + * @throws IncomingRequestException + */ + private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void { + $instance = $this->getHostFromFederationId($entry); + if ($signedRequest === null) { + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } elseif ($instance !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin()); + } + } + + /** + * @param string $entry + * @return string + * @throws IncomingRequestException + */ + private function getHostFromFederationId(string $entry): string { + if (!str_contains($entry, '@')) { + throw new IncomingRequestException('entry ' . $entry . ' does not contain @'); + } + $rightPart = substr($entry, strrpos($entry, '@') + 1); + + // in case the full scheme is sent; getting rid of it + $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart); + try { + return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart); + } catch (IdentityNotFoundException) { + throw new IncomingRequestException('invalid host within federation id: ' . $entry); + } + } +} diff --git a/apps/cloud_federation_api/lib/Db/FederatedInvite.php b/apps/cloud_federation_api/lib/Db/FederatedInvite.php new file mode 100644 index 00000000000..b2447ff4e23 --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInvite.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI\Db; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method bool isAccepted() + * @method void setAccepted(bool $accepted) + * @method int|null getAcceptedAt() + * @method void setAcceptedAt(int $acceptedAt) + * @method int|null getCreatedAt() + * @method void setCreatedAt(int $createdAt) + * @method int|null getExpiredAt() + * @method void setExpiredAt(int $expiredAt) + * @method string|null getRecipientEmail() + * @method void setRecipientEmail(string $recipientEmail) + * @method string|null getRecipientName() + * @method void setRecipientName(string $recipientName) + * @method string|null getRecipientProvider() + * @method void setRecipientProvider(string $recipientProvider) + * @method string|null getRecipientUserId() + * @method void setRecipientUserId(string $recipientUserId) + * @method string getToken() + * @method void setToken(string $token) + * @method string|null getUserId() + * @method void setUserId(string $userId) + */ + +class FederatedInvite extends Entity { + protected bool $accepted = false; + protected ?int $acceptedAt = 0; + protected int $createdAt = 0; + protected ?int $expiredAt = 0; + protected ?string $recipientEmail = null; + protected ?string $recipientName = null; + protected ?string $recipientProvider = null; + protected ?string $recipientUserId = null; + protected string $token = ''; + protected string $userId = ''; + + public function __construct() { + $this->addType('accepted', Types::BOOLEAN); + $this->addType('acceptedAt', Types::BIGINT); + $this->addType('createdAt', Types::BIGINT); + $this->addType('expiredAt', Types::BIGINT); + $this->addType('recipientEmail', Types::STRING); + $this->addType('recipientName', Types::STRING); + $this->addType('recipientProvider', Types::STRING); + $this->addType('recipientUserId', Types::STRING); + $this->addType('token', Types::STRING); + $this->addType('userId', Types::STRING); + } +} diff --git a/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php new file mode 100644 index 00000000000..5feb08b2c7f --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<FederatedInvite> + */ +class FederatedInviteMapper extends QBMapper { + public const TABLE_NAME = 'federated_invites'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, self::TABLE_NAME); + } + + public function findByToken(string $token): FederatedInvite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('federated_invites') + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))); + return $this->findEntity($qb); + } + +} diff --git a/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php new file mode 100644 index 00000000000..c4d079d083e --- /dev/null +++ b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\CloudFederationAPI\Events; + +use OCA\CloudFederationAPI\Db\FederatedInvite; +use OCP\EventDispatcher\Event; + +class FederatedInviteAcceptedEvent extends Event { + public function __construct( + private FederatedInvite $invitation, + ) { + parent::__construct(); + } + + public function getInvitation(): FederatedInvite { + return $this->invitation; + } +} diff --git a/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php new file mode 100644 index 00000000000..a3523d45e38 --- /dev/null +++ b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1016Date202502262004 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(); + $table_name = 'federated_invites'; + + if (!$schema->hasTable($table_name)) { + $table = $schema->createTable($table_name); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + + ]); + // https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers + // We use the least common denominator, the minimum length supported by browsers + $table->addColumn('recipient_provider', Types::STRING, [ + 'notnull' => false, + 'length' => 2083, + ]); + $table->addColumn('recipient_user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + $table->addColumn('recipient_name', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + // https://www.directedignorance.com/blog/maximum-length-of-email-address + $table->addColumn('recipient_email', Types::STRING, [ + 'notnull' => false, + 'length' => 320, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 60, + ]); + $table->addColumn('accepted', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + ]); + + $table->addColumn('expired_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addColumn('accepted_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addUniqueConstraint(['token']); + $table->setPrimaryKey(['id']); + return $schema; + } + + return null; + } +} diff --git a/apps/cloud_federation_api/lib/ResponseDefinitions.php b/apps/cloud_federation_api/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..b17f5aadc1d --- /dev/null +++ b/apps/cloud_federation_api/lib/ResponseDefinitions.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\CloudFederationAPI; + +/** + * @psalm-type CloudFederationAPIAddShare = array{ + * recipientDisplayName: string, + * recipientUserId?: string, + * } + * + * @psalm-type CloudFederationAPIError = array{ + * message: string, + * } + * + * @psalm-type CloudFederationAPIValidationError = CloudFederationAPIError&array{ + * validationErrors: list<array{ + * name: string, + * message: string|null, + * }>, + * } + */ +class ResponseDefinitions { +} |