diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index b84d9139a148a..44a40a6951ff2 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -8,6 +8,7 @@ namespace OCA\CloudFederationAPI\Controller; use OC\AppFramework\Http\Attributes\FederationRateLimit; +use OC\Federation\SenderDomainAwareCloudFederationNotification; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\ResponseDefinitions; @@ -270,21 +271,27 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ * 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 $resourceType calendar, file, contact,... * @param string|null $providerId ID of the share * @param array|null $notification The actual payload of the notification + * @param string|null $senderDomain Domain of the sending server + * @param string|null $shareType Type of the recipient the notification is about, e.g. user * * @return JSONResponse, array{}>|JSONResponse|JSONResponse * * 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 + * 501: The resource type or the notification type is not supported */ #[NoCSRFRequired] #[PublicPage] #[BruteForceProtection(action: 'receiveFederatedShareNotification')] - public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) { + public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification, $senderDomain, $shareType) { + if ($senderDomain !== null) { + return $this->receiveSenderDomainNotification($notificationType, $resourceType, $providerId, $notification, $senderDomain, $shareType); + } + // check if all required parameters are set if ( $notificationType === null @@ -317,6 +324,73 @@ public function receiveNotification($notificationType, $resourceType, $providerI try { $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); $result = $provider->notificationReceived($notificationType, $providerId, $notification); + } catch (\Exception $e) { + return $this->notificationExceptionResponse($e); + } + + try { + $notificationObject = new SenderDomainAwareCloudFederationNotification(null, null); + $notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification); + $notificationEvent = new OCMNotificationReceivedEvent($notificationObject); + $this->dispatcher->dispatchTyped($notificationEvent); + } catch (\Exception $e) { + $this->logger->warning('error while dispatching OCM notification received event', ['exception' => $e]); + } + + return new JSONResponse($result, Http::STATUS_CREATED); + } + + /** + * Send a notification with a senderDomain field to listeners of {@see OCMNotificationReceivedEvent} + * + * @param array|null $notification + * @return JSONResponse, array{}>|JSONResponse|JSONResponse + */ + private function receiveSenderDomainNotification(?string $notificationType, ?string $resourceType, ?string $providerId, ?array $notification, string $senderDomain, ?string $shareType): JSONResponse { + // check if all required parameters are set + if ( + $notificationType === null + || $senderDomain === '' + || ($resourceType === null && $shareType === null) + ) { + return new JSONResponse( + [ + 'message' => 'Missing arguments', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + + try { + $this->ocmDiscoveryService->getIncomingSignedRequestFromDomain($senderDomain); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + + $notificationObject = new SenderDomainAwareCloudFederationNotification($senderDomain, $shareType); + $notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification ?? []); + $notificationEvent = new OCMNotificationReceivedEvent($notificationObject); + + try { + $this->dispatcher->dispatchTyped($notificationEvent); + $result = $notificationEvent->getResult() ?? throw new ActionNotSupportedException($notificationType); + } catch (\Exception $e) { + return $this->notificationExceptionResponse($e); + } + + return new JSONResponse($result, Http::STATUS_CREATED); + } + + /** + * Map an exception thrown while handling a notification to its response + * + * @return JSONResponse|JSONResponse + */ + private function notificationExceptionResponse(\Exception $e): JSONResponse { + try { + throw $e; } catch (ProviderDoesNotExistsException $e) { return new JSONResponse( [ @@ -356,17 +430,6 @@ public function receiveNotification($notificationType, $resourceType, $providerI Http::STATUS_BAD_REQUEST ); } - - try { - $notificationObject = $this->factory->getCloudFederationNotification(); - $notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification); - $notificationEvent = new OCMNotificationReceivedEvent($notificationObject); - $this->dispatcher->dispatchTyped($notificationEvent); - } catch (\Exception $e) { - $this->logger->warning('error while dispatching OCM notification received event', ['exception' => $e]); - } - - return new JSONResponse($result, Http::STATUS_CREATED); } /** diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index fffbefe54162b..a50ffdca44e15 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -244,8 +244,7 @@ "schema": { "type": "object", "required": [ - "notificationType", - "resourceType" + "notificationType" ], "properties": { "notificationType": { @@ -254,6 +253,7 @@ }, "resourceType": { "type": "string", + "nullable": true, "description": "calendar, file, contact,..." }, "providerId": { @@ -268,6 +268,16 @@ "additionalProperties": { "type": "object" } + }, + "senderDomain": { + "type": "string", + "nullable": true, + "description": "Domain of the sending server" + }, + "shareType": { + "type": "string", + "nullable": true, + "description": "Type of the recipient the notification is about, e.g. user" } } } @@ -309,7 +319,7 @@ } }, "501": { - "description": "The resource type is not supported", + "description": "The resource type or the notification type is not supported", "content": { "application/json": { "schema": { diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index ed0949d9cd9e4..cda2ee7667190 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -469,6 +469,7 @@ 'OCP\\Federation\\ICloudId' => $baseDir . '/lib/public/Federation/ICloudId.php', 'OCP\\Federation\\ICloudIdManager' => $baseDir . '/lib/public/Federation/ICloudIdManager.php', 'OCP\\Federation\\ICloudIdResolver' => $baseDir . '/lib/public/Federation/ICloudIdResolver.php', + 'OCP\\Federation\\ISenderDomainAwareCloudFederationNotification' => $baseDir . '/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php', 'OCP\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/public/Federation/ISignedCloudFederationProvider.php', 'OCP\\Federation\\IValidationAwareCloudFederationProvider' => $baseDir . '/lib/public/Federation/IValidationAwareCloudFederationProvider.php', 'OCP\\Files' => $baseDir . '/lib/public/Files.php', @@ -1896,6 +1897,7 @@ 'OC\\Federation\\CloudFederationShare' => $baseDir . '/lib/private/Federation/CloudFederationShare.php', 'OC\\Federation\\CloudId' => $baseDir . '/lib/private/Federation/CloudId.php', 'OC\\Federation\\CloudIdManager' => $baseDir . '/lib/private/Federation/CloudIdManager.php', + 'OC\\Federation\\SenderDomainAwareCloudFederationNotification' => $baseDir . '/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php', 'OC\\FilesMetadata\\FilesMetadataManager' => $baseDir . '/lib/private/FilesMetadata/FilesMetadataManager.php', 'OC\\FilesMetadata\\Job\\UpdateSingleMetadata' => $baseDir . '/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php', 'OC\\FilesMetadata\\Listener\\MetadataDelete' => $baseDir . '/lib/private/FilesMetadata/Listener/MetadataDelete.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 42edbbffa04ab..19ac573e13343 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -510,6 +510,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Federation\\ICloudId' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudId.php', 'OCP\\Federation\\ICloudIdManager' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudIdManager.php', 'OCP\\Federation\\ICloudIdResolver' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudIdResolver.php', + 'OCP\\Federation\\ISenderDomainAwareCloudFederationNotification' => __DIR__ . '/../../..' . '/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php', 'OCP\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/public/Federation/ISignedCloudFederationProvider.php', 'OCP\\Federation\\IValidationAwareCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/public/Federation/IValidationAwareCloudFederationProvider.php', 'OCP\\Files' => __DIR__ . '/../../..' . '/lib/public/Files.php', @@ -1937,6 +1938,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Federation\\CloudFederationShare' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudFederationShare.php', 'OC\\Federation\\CloudId' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudId.php', 'OC\\Federation\\CloudIdManager' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudIdManager.php', + 'OC\\Federation\\SenderDomainAwareCloudFederationNotification' => __DIR__ . '/../../..' . '/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php', 'OC\\FilesMetadata\\FilesMetadataManager' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/FilesMetadataManager.php', 'OC\\FilesMetadata\\Job\\UpdateSingleMetadata' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php', 'OC\\FilesMetadata\\Listener\\MetadataDelete' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/Listener/MetadataDelete.php', diff --git a/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php b/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php new file mode 100644 index 0000000000000..cc97917592d2d --- /dev/null +++ b/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php @@ -0,0 +1,51 @@ +getMessage()['notificationType'] ?? ''); + } + + #[\Override] + public function getSenderDomain(): ?string { + return $this->senderDomain; + } + + #[\Override] + public function getResourceType(): ?string { + return $this->getMessage()['resourceType'] ?? null; + } + + #[\Override] + public function getShareType(): ?string { + return $this->shareType; + } + + #[\Override] + public function getProviderId(): ?string { + $providerId = $this->getMessage()['providerId'] ?? ''; + return $providerId === '' ? null : $providerId; + } + + #[\Override] + public function getNotification(): array { + return $this->getMessage()['notification'] ?? []; + } +} diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 996ceaa22b553..1e18de6fc267c 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -212,7 +212,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { try { // http-sig advertisement requires a jwksUri $provider->setJwksUri($this->signatoryManager->getLocalJwksUri()); - $provider->setCapabilities(['http-sig']); + $provider->setCapabilities(['http-sig', 'notification-sender-domain']); } catch (IdentityNotFoundException $e) { $this->logger->warning('cannot build local jwksUri, http-sig capability not advertised', ['exception' => $e]); } @@ -290,6 +290,40 @@ public function getIncomingSignedRequest(?string $ocmAddress = null): ?IIncoming return null; } + /** + * @inheritDoc + * + * @throws IncomingRequestException + * @since 36.0.0 + */ + #[\Override] + public function getIncomingSignedRequestFromDomain(string $senderDomain): IIncomingSignedRequest { + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + throw new IncomingRequestException('Signed requests are disabled'); + } + + try { + $origin = $this->signatureManager->extractIdentityFromUri('https://' . $senderDomain); + $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager, null, $origin); + } catch (IdentityNotFoundException $e) { + throw new IncomingRequestException('invalid sender domain: ' . $senderDomain, previous: $e); + } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { + $this->logger->debug('unsigned request from sender domain', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request', previous: $e); + } catch (SignatureException $e) { + $this->logger->warning('wrongly signed request', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature', previous: $e); + } + + if ($signedRequest->getOrigin() !== $origin) { + throw new IncomingRequestException( + 'claimed origin ' . $origin . ' does not match signed origin ' . $signedRequest->getOrigin() + ); + } + + return $signedRequest; + } + /** * @inheritDoc * diff --git a/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php b/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php new file mode 100644 index 0000000000000..5add67f6e5b1f --- /dev/null +++ b/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php @@ -0,0 +1,66 @@ + + * @since 36.0.0 + */ + public function getNotification(): array; +} diff --git a/lib/public/OCM/Events/OCMNotificationReceivedEvent.php b/lib/public/OCM/Events/OCMNotificationReceivedEvent.php index 517ddf65b0922..e4fc58965a6e6 100644 --- a/lib/public/OCM/Events/OCMNotificationReceivedEvent.php +++ b/lib/public/OCM/Events/OCMNotificationReceivedEvent.php @@ -11,12 +11,16 @@ use OCP\AppFramework\Attribute\Listenable; use OCP\EventDispatcher\Event; use OCP\Federation\ICloudFederationNotification; +use OCP\Federation\ISenderDomainAwareCloudFederationNotification; /** * @since 35.0.0 */ #[Listenable(since: '35.0.0')] class OCMNotificationReceivedEvent extends Event { + /** @var array|null */ + private ?array $result = null; + /** * @since 35.0.0 */ @@ -27,9 +31,32 @@ public function __construct( } /** + * Notifications received on the OCM notifications endpoint are + * {@see ISenderDomainAwareCloudFederationNotification} + * * @since 35.0.0 */ public function getNotification(): ICloudFederationNotification { return $this->notification; } + + /** + * Set the data sent back to the sender. Only used for notifications with a + * senderDomain field, which are handled by listeners instead of a cloud + * federation provider. + * + * @param array $result + * @since 36.0.0 + */ + public function setResult(array $result): void { + $this->result = $result; + } + + /** + * @return array|null null if no listener handled the notification + * @since 36.0.0 + */ + public function getResult(): ?array { + return $this->result; + } } diff --git a/lib/public/OCM/IOCMDiscoveryService.php b/lib/public/OCM/IOCMDiscoveryService.php index 92c705be41d2f..4118217b4c70b 100644 --- a/lib/public/OCM/IOCMDiscoveryService.php +++ b/lib/public/OCM/IOCMDiscoveryService.php @@ -68,6 +68,21 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider; */ public function getIncomingSignedRequest(?string $ocmAddress = null): ?IIncomingSignedRequest; + /** + * Returns incoming request signed by $senderDomain. + * + * throw an exception: + * - if signed requests are disabled + * - if the request is not signed + * - if the request is signed, but not by $senderDomain + * + * @param string $senderDomain domain of the sending server + * + * @throws IncomingRequestException + * @since 36.0.0 + */ + public function getIncomingSignedRequestFromDomain(string $senderDomain): IIncomingSignedRequest; + /** * Confirm that the host portion of $ocmAddress matches $signedOrigin * under the current local signing policy. diff --git a/lib/public/OCM/OCMCapabilities.php b/lib/public/OCM/OCMCapabilities.php index 5de021da0acdb..75c4cdb214380 100644 --- a/lib/public/OCM/OCMCapabilities.php +++ b/lib/public/OCM/OCMCapabilities.php @@ -98,6 +98,15 @@ public function hasExchangeToken(): bool { return $this->has('exchange-token'); } + /** + * Whether the peer accepts notifications with a senderDomain field. + * + * @since 36.0.0 + */ + public function hasNotificationSenderDomain(): bool { + return $this->has('notification-sender-domain'); + } + /** * Raw list of advertised capability strings (normalized). * diff --git a/openapi.json b/openapi.json index 6c26d3ac0f2ce..baca071770d7f 100644 --- a/openapi.json +++ b/openapi.json @@ -19485,8 +19485,7 @@ "schema": { "type": "object", "required": [ - "notificationType", - "resourceType" + "notificationType" ], "properties": { "notificationType": { @@ -19495,6 +19494,7 @@ }, "resourceType": { "type": "string", + "nullable": true, "description": "calendar, file, contact,..." }, "providerId": { @@ -19509,6 +19509,16 @@ "additionalProperties": { "type": "object" } + }, + "senderDomain": { + "type": "string", + "nullable": true, + "description": "Domain of the sending server" + }, + "shareType": { + "type": "string", + "nullable": true, + "description": "Type of the recipient the notification is about, e.g. user" } } } @@ -19550,7 +19560,7 @@ } }, "501": { - "description": "The resource type is not supported", + "description": "The resource type or the notification type is not supported", "content": { "application/json": { "schema": {