diff --git a/apps/cloud_federation_api/appinfo/routes.php b/apps/cloud_federation_api/appinfo/routes.php index 6467005e21bc3..cd681ae98e7f2 100644 --- a/apps/cloud_federation_api/appinfo/routes.php +++ b/apps/cloud_federation_api/appinfo/routes.php @@ -25,6 +25,15 @@ 'url' => '/invite-accepted', 'verb' => 'POST', 'root' => '/ocm', - ] + ], + + // needs to be kept at the bottom of the list + [ + 'name' => 'OCMRequest#manageOCMRequests', + 'url' => '/{ocmPath}', + 'requirements' => ['ocmPath' => '.*'], + 'verb' => ['GET', 'POST', 'PUT', 'DELETE'], + 'root' => '/ocm', + ], ], ]; diff --git a/apps/cloud_federation_api/composer/composer/autoload_classmap.php b/apps/cloud_federation_api/composer/composer/autoload_classmap.php index 3cadc540c882d..5441bfc832535 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_classmap.php +++ b/apps/cloud_federation_api/composer/composer/autoload_classmap.php @@ -10,6 +10,7 @@ 'OCA\\CloudFederationAPI\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', 'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php', 'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php', + 'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => $baseDir . '/../lib/Controller/OCMRequestController.php', 'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => $baseDir . '/../lib/Controller/RequestHandlerController.php', 'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php', 'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php', diff --git a/apps/cloud_federation_api/composer/composer/autoload_static.php b/apps/cloud_federation_api/composer/composer/autoload_static.php index 7c4980bbc496b..6d192f98e5138 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_static.php +++ b/apps/cloud_federation_api/composer/composer/autoload_static.php @@ -25,6 +25,7 @@ class ComposerStaticInitCloudFederationAPI 'OCA\\CloudFederationAPI\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', 'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php', 'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php', + 'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => __DIR__ . '/..' . '/../lib/Controller/OCMRequestController.php', 'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => __DIR__ . '/..' . '/../lib/Controller/RequestHandlerController.php', 'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php', 'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php', diff --git a/apps/cloud_federation_api/lib/Controller/OCMRequestController.php b/apps/cloud_federation_api/lib/Controller/OCMRequestController.php new file mode 100644 index 0000000000000..90d10df2c2bde --- /dev/null +++ b/apps/cloud_federation_api/lib/Controller/OCMRequestController.php @@ -0,0 +1,88 @@ +ocmDiscoveryService->getIncomingSignedRequest(); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming ocm request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + + // assuming that ocm request contains a json array + $payload = $signedRequest?->getBody() ?? file_get_contents('php://input'); + try { + $payload = ($payload) ? json_decode($payload, true, 512, JSON_THROW_ON_ERROR) : null; + } catch (JsonException $e) { + $this->logger->debug('json decode error', ['exception' => $e]); + $payload = null; + } + + $event = new OCMEndpointRequestEvent( + $this->request->getMethod(), + preg_replace('@/+@', '/', $ocmPath), + $payload, + $signedRequest?->getOrigin() + ); + $this->eventDispatcher->dispatchTyped($event); + + return $event->getResponse() ?? new DataResponse('', Http::STATUS_NOT_FOUND); + } +} diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index a9aa1aae0063e..2f0f9f667aa69 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -11,8 +11,6 @@ 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; @@ -44,6 +42,7 @@ use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\OCM\IOCMDiscoveryService; use OCP\Share\Exceptions\ShareNotFound; use OCP\Util; use Psr\Log\LoggerInterface; @@ -74,8 +73,8 @@ public function __construct( private readonly IAppConfig $appConfig, private ICloudFederationFactory $factory, private ICloudIdManager $cloudIdManager, + private readonly IOCMDiscoveryService $ocmDiscoveryService, private readonly ISignatureManager $signatureManager, - private readonly OCMSignatoryManager $signatoryManager, private ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); @@ -108,9 +107,9 @@ public function __construct( public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) { if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { try { - // if request is signed and well signed, no exception are thrown + // if request is signed and well signed, no exceptions are thrown // if request is not signed and host is known for not supporting signed request, no exception are thrown - $signedRequest = $this->getSignedRequest(); + $signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest(); $this->confirmSignedOrigin($signedRequest, 'owner', $owner); } catch (IncomingRequestException $e) { $this->logger->warning('incoming request exception', ['exception' => $e]); @@ -360,7 +359,7 @@ public function receiveNotification($notificationType, $resourceType, $providerI 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(); + $signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest(); $this->confirmNotificationIdentity($signedRequest, $resourceType, $notification); } catch (IncomingRequestException $e) { $this->logger->warning('incoming request exception', ['exception' => $e]); @@ -434,37 +433,6 @@ private function mapUid($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. diff --git a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php index 769e0a2dbffd7..81557119925fd 100644 --- a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php +++ b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php @@ -10,7 +10,6 @@ namespace OCA\CloudFederationApi\Tests; use NCU\Security\Signature\ISignatureManager; -use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Controller\RequestHandlerController; use OCA\CloudFederationAPI\Db\FederatedInvite; @@ -29,6 +28,7 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\OCM\IOCMDiscoveryService; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -45,10 +45,11 @@ class RequestHandlerControllerTest extends TestCase { private FederatedInviteMapper&MockObject $federatedInviteMapper; private AddressHandler&MockObject $addressHandler; private IAppConfig&MockObject $appConfig; + private ICloudFederationFactory&MockObject $cloudFederationFactory; private ICloudIdManager&MockObject $cloudIdManager; + private IOCMDiscoveryService&MockObject $discoveryService; private ISignatureManager&MockObject $signatureManager; - private OCMSignatoryManager&MockObject $signatoryManager; private ITimeFactory&MockObject $timeFactory; private RequestHandlerController $requestHandlerController; @@ -69,8 +70,8 @@ protected function setUp(): void { $this->appConfig = $this->createMock(IAppConfig::class); $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->discoveryService = $this->createMock(IOCMDiscoveryService::class); $this->signatureManager = $this->createMock(ISignatureManager::class); - $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->requestHandlerController = new RequestHandlerController( @@ -88,8 +89,8 @@ protected function setUp(): void { $this->appConfig, $this->cloudFederationFactory, $this->cloudIdManager, + $this->discoveryService, $this->signatureManager, - $this->signatoryManager, $this->timeFactory, ); } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index aca39f64ddba1..102d50effe408 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -718,9 +718,14 @@ 'OCP\\Notification\\InvalidValueException' => $baseDir . '/lib/public/Notification/InvalidValueException.php', 'OCP\\Notification\\NotificationPreloadReason' => $baseDir . '/lib/public/Notification/NotificationPreloadReason.php', 'OCP\\Notification\\UnknownNotificationException' => $baseDir . '/lib/public/Notification/UnknownNotificationException.php', + 'OCP\\OCM\\Enum\\ParamType' => $baseDir . '/lib/public/OCM/Enum/ParamType.php', + 'OCP\\OCM\\Events\\LocalOCMDiscoveryEvent' => $baseDir . '/lib/public/OCM/Events/LocalOCMDiscoveryEvent.php', + 'OCP\\OCM\\Events\\OCMEndpointRequestEvent' => $baseDir . '/lib/public/OCM/Events/OCMEndpointRequestEvent.php', 'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => $baseDir . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php', 'OCP\\OCM\\Exceptions\\OCMArgumentException' => $baseDir . '/lib/public/OCM/Exceptions/OCMArgumentException.php', + 'OCP\\OCM\\Exceptions\\OCMCapabilityException' => $baseDir . '/lib/public/OCM/Exceptions/OCMCapabilityException.php', 'OCP\\OCM\\Exceptions\\OCMProviderException' => $baseDir . '/lib/public/OCM/Exceptions/OCMProviderException.php', + 'OCP\\OCM\\Exceptions\\OCMRequestException' => $baseDir . '/lib/public/OCM/Exceptions/OCMRequestException.php', 'OCP\\OCM\\ICapabilityAwareOCMProvider' => $baseDir . '/lib/public/OCM/ICapabilityAwareOCMProvider.php', 'OCP\\OCM\\IOCMDiscoveryService' => $baseDir . '/lib/public/OCM/IOCMDiscoveryService.php', 'OCP\\OCM\\IOCMProvider' => $baseDir . '/lib/public/OCM/IOCMProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index e486f075eed4f..6eb7705929341 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -759,9 +759,14 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Notification\\InvalidValueException' => __DIR__ . '/../../..' . '/lib/public/Notification/InvalidValueException.php', 'OCP\\Notification\\NotificationPreloadReason' => __DIR__ . '/../../..' . '/lib/public/Notification/NotificationPreloadReason.php', 'OCP\\Notification\\UnknownNotificationException' => __DIR__ . '/../../..' . '/lib/public/Notification/UnknownNotificationException.php', + 'OCP\\OCM\\Enum\\ParamType' => __DIR__ . '/../../..' . '/lib/public/OCM/Enum/ParamType.php', + 'OCP\\OCM\\Events\\LocalOCMDiscoveryEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/LocalOCMDiscoveryEvent.php', + 'OCP\\OCM\\Events\\OCMEndpointRequestEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/OCMEndpointRequestEvent.php', 'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php', 'OCP\\OCM\\Exceptions\\OCMArgumentException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMArgumentException.php', + 'OCP\\OCM\\Exceptions\\OCMCapabilityException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMCapabilityException.php', 'OCP\\OCM\\Exceptions\\OCMProviderException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMProviderException.php', + 'OCP\\OCM\\Exceptions\\OCMRequestException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMRequestException.php', 'OCP\\OCM\\ICapabilityAwareOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/ICapabilityAwareOCMProvider.php', 'OCP\\OCM\\IOCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMDiscoveryService.php', 'OCP\\OCM\\IOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMProvider.php', diff --git a/lib/private/AppFramework/Routing/RouteParser.php b/lib/private/AppFramework/Routing/RouteParser.php index 13907b2a8af52..1470376bfa233 100644 --- a/lib/private/AppFramework/Routing/RouteParser.php +++ b/lib/private/AppFramework/Routing/RouteParser.php @@ -75,7 +75,6 @@ private function processRoute(array $route, string $appName, string $routeNamePr $root = $this->buildRootPrefix($route, $appName, $routeNamePrefix); $url = $root . '/' . ltrim($route['url'], '/'); - $verb = strtoupper($route['verb'] ?? 'GET'); $split = explode('#', $name, 3); if (count($split) !== 2) { @@ -95,7 +94,7 @@ private function processRoute(array $route, string $appName, string $routeNamePr $routeName = strtolower($routeNamePrefix . $appName . '.' . $controller . '.' . $action . $postfix); $routeObject = new Route($url); - $routeObject->method($verb); + $routeObject->method($route['verb'] ?? 'GET'); // optionally register requirements for route. This is used to // tell the route parser how url parameters should be matched @@ -174,7 +173,6 @@ private function processResources(array $resources, string $appName, string $rou $url = $root . '/' . ltrim($config['url'], '/'); $method = $action['name']; - $verb = strtoupper($action['verb'] ?? 'GET'); $collectionAction = $action['on-collection'] ?? false; if (!$collectionAction) { $url .= '/{id}'; @@ -188,7 +186,7 @@ private function processResources(array $resources, string $appName, string $rou $routeName = $routeNamePrefix . $appName . '.' . strtolower($resource) . '.' . $method; $route = new Route($url); - $route->method($verb); + $route->method($action['verb'] ?? 'GET'); $route->defaults(['caller' => [$appName, $controllerName, $actionName]]); diff --git a/lib/private/Federation/CloudFederationProviderManager.php b/lib/private/Federation/CloudFederationProviderManager.php index 81b5d717a56b9..ca5161fd7dba0 100644 --- a/lib/private/Federation/CloudFederationProviderManager.php +++ b/lib/private/Federation/CloudFederationProviderManager.php @@ -23,6 +23,7 @@ use OCP\Http\Client\IResponse; use OCP\IAppConfig; use OCP\IConfig; +use OCP\OCM\Exceptions\OCMCapabilityException; use OCP\OCM\Exceptions\OCMProviderException; use OCP\OCM\IOCMDiscoveryService; use Psr\Log\LoggerInterface; @@ -107,7 +108,7 @@ public function sendShare(ICloudFederationShare $share) { $cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith()); try { try { - $response = $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare())); + $response = $this->postOcmPayload($cloudID->getRemote(), '/shares', $share->getShare()); } catch (OCMProviderException) { return false; } @@ -138,7 +139,7 @@ public function sendCloudShare(ICloudFederationShare $share): IResponse { $cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith()); $client = $this->httpClientService->newClient(); try { - return $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare()), $client); + return $this->postOcmPayload($cloudID->getRemote(), '/shares', $share->getShare(), $client); } catch (\Throwable $e) { $this->logger->error('Error while sending share to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -158,7 +159,7 @@ public function sendCloudShare(ICloudFederationShare $share): IResponse { public function sendNotification($url, ICloudFederationNotification $notification) { try { try { - $response = $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage())); + $response = $this->postOcmPayload($url, '/notifications', $notification->getMessage()); } catch (OCMProviderException) { return false; } @@ -183,7 +184,7 @@ public function sendNotification($url, ICloudFederationNotification $notificatio public function sendCloudNotification(string $url, ICloudFederationNotification $notification): IResponse { $client = $this->httpClientService->newClient(); try { - return $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage()), $client); + return $this->postOcmPayload($url, '/notifications', $notification->getMessage(), $client); } catch (\Throwable $e) { $this->logger->error('Error while sending notification to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -204,51 +205,18 @@ public function isReady() { } /** - * @param string $cloudId - * @param string $uri - * @param string $payload - * - * @return IResponse + * @throws OCMCapabilityException * @throws OCMProviderException */ - private function postOcmPayload(string $cloudId, string $uri, string $payload, ?IClient $client = null): IResponse { - $ocmProvider = $this->discoveryService->discover($cloudId); - $uri = $ocmProvider->getEndPoint() . '/' . ltrim($uri, '/'); - $client = $client ?? $this->httpClientService->newClient(); - return $client->post($uri, $this->prepareOcmPayload($uri, $payload)); - } - - /** - * @param string $uri - * @param string $payload - * - * @return array - */ - private function prepareOcmPayload(string $uri, string $payload): array { - $payload = array_merge($this->getDefaultRequestOptions(), ['body' => $payload]); - - if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) - && $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { - return $payload; - } - - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $payload, - 'post', $uri - ); - } - - return $signedPayload ?? $payload; - } - - private function getDefaultRequestOptions(): array { - return [ - 'headers' => ['content-type' => 'application/json'], - 'timeout' => 10, - 'connect_timeout' => 10, - 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), - ]; + private function postOcmPayload(string $cloudId, string $uri, array $payload, ?IClient $client = null): IResponse { + return $this->discoveryService->requestRemoteOcmEndpoint( + null, + $cloudId, + $uri, + $payload, + 'post', + $client, + ['verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false)], + ); } } diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index 09fe383675bf6..3159f382ad8aa 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -12,13 +12,13 @@ use NCU\Security\Signature\Model\Signatory; use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\Exceptions\OCMProviderException; -use OCP\OCM\ICapabilityAwareOCMProvider; +use OCP\OCM\IOCMProvider; use OCP\OCM\IOCMResource; /** * @since 28.0.0 */ -class OCMProvider implements ICapabilityAwareOCMProvider { +class OCMProvider implements IOCMProvider { private bool $enabled = false; private string $apiVersion = ''; private string $inviteAcceptDialog = ''; @@ -124,12 +124,10 @@ public function getProvider(): string { * @return $this */ public function setCapabilities(array $capabilities): static { - foreach ($capabilities as $value) { - if (!in_array($value, $this->capabilities)) { - array_push($this->capabilities, $value); - } - } - + $this->capabilities = array_unique(array_merge( + $this->capabilities, + array_map([$this, 'normalizeCapability'], $capabilities) + )); return $this; } @@ -139,6 +137,20 @@ public function setCapabilities(array $capabilities): static { public function getCapabilities(): array { return $this->capabilities; } + + /** + * @param string $capability + * @return bool + */ + public function hasCapability(string $capability): bool { + return (in_array($this->normalizeCapability($capability), $this->capabilities, true)); + } + + private function normalizeCapability(string $capability): string { + // since ocm 1.2, removing leading slashes from capabilities + return strtolower(ltrim($capability, '/')); + } + /** * create a new resource to later add it with {@see IOCMProvider::addResourceType()} * @return IOCMResource diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index a939fa6ef29bd..ec8d20d2aee75 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -9,35 +9,48 @@ namespace OC\OCM; +use Exception; use GuzzleHttp\Exception\ConnectException; use JsonException; use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; use NCU\Security\Signature\Exceptions\SignatoryException; +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\Core\AppInfo\ConfigLexicon; use OC\OCM\Model\OCMProvider; +use OCP\AppFramework\Attribute\Consumable; use OCP\AppFramework\Http; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; use OCP\IAppConfig; use OCP\ICache; use OCP\ICacheFactory; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; use OCP\OCM\Events\ResourceTypeRegisterEvent; +use OCP\OCM\Exceptions\OCMCapabilityException; use OCP\OCM\Exceptions\OCMProviderException; -use OCP\OCM\ICapabilityAwareOCMProvider; +use OCP\OCM\Exceptions\OCMRequestException; use OCP\OCM\IOCMDiscoveryService; +use OCP\OCM\IOCMProvider; use Psr\Log\LoggerInterface; /** * @since 28.0.0 */ -class OCMDiscoveryService implements IOCMDiscoveryService { +#[Consumable(since: '28.0.0')] +final class OCMDiscoveryService implements IOCMDiscoveryService { private ICache $cache; public const API_VERSION = '1.1.0'; - - private ?ICapabilityAwareOCMProvider $localProvider = null; - /** @var array */ + private ?IOCMProvider $localProvider = null; + /** @var array */ private array $remoteProviders = []; public function __construct( @@ -47,20 +60,25 @@ public function __construct( protected IConfig $config, private IAppConfig $appConfig, private IURLGenerator $urlGenerator, - private OCMSignatoryManager $ocmSignatoryManager, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, private LoggerInterface $logger, ) { $this->cache = $cacheFactory->createDistributed('ocm-discovery'); } + /** - * @param string $remote - * @param bool $skipCache + * @inheritDoc * - * @return ICapabilityAwareOCMProvider - * @throws OCMProviderException + * @param string $remote address of the remote provider + * @param bool $skipCache ignore cache, refresh data + * + * @return IOCMProvider + * @throws OCMProviderException if no valid discovery data can be returned + * @since 28.0.0 */ - public function discover(string $remote, bool $skipCache = false): ICapabilityAwareOCMProvider { + public function discover(string $remote, bool $skipCache = false): IOCMProvider { $remote = rtrim($remote, '/'); if (!str_starts_with($remote, 'http://') && !str_starts_with($remote, 'https://')) { // if scheme not specified, we test both; @@ -138,7 +156,6 @@ public function discover(string $remote, bool $skipCache = false): ICapabilityAw throw $exception; } - throw new OCMProviderException('invalid remote ocm endpoint'); } catch (JsonException|OCMProviderException) { $this->cache->set($remote, false, 5 * 60); @@ -154,9 +171,15 @@ public function discover(string $remote, bool $skipCache = false): ICapabilityAw } /** - * @return ICapabilityAwareOCMProvider + * @inheritDoc + * + * @param bool $fullDetails complete details, including public keys. + * Set to FALSE for client (capabilities) purpose. + * + * @return IOCMProvider + * @since 33.0.0 */ - public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareOCMProvider { + public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { if ($this->localProvider !== null) { return $this->localProvider; } @@ -176,7 +199,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareO $provider->setEnabled(true); $provider->setApiVersion(self::API_VERSION); $provider->setEndPoint(substr($url, 0, $pos)); - $provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']); + $provider->setCapabilities(['invite-accepted', 'notifications', 'shares']); // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); @@ -198,7 +221,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareO * @experimental 31.0.0 * @psalm-suppress UndefinedInterfaceMethod */ - $provider->setSignatory($this->ocmSignatoryManager->getLocalSignatory()); + $provider->setSignatory($this->signatoryManager->getLocalSignatory()); } else { $this->logger->debug('ocm public key feature disabled'); } @@ -207,6 +230,10 @@ public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareO } } + $event = new LocalOCMDiscoveryEvent($provider); + $this->eventDispatcher->dispatchTyped($event); + + // deprecated since 33.0.0 $event = new ResourceTypeRegisterEvent($provider); $this->eventDispatcher->dispatchTyped($event); @@ -214,4 +241,136 @@ public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareO return $provider; } + /** + * @inheritDoc + * + * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request + * @throws IncomingRequestException + * @since 33.0.0 + */ + public function getIncomingSignedRequest(): ?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; + } + + /** + * @inheritDoc + * + * @param string|null $capability when not NULL, method will throw + * {@see OCMCapabilityException} + * if remote does not support the capability + * @param string $remote remote ocm cloud id + * @param string $ocmSubPath path to reach, complementing the ocm endpoint extracted + * from remote discovery data + * @param array|null $payload payload attached to the request + * @param string $method method to use ('get', 'post', 'put', 'delete') + * @param IClient|null $client NULL to use default {@see IClient} + * @param array|null $options options related to IClient + * @param bool $signed FALSE to not auth the request + * + * @throws OCMCapabilityException if remote does not support $capability + * @throws OCMProviderException if remote ocm provider is disabled or invalid data returned + * @throws OCMRequestException on internal issue + * @since 33.0.0 + */ + public function requestRemoteOcmEndpoint( + ?string $capability, + string $remote, + string $ocmSubPath, + ?array $payload = null, + string $method = 'get', + ?IClient $client = null, + ?array $options = null, + bool $signed = true, + ): IResponse { + $ocmProvider = $this->discover($remote); + if (!$ocmProvider->isEnabled()) { + throw new OCMProviderException('remote ocm provider is disabled'); + } + + if ($capability !== null && !$ocmProvider->hasCapability($capability)) { + throw new OCMCapabilityException(sprintf('remote does not support %s', $capability)); + } + + $uri = $ocmProvider->getEndPoint() . '/' . ltrim($ocmSubPath, '/'); + $client = $client ?? $this->clientService->newClient(); + + try { + $body = json_encode($payload ?? [], JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->logger->warning('payload could not be converted to JSON', ['exception' => $e]); + throw new OCMRequestException('ocm payload issue'); + } + + try { + $options = $options ?? []; + return match (strtolower($method)) { + 'get' => $client->get($uri, $this->prepareOcmPayload($uri, 'get', $options, $body, $signed)), + 'post' => $client->post($uri, $this->prepareOcmPayload($uri, 'post', $options, $body, $signed)), + 'put' => $client->put($uri, $this->prepareOcmPayload($uri, 'put', $options, $body, $signed)), + 'delete' => $client->delete($uri, $this->prepareOcmPayload($uri, 'delete', $options, $body, $signed)), + default => throw new OCMRequestException('unknown method'), + }; + } catch (OCMRequestException $e) { + throw $e; + } catch (Exception $e) { + $this->logger->warning('error while requesting remote ocm endpoint', ['exception' => $e]); + throw new OCMProviderException('error while requesting remote endpoint'); + } + } + + /** + * add entries to the payload to auth the whole request + * + * @throws OCMProviderException + * @return array + */ + private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array { + $payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]); + if (!$signed) { + return $payload; + } + + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) + && $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { + throw new OCMProviderException('remote endpoint does not support signed request'); + } + + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + $method, $uri + ); + } + + return $signedPayload ?? $payload; + } + + private function generateRequestOptions(array $options): array { + return array_merge( + [ + 'headers' => ['content-type' => 'application/json'], + 'timeout' => 5, + 'connect_timeout' => 5, + ], + $options + ); + } } diff --git a/lib/private/Route/Route.php b/lib/private/Route/Route.php index 08231649e76f0..ad967135104cf 100644 --- a/lib/private/Route/Route.php +++ b/lib/private/Route/Route.php @@ -14,7 +14,7 @@ class Route extends SymfonyRoute implements IRoute { /** * Specify the method when this route is to be used * - * @param string $method HTTP method (uppercase) + * @param string|array $method HTTP method * @return \OC\Route\Route */ public function method($method) { diff --git a/lib/private/Server.php b/lib/private/Server.php index a4cf03cdc4fcf..a4fd45491846f 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -206,9 +206,7 @@ use OCP\Log\ILogFactory; use OCP\Mail\IEmailValidator; use OCP\Mail\IMailer; -use OCP\OCM\ICapabilityAwareOCMProvider; use OCP\OCM\IOCMDiscoveryService; -use OCP\OCM\IOCMProvider; use OCP\Preview\IMimeIconProvider; use OCP\Profile\IProfileManager; use OCP\Profiler\IProfiler; @@ -1245,9 +1243,9 @@ public function __construct($webRoot, \OC\Config $config) { $this->registerAlias(IPhoneNumberUtil::class, PhoneNumberUtil::class); - // there is no reason for having OCMProvider as a Service - $this->registerDeprecatedAlias(ICapabilityAwareOCMProvider::class, OCMProvider::class); - $this->registerDeprecatedAlias(IOCMProvider::class, OCMProvider::class); + // there is no reason for having OCMProvider as a Service (marked as deprecated since 32.0.0) + $this->registerDeprecatedAlias(\OCP\OCM\ICapabilityAwareOCMProvider::class, OCMProvider::class); + $this->registerDeprecatedAlias(\OCP\OCM\IOCMProvider::class, OCMProvider::class); $this->registerAlias(ISetupCheckManager::class, SetupCheckManager::class); diff --git a/lib/public/OCM/Enum/ParamType.php b/lib/public/OCM/Enum/ParamType.php new file mode 100644 index 0000000000000..fa4a19ebe1736 --- /dev/null +++ b/lib/public/OCM/Enum/ParamType.php @@ -0,0 +1,24 @@ +provider->setCapabilities([$capability]); + } + + /** + * @param string $name + * @param list $shareTypes List of supported share recipients, e.g. 'user', 'group', … + * @param array $protocols List of supported protocols and their location, + * e.g. ['webdav' => '/remote.php/webdav/'] + * @since 33.0.0 + */ + public function registerResourceType(string $name, array $shareTypes, array $protocols): void { + $resourceType = $this->provider->createNewResourceType(); + $resourceType->setName($name) + ->setShareTypes($shareTypes) + ->setProtocols($protocols); + $this->provider->addResourceType($resourceType); + } +} diff --git a/lib/public/OCM/Events/OCMEndpointRequestEvent.php b/lib/public/OCM/Events/OCMEndpointRequestEvent.php new file mode 100644 index 0000000000000..ecbb193dad595 --- /dev/null +++ b/lib/public/OCM/Events/OCMEndpointRequestEvent.php @@ -0,0 +1,170 @@ +capability = $path; + $path = ''; + } else { + [$this->capability, $path] = explode('/', $path, 2); + } + $this->path = $path ?? ''; + } + + /** + * returns the first parameter of the sub-path (post-/ocm/) from the request + * + * @since 33.0.0 + */ + public function getRequestedCapability(): string { + return $this->capability; + } + + /** + * returns the method used + * + * @since 33.0.0 + */ + public function getUsedMethod(): string { + return $this->method; + } + + /** + * returns the sub-path (post-/ocm/) of the request + * will start with a slash ('/') + * + * @since 33.0.0 + */ + public function getPath(): string { + return '/' . $this->path; + } + + /** + * Returns the list of parameters from the request, post-'capability' + * + * If no ParamType is specified as parameter of the method, the returned array + * will contain all entries (all string). + * + * If one or multiple ParamType are set: + * - the returned array will contain as many entries as the number of ParamType, + * - each value from the returned array will be typed based on set ParamType, + * - if ParamType cannot be applied (i.e., only alphabetic chars while expecting + * integer), value will be NULL, + * - if missing elements to the request path, missing entries will be NULL, + * + * @since 33.0.0 + */ + public function getArgs(ParamType ...$params): array { + if ($this->path === '') { + return []; + } + + $args = explode('/', $this->path); + if (empty($params)) { + return $args; + } + + $typedArgs = []; + $i = 0; + foreach ($params as $param) { + if (($args[$i] ?? null) === null) { + break; + } + $typedArgs[] = match($param) { + ParamType::STRING => $args[$i], + ParamType::INT => (is_numeric($args[$i]) && ((int)$args[$i] == (float)$args[$i])) ? (int)$args[$i] : null, + ParamType::FLOAT => (is_numeric($args[$i])) ? (float)$args[$i] : null, + ParamType::BOOL => in_array(strtolower($args[$i]), ['1', 'true', 'yes', 'on'], true), + }; + $i++; + } + + return $typedArgs; + } + + /** + * return the number of parameters found in the subpath + * + * @since 33.0.0 + */ + public function getArgsCount(): int { + return count($this->getArgs()); + } + + /** + * returns the payload attached to the request + * + * @since 33.0.0 + */ + public function getPayload(): array { + return $this->payload ?? []; + } + + /** + * @return bool TRUE if request is signed + * @since 33.0.0 + */ + public function isSigned(): bool { + return ($this->getRemote() !== null); + } + + /** + * returns the origin of the request, if signed. + * + * @return string|null NULL if request is not authed + * @since 33.0.0 + */ + public function getRemote(): ?string { + return $this->remote; + } + + /** + * set the Response to the Request to be sent to requester + * + * @since 33.0.0 + */ + public function setResponse(Response $response): void { + $this->response = $response; + } + + /** + * @since 33.0.0 + */ + public function getResponse(): ?Response { + return $this->response; + } +} diff --git a/lib/public/OCM/Events/ResourceTypeRegisterEvent.php b/lib/public/OCM/Events/ResourceTypeRegisterEvent.php index c012919756689..82363ded3357d 100644 --- a/lib/public/OCM/Events/ResourceTypeRegisterEvent.php +++ b/lib/public/OCM/Events/ResourceTypeRegisterEvent.php @@ -16,11 +16,13 @@ * them in the OCM provider list and capability * * @since 28.0.0 + * @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent}) */ class ResourceTypeRegisterEvent extends Event { /** * @param IOCMProvider $provider * @since 28.0.0 + * @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent}) */ public function __construct( protected IOCMProvider $provider, @@ -34,6 +36,7 @@ public function __construct( * @param array $protocols List of supported protocols and their location, * e.g. ['webdav' => '/remote.php/webdav/'] * @since 28.0.0 + * @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent}) */ public function registerResourceType(string $name, array $shareTypes, array $protocols): void { $resourceType = $this->provider->createNewResourceType(); diff --git a/lib/public/OCM/Exceptions/OCMCapabilityException.php b/lib/public/OCM/Exceptions/OCMCapabilityException.php new file mode 100644 index 0000000000000..98153d7d43e6b --- /dev/null +++ b/lib/public/OCM/Exceptions/OCMCapabilityException.php @@ -0,0 +1,17 @@ +logger = $this->createMock(LoggerInterface::class); + $this->context = Server::get(RegistrationContext::class); + $this->dispatcher = Server::get(IEventDispatcher::class); + $this->discoveryService = Server::get(OCMDiscoveryService::class); + $this->config = Server::get(IConfig::class); + + // reset $localProvider value between tests + $reflection = new ReflectionClass($this->discoveryService); + $localProvider = $reflection->getProperty('localProvider'); + $localProvider->setValue($this->discoveryService, null); + + $this->requestController = Server::get(OCMRequestController::class); + } + + public static function dataTestOCMRequest(): array { + return [ + ['/inexistant-path/', 404, null], + ['/ocm-capability-test/', 404, null], + ['/ocm-capability-test/get', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get', + 'args' => ['get'], + 'totalArgs' => 1, + 'typedArgs' => ['get'], + ] + ], + ['/ocm-capability-test/get/10/', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get/10', + 'args' => ['get', '10'], + 'totalArgs' => 2, + 'typedArgs' => ['get', '10'], + ] + ], + ['/ocm-capability-test/get/random/10/', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get/random/10', + 'args' => ['get', 'random', '10'], + 'totalArgs' => 3, + 'typedArgs' => ['get', 'random', 10], + ] + ], + ['/ocm-capability-test/get/random/10/1', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get/random/10/1', + 'args' => ['get', 'random', '10', '1'], + 'totalArgs' => 4, + 'typedArgs' => ['get', 'random', 10, true], + ] + ], + ['/ocm-capability-test/get/random/10/true', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get/random/10/true', + 'args' => ['get', 'random', '10', 'true'], + 'totalArgs' => 4, + 'typedArgs' => ['get', 'random', 10, true], + ] + ], + ['/ocm-capability-test/get/random/10/true/42', 200, + [ + 'capability' => 'ocm-capability-test', + 'path' => '/get/random/10/true/42', + 'args' => ['get', 'random', '10', 'true', '42'], + 'totalArgs' => 5, + 'typedArgs' => ['get', 'random', 10, true, 42], + ] + ], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataTestOCMRequest')] + public function testOCMRequest(string $path, int $expectedStatus, ?array $expectedResult): void { + $this->context->for('ocm-request-app')->registerEventListener(OCMEndpointRequestEvent::class, OCMEndpointRequestTestEvent::class); + $this->context->delegateEventListenerRegistrations($this->dispatcher); + + $response = $this->requestController->manageOCMRequests($path); + $this->assertSame($expectedStatus, $response->getStatus()); + if ($expectedResult !== null) { + $this->assertSame($expectedResult, $response->getData()); + } + } + + + public function testLocalBaseCapability(): void { + $local = $this->discoveryService->getLocalOCMProvider(); + $this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities())); + } + + + public function testLocalAddedCapability(): void { + $this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class); + $this->context->delegateEventListenerRegistrations($this->dispatcher); + $local = $this->discoveryService->getLocalOCMProvider(); + $this->assertEmpty(array_diff(['notifications', 'shares', 'ocm-capability-test'], $local->getCapabilities())); + } + +} diff --git a/tests/lib/OCM/Listeners/LocalOCMDiscoveryTestEvent.php b/tests/lib/OCM/Listeners/LocalOCMDiscoveryTestEvent.php new file mode 100644 index 0000000000000..7553d9f1b74b3 --- /dev/null +++ b/tests/lib/OCM/Listeners/LocalOCMDiscoveryTestEvent.php @@ -0,0 +1,27 @@ + */ +class LocalOCMDiscoveryTestEvent implements IEventListener { + public function __construct( + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof \OCP\OCM\Events\LocalOCMDiscoveryEvent)) { + return; + } + + $event->addCapability('ocm-capability-test'); + } +} diff --git a/tests/lib/OCM/Listeners/OCMEndpointRequestTestEvent.php b/tests/lib/OCM/Listeners/OCMEndpointRequestTestEvent.php new file mode 100644 index 0000000000000..f0b706fe39b79 --- /dev/null +++ b/tests/lib/OCM/Listeners/OCMEndpointRequestTestEvent.php @@ -0,0 +1,48 @@ + */ +class OCMEndpointRequestTestEvent implements IEventListener { + public function __construct( + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof \OCP\OCM\Events\OCMEndpointRequestEvent)) { + return; + } + + if ($event->getPath() === '/') { + $event->setResponse(new Response(404)); + return; + } + + $event->setResponse(new DataResponse( + [ + 'capability' => $event->getRequestedCapability(), + 'path' => $event->getPath(), + 'args' => $event->getArgs(), + 'totalArgs' => $event->getArgsCount(), + 'typedArgs' => $event->getArgs( + \OCP\OCM\Enum\ParamType::STRING, + \OCP\OCM\Enum\ParamType::STRING, + \OCP\OCM\Enum\ParamType::INT, + \OCP\OCM\Enum\ParamType::BOOL, + \OCP\OCM\Enum\ParamType::INT + ) + ] + )); + } +}