RequestHandlerController.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OCA\CloudFederationAPI\Controller;
  7. use NCU\Federation\ISignedCloudFederationProvider;
  8. use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
  9. use NCU\Security\Signature\Exceptions\IncomingRequestException;
  10. use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
  11. use NCU\Security\Signature\Exceptions\SignatureException;
  12. use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
  13. use NCU\Security\Signature\IIncomingSignedRequest;
  14. use NCU\Security\Signature\ISignatureManager;
  15. use OC\OCM\OCMSignatoryManager;
  16. use OCA\CloudFederationAPI\Config;
  17. use OCA\CloudFederationAPI\ResponseDefinitions;
  18. use OCA\FederatedFileSharing\AddressHandler;
  19. use OCP\AppFramework\Controller;
  20. use OCP\AppFramework\Http;
  21. use OCP\AppFramework\Http\Attribute\BruteForceProtection;
  22. use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
  23. use OCP\AppFramework\Http\Attribute\OpenAPI;
  24. use OCP\AppFramework\Http\Attribute\PublicPage;
  25. use OCP\AppFramework\Http\JSONResponse;
  26. use OCP\Federation\Exceptions\ActionNotSupportedException;
  27. use OCP\Federation\Exceptions\AuthenticationFailedException;
  28. use OCP\Federation\Exceptions\BadRequestException;
  29. use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
  30. use OCP\Federation\Exceptions\ProviderDoesNotExistsException;
  31. use OCP\Federation\ICloudFederationFactory;
  32. use OCP\Federation\ICloudFederationProviderManager;
  33. use OCP\Federation\ICloudIdManager;
  34. use OCP\IAppConfig;
  35. use OCP\IGroupManager;
  36. use OCP\IRequest;
  37. use OCP\IURLGenerator;
  38. use OCP\IUserManager;
  39. use OCP\Share\Exceptions\ShareNotFound;
  40. use OCP\Util;
  41. use Psr\Log\LoggerInterface;
  42. /**
  43. * Open-Cloud-Mesh-API
  44. *
  45. * @package OCA\CloudFederationAPI\Controller
  46. *
  47. * @psalm-import-type CloudFederationAPIAddShare from ResponseDefinitions
  48. * @psalm-import-type CloudFederationAPIValidationError from ResponseDefinitions
  49. * @psalm-import-type CloudFederationAPIError from ResponseDefinitions
  50. */
  51. #[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
  52. class RequestHandlerController extends Controller {
  53. public function __construct(
  54. string $appName,
  55. IRequest $request,
  56. private LoggerInterface $logger,
  57. private IUserManager $userManager,
  58. private IGroupManager $groupManager,
  59. private IURLGenerator $urlGenerator,
  60. private ICloudFederationProviderManager $cloudFederationProviderManager,
  61. private Config $config,
  62. private readonly AddressHandler $addressHandler,
  63. private readonly IAppConfig $appConfig,
  64. private ICloudFederationFactory $factory,
  65. private ICloudIdManager $cloudIdManager,
  66. private readonly ISignatureManager $signatureManager,
  67. private readonly OCMSignatoryManager $signatoryManager,
  68. ) {
  69. parent::__construct($appName, $request);
  70. }
  71. /**
  72. * Add share
  73. *
  74. * @param string $shareWith The user who the share will be shared with
  75. * @param string $name The resource name (e.g. document.odt)
  76. * @param string|null $description Share description
  77. * @param string $providerId Resource UID on the provider side
  78. * @param string $owner Provider specific UID of the user who owns the resource
  79. * @param string|null $ownerDisplayName Display name of the user who shared the item
  80. * @param string|null $sharedBy Provider specific UID of the user who shared the resource
  81. * @param string|null $sharedByDisplayName Display name of the user who shared the resource
  82. * @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]
  83. * @param string $shareType 'group' or 'user' share
  84. * @param string $resourceType 'file', 'calendar',...
  85. *
  86. * @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
  87. *
  88. * 201: The notification was successfully received. The display name of the recipient might be returned in the body
  89. * 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing
  90. * 501: Share type or the resource type is not supported
  91. */
  92. #[PublicPage]
  93. #[NoCSRFRequired]
  94. #[BruteForceProtection(action: 'receiveFederatedShare')]
  95. public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
  96. try {
  97. // if request is signed and well signed, no exception are thrown
  98. // if request is not signed and host is known for not supporting signed request, no exception are thrown
  99. $signedRequest = $this->getSignedRequest();
  100. $this->confirmSignedOrigin($signedRequest, 'owner', $owner);
  101. } catch (IncomingRequestException $e) {
  102. $this->logger->warning('incoming request exception', ['exception' => $e]);
  103. return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
  104. }
  105. // check if all required parameters are set
  106. if ($shareWith === null ||
  107. $name === null ||
  108. $providerId === null ||
  109. $resourceType === null ||
  110. $shareType === null ||
  111. !is_array($protocol) ||
  112. !isset($protocol['name']) ||
  113. !isset($protocol['options']) ||
  114. !is_array($protocol['options']) ||
  115. !isset($protocol['options']['sharedSecret'])
  116. ) {
  117. return new JSONResponse(
  118. [
  119. 'message' => 'Missing arguments',
  120. 'validationErrors' => [],
  121. ],
  122. Http::STATUS_BAD_REQUEST
  123. );
  124. }
  125. $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType);
  126. if (!in_array($shareType, $supportedShareTypes)) {
  127. return new JSONResponse(
  128. ['message' => 'Share type "' . $shareType . '" not implemented'],
  129. Http::STATUS_NOT_IMPLEMENTED
  130. );
  131. }
  132. $cloudId = $this->cloudIdManager->resolveCloudId($shareWith);
  133. $shareWith = $cloudId->getUser();
  134. if ($shareType === 'user') {
  135. $shareWith = $this->mapUid($shareWith);
  136. if (!$this->userManager->userExists($shareWith)) {
  137. $response = new JSONResponse(
  138. [
  139. 'message' => 'User "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
  140. 'validationErrors' => [],
  141. ],
  142. Http::STATUS_BAD_REQUEST
  143. );
  144. $response->throttle();
  145. return $response;
  146. }
  147. }
  148. if ($shareType === 'group') {
  149. if (!$this->groupManager->groupExists($shareWith)) {
  150. $response = new JSONResponse(
  151. [
  152. 'message' => 'Group "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
  153. 'validationErrors' => [],
  154. ],
  155. Http::STATUS_BAD_REQUEST
  156. );
  157. $response->throttle();
  158. return $response;
  159. }
  160. }
  161. // if no explicit display name is given, we use the uid as display name
  162. $ownerDisplayName = $ownerDisplayName === null ? $owner : $ownerDisplayName;
  163. $sharedByDisplayName = $sharedByDisplayName === null ? $sharedBy : $sharedByDisplayName;
  164. // sharedBy* parameter is optional, if nothing is set we assume that it is the same user as the owner
  165. if ($sharedBy === null) {
  166. $sharedBy = $owner;
  167. $sharedByDisplayName = $ownerDisplayName;
  168. }
  169. try {
  170. $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
  171. $share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType);
  172. $share->setProtocol($protocol);
  173. $provider->shareReceived($share);
  174. } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) {
  175. return new JSONResponse(
  176. ['message' => $e->getMessage()],
  177. Http::STATUS_NOT_IMPLEMENTED
  178. );
  179. } catch (\Exception $e) {
  180. $this->logger->error($e->getMessage(), ['exception' => $e]);
  181. return new JSONResponse(
  182. [
  183. 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
  184. 'validationErrors' => [],
  185. ],
  186. Http::STATUS_BAD_REQUEST
  187. );
  188. }
  189. $responseData = ['recipientDisplayName' => ''];
  190. if ($shareType === 'user') {
  191. $user = $this->userManager->get($shareWith);
  192. if ($user) {
  193. $responseData = [
  194. 'recipientDisplayName' => $user->getDisplayName(),
  195. 'recipientUserId' => $user->getUID(),
  196. ];
  197. }
  198. }
  199. return new JSONResponse($responseData, Http::STATUS_CREATED);
  200. }
  201. /**
  202. * Send a notification about an existing share
  203. *
  204. * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED
  205. * @param string $resourceType calendar, file, contact,...
  206. * @param string|null $providerId ID of the share
  207. * @param array<string, mixed>|null $notification The actual payload of the notification
  208. *
  209. * @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{}>
  210. *
  211. * 201: The notification was successfully received
  212. * 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing
  213. * 403: Getting resource is not allowed
  214. * 501: The resource type is not supported
  215. */
  216. #[NoCSRFRequired]
  217. #[PublicPage]
  218. #[BruteForceProtection(action: 'receiveFederatedShareNotification')]
  219. public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
  220. // check if all required parameters are set
  221. if ($notificationType === null ||
  222. $resourceType === null ||
  223. $providerId === null ||
  224. !is_array($notification)
  225. ) {
  226. return new JSONResponse(
  227. [
  228. 'message' => 'Missing arguments',
  229. 'validationErrors' => [],
  230. ],
  231. Http::STATUS_BAD_REQUEST
  232. );
  233. }
  234. try {
  235. // if request is signed and well signed, no exception are thrown
  236. // if request is not signed and host is known for not supporting signed request, no exception are thrown
  237. $signedRequest = $this->getSignedRequest();
  238. $this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
  239. } catch (IncomingRequestException $e) {
  240. $this->logger->warning('incoming request exception', ['exception' => $e]);
  241. return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
  242. }
  243. try {
  244. $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
  245. $result = $provider->notificationReceived($notificationType, $providerId, $notification);
  246. } catch (ProviderDoesNotExistsException $e) {
  247. return new JSONResponse(
  248. [
  249. 'message' => $e->getMessage(),
  250. 'validationErrors' => [],
  251. ],
  252. Http::STATUS_BAD_REQUEST
  253. );
  254. } catch (ShareNotFound $e) {
  255. $response = new JSONResponse(
  256. [
  257. 'message' => $e->getMessage(),
  258. 'validationErrors' => [],
  259. ],
  260. Http::STATUS_BAD_REQUEST
  261. );
  262. $response->throttle();
  263. return $response;
  264. } catch (ActionNotSupportedException $e) {
  265. return new JSONResponse(
  266. ['message' => $e->getMessage()],
  267. Http::STATUS_NOT_IMPLEMENTED
  268. );
  269. } catch (BadRequestException $e) {
  270. return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST);
  271. } catch (AuthenticationFailedException $e) {
  272. $response = new JSONResponse(['message' => 'RESOURCE_NOT_FOUND'], Http::STATUS_FORBIDDEN);
  273. $response->throttle();
  274. return $response;
  275. } catch (\Exception $e) {
  276. $this->logger->warning('incoming notification exception', ['exception' => $e]);
  277. return new JSONResponse(
  278. [
  279. 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
  280. 'validationErrors' => [],
  281. ],
  282. Http::STATUS_BAD_REQUEST
  283. );
  284. }
  285. return new JSONResponse($result, Http::STATUS_CREATED);
  286. }
  287. /**
  288. * map login name to internal LDAP UID if a LDAP backend is in use
  289. *
  290. * @param string $uid
  291. * @return string mixed
  292. */
  293. private function mapUid($uid) {
  294. // FIXME this should be a method in the user management instead
  295. $this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]);
  296. Util::emitHook(
  297. '\OCA\Files_Sharing\API\Server2Server',
  298. 'preLoginNameUsedAsUserName',
  299. ['uid' => &$uid]
  300. );
  301. $this->logger->debug('shareWith after, ' . $uid, ['app' => $this->appName]);
  302. return $uid;
  303. }
  304. /**
  305. * returns signed request if available.
  306. * throw an exception:
  307. * - if request is signed, but wrongly signed
  308. * - if request is not signed but instance is configured to only accept signed ocm request
  309. *
  310. * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request
  311. * @throws IncomingRequestException
  312. */
  313. private function getSignedRequest(): ?IIncomingSignedRequest {
  314. try {
  315. $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
  316. $this->logger->debug('signed request available', ['signedRequest' => $signedRequest]);
  317. return $signedRequest;
  318. } catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
  319. $this->logger->debug('remote does not support signed request', ['exception' => $e]);
  320. // remote does not support signed request.
  321. // currently we still accept unsigned request until lazy appconfig
  322. // core.enforce_signed_ocm_request is set to true (default: false)
  323. if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
  324. $this->logger->notice('ignored unsigned request', ['exception' => $e]);
  325. throw new IncomingRequestException('Unsigned request');
  326. }
  327. } catch (SignatureException $e) {
  328. $this->logger->warning('wrongly signed request', ['exception' => $e]);
  329. throw new IncomingRequestException('Invalid signature');
  330. }
  331. return null;
  332. }
  333. /**
  334. * confirm that the value related to $key entry from the payload is in format userid@hostname
  335. * and compare hostname with the origin of the signed request.
  336. *
  337. * If request is not signed, we still verify that the hostname from the extracted value does,
  338. * actually, not support signed request
  339. *
  340. * @param IIncomingSignedRequest|null $signedRequest
  341. * @param string $key entry from data available in data
  342. * @param string $value value itself used in case request is not signed
  343. *
  344. * @throws IncomingRequestException
  345. */
  346. private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
  347. if ($signedRequest === null) {
  348. $instance = $this->getHostFromFederationId($value);
  349. try {
  350. $this->signatureManager->getSignatory($instance);
  351. throw new IncomingRequestException('instance is supposed to sign its request');
  352. } catch (SignatoryNotFoundException) {
  353. return;
  354. }
  355. }
  356. $body = json_decode($signedRequest->getBody(), true) ?? [];
  357. $entry = trim($body[$key] ?? '', '@');
  358. if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) {
  359. throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']');
  360. }
  361. }
  362. /**
  363. * confirm identity of the remote instance on notification, based on the share token.
  364. *
  365. * If request is not signed, we still verify that the hostname from the extracted value does,
  366. * actually, not support signed request
  367. *
  368. * @param IIncomingSignedRequest|null $signedRequest
  369. * @param string $resourceType
  370. * @param string $sharedSecret
  371. *
  372. * @throws IncomingRequestException
  373. * @throws BadRequestException
  374. */
  375. private function confirmNotificationIdentity(
  376. ?IIncomingSignedRequest $signedRequest,
  377. string $resourceType,
  378. array $notification,
  379. ): void {
  380. $sharedSecret = $notification['sharedSecret'] ?? '';
  381. if ($sharedSecret === '') {
  382. throw new BadRequestException(['sharedSecret']);
  383. }
  384. try {
  385. $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
  386. if ($provider instanceof ISignedCloudFederationProvider) {
  387. $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
  388. } else {
  389. $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
  390. return;
  391. }
  392. } catch (\Exception $e) {
  393. throw new IncomingRequestException($e->getMessage());
  394. }
  395. $this->confirmNotificationEntry($signedRequest, $identity);
  396. }
  397. /**
  398. * @param IIncomingSignedRequest|null $signedRequest
  399. * @param string $entry
  400. *
  401. * @return void
  402. * @throws IncomingRequestException
  403. */
  404. private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
  405. $instance = $this->getHostFromFederationId($entry);
  406. if ($signedRequest === null) {
  407. try {
  408. $this->signatureManager->getSignatory($instance);
  409. throw new IncomingRequestException('instance is supposed to sign its request');
  410. } catch (SignatoryNotFoundException) {
  411. return;
  412. }
  413. } elseif ($instance !== $signedRequest->getOrigin()) {
  414. throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin());
  415. }
  416. }
  417. /**
  418. * @param string $entry
  419. * @return string
  420. * @throws IncomingRequestException
  421. */
  422. private function getHostFromFederationId(string $entry): string {
  423. if (!str_contains($entry, '@')) {
  424. throw new IncomingRequestException('entry ' . $entry . ' does not contains @');
  425. }
  426. $rightPart = substr($entry, strrpos($entry, '@') + 1);
  427. // in case the full scheme is sent; getting rid of it
  428. $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart);
  429. try {
  430. return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart);
  431. } catch (IdentityNotFoundException) {
  432. throw new IncomingRequestException('invalid host within federation id: ' . $entry);
  433. }
  434. }
  435. }