Completed
Push — master ( 75bed8...632ffb )
by Maxence
29:29
created
apps/cloud_federation_api/lib/Controller/RequestHandlerController.php 1 patch
Indentation   +515 added lines, -515 removed lines patch added patch discarded remove patch
@@ -59,519 +59,519 @@
 block discarded – undo
59 59
  */
60 60
 #[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
61 61
 class RequestHandlerController extends Controller {
62
-	public function __construct(
63
-		string $appName,
64
-		IRequest $request,
65
-		private LoggerInterface $logger,
66
-		private IUserManager $userManager,
67
-		private IGroupManager $groupManager,
68
-		private IURLGenerator $urlGenerator,
69
-		private ICloudFederationProviderManager $cloudFederationProviderManager,
70
-		private Config $config,
71
-		private IEventDispatcher $dispatcher,
72
-		private FederatedInviteMapper $federatedInviteMapper,
73
-		private readonly AddressHandler $addressHandler,
74
-		private readonly IAppConfig $appConfig,
75
-		private ICloudFederationFactory $factory,
76
-		private ICloudIdManager $cloudIdManager,
77
-		private readonly ISignatureManager $signatureManager,
78
-		private readonly OCMSignatoryManager $signatoryManager,
79
-		private ITimeFactory $timeFactory,
80
-	) {
81
-		parent::__construct($appName, $request);
82
-	}
83
-
84
-	/**
85
-	 * Add share
86
-	 *
87
-	 * @param string $shareWith The user who the share will be shared with
88
-	 * @param string $name The resource name (e.g. document.odt)
89
-	 * @param string|null $description Share description
90
-	 * @param string $providerId Resource UID on the provider side
91
-	 * @param string $owner Provider specific UID of the user who owns the resource
92
-	 * @param string|null $ownerDisplayName Display name of the user who shared the item
93
-	 * @param string|null $sharedBy Provider specific UID of the user who shared the resource
94
-	 * @param string|null $sharedByDisplayName Display name of the user who shared the resource
95
-	 * @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]
96
-	 * @param string $shareType 'group' or 'user' share
97
-	 * @param string $resourceType 'file', 'calendar',...
98
-	 *
99
-	 * @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
100
-	 *
101
-	 * 201: The notification was successfully received. The display name of the recipient might be returned in the body
102
-	 * 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing
103
-	 * 501: Share type or the resource type is not supported
104
-	 */
105
-	#[PublicPage]
106
-	#[NoCSRFRequired]
107
-	#[BruteForceProtection(action: 'receiveFederatedShare')]
108
-	public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
109
-		if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
110
-			try {
111
-				// if request is signed and well signed, no exception are thrown
112
-				// if request is not signed and host is known for not supporting signed request, no exception are thrown
113
-				$signedRequest = $this->getSignedRequest();
114
-				$this->confirmSignedOrigin($signedRequest, 'owner', $owner);
115
-			} catch (IncomingRequestException $e) {
116
-				$this->logger->warning('incoming request exception', ['exception' => $e]);
117
-				return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
118
-			}
119
-		}
120
-
121
-		// check if all required parameters are set
122
-		if (
123
-			$shareWith === null
124
-			|| $name === null
125
-			|| $providerId === null
126
-			|| $resourceType === null
127
-			|| $shareType === null
128
-			|| !is_array($protocol)
129
-			|| !isset($protocol['name'])
130
-			|| !isset($protocol['options'])
131
-			|| !is_array($protocol['options'])
132
-			|| !isset($protocol['options']['sharedSecret'])
133
-		) {
134
-			return new JSONResponse(
135
-				[
136
-					'message' => 'Missing arguments',
137
-					'validationErrors' => [],
138
-				],
139
-				Http::STATUS_BAD_REQUEST
140
-			);
141
-		}
142
-
143
-		$supportedShareTypes = $this->config->getSupportedShareTypes($resourceType);
144
-		if (!in_array($shareType, $supportedShareTypes)) {
145
-			return new JSONResponse(
146
-				['message' => 'Share type "' . $shareType . '" not implemented'],
147
-				Http::STATUS_NOT_IMPLEMENTED
148
-			);
149
-		}
150
-
151
-		$cloudId = $this->cloudIdManager->resolveCloudId($shareWith);
152
-		$shareWith = $cloudId->getUser();
153
-
154
-		if ($shareType === 'user') {
155
-			$shareWith = $this->mapUid($shareWith);
156
-
157
-			if (!$this->userManager->userExists($shareWith)) {
158
-				$response = new JSONResponse(
159
-					[
160
-						'message' => 'User "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
161
-						'validationErrors' => [],
162
-					],
163
-					Http::STATUS_BAD_REQUEST
164
-				);
165
-				$response->throttle();
166
-				return $response;
167
-			}
168
-		}
169
-
170
-		if ($shareType === 'group') {
171
-			if (!$this->groupManager->groupExists($shareWith)) {
172
-				$response = new JSONResponse(
173
-					[
174
-						'message' => 'Group "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
175
-						'validationErrors' => [],
176
-					],
177
-					Http::STATUS_BAD_REQUEST
178
-				);
179
-				$response->throttle();
180
-				return $response;
181
-			}
182
-		}
183
-
184
-		// if no explicit display name is given, we use the uid as display name
185
-		$ownerDisplayName = $ownerDisplayName === null ? $owner : $ownerDisplayName;
186
-		$sharedByDisplayName = $sharedByDisplayName === null ? $sharedBy : $sharedByDisplayName;
187
-
188
-		// sharedBy* parameter is optional, if nothing is set we assume that it is the same user as the owner
189
-		if ($sharedBy === null) {
190
-			$sharedBy = $owner;
191
-			$sharedByDisplayName = $ownerDisplayName;
192
-		}
193
-
194
-		try {
195
-			$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
196
-			$share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType);
197
-			$share->setProtocol($protocol);
198
-			$provider->shareReceived($share);
199
-		} catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) {
200
-			return new JSONResponse(
201
-				['message' => $e->getMessage()],
202
-				Http::STATUS_NOT_IMPLEMENTED
203
-			);
204
-		} catch (\Exception $e) {
205
-			$this->logger->error($e->getMessage(), ['exception' => $e]);
206
-			return new JSONResponse(
207
-				[
208
-					'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
209
-					'validationErrors' => [],
210
-				],
211
-				Http::STATUS_BAD_REQUEST
212
-			);
213
-		}
214
-
215
-		$responseData = ['recipientDisplayName' => ''];
216
-		if ($shareType === 'user') {
217
-			$user = $this->userManager->get($shareWith);
218
-			if ($user) {
219
-				$responseData = [
220
-					'recipientDisplayName' => $user->getDisplayName(),
221
-					'recipientUserId' => $user->getUID(),
222
-				];
223
-			}
224
-		}
225
-
226
-		return new JSONResponse($responseData, Http::STATUS_CREATED);
227
-	}
228
-
229
-	/**
230
-	 * Inform the sender that an invitation was accepted to start sharing
231
-	 *
232
-	 * Inform about an accepted invitation so the user on the sender provider's side
233
-	 * can initiate the OCM share creation. To protect the identity of the parties,
234
-	 * for shares created following an OCM invitation, the user id MAY be hashed,
235
-	 * and recipients implementing the OCM invitation workflow MAY refuse to process
236
-	 * shares coming from unknown parties.
237
-	 * @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
238
-	 *
239
-	 * @param string $recipientProvider The address of the recipent's provider
240
-	 * @param string $token The token used for the invitation
241
-	 * @param string $userID The userID of the recipient at the recipient's provider
242
-	 * @param string $email The email address of the recipient
243
-	 * @param string $name The display name of the recipient
244
-	 *
245
-	 * @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{}>
246
-	 *
247
-	 * Note: Not implementing 404 Invitation token does not exist, instead using 400
248
-	 * 200: Invitation accepted
249
-	 * 400: Invalid token
250
-	 * 403: Invitation token does not exist
251
-	 * 409: User is already known by the OCM provider
252
-	 */
253
-	#[PublicPage]
254
-	#[NoCSRFRequired]
255
-	#[BruteForceProtection(action: 'inviteAccepted')]
256
-	public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
257
-		$this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
258
-
259
-		$updated = $this->timeFactory->getTime();
260
-
261
-		if ($token === '') {
262
-			$response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
263
-			$response->throttle();
264
-			return $response;
265
-		}
266
-
267
-		try {
268
-			$invitation = $this->federatedInviteMapper->findByToken($token);
269
-		} catch (DoesNotExistException) {
270
-			$response = ['message' => 'Invalid or non existing token', 'error' => true];
271
-			$status = Http::STATUS_BAD_REQUEST;
272
-			$response = new JSONResponse($response, $status);
273
-			$response->throttle();
274
-			return $response;
275
-		}
276
-
277
-		if ($invitation->isAccepted() === true) {
278
-			$response = ['message' => 'Invite already accepted', 'error' => true];
279
-			$status = Http::STATUS_CONFLICT;
280
-			return new JSONResponse($response, $status);
281
-		}
282
-
283
-		if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
284
-			$response = ['message' => 'Invitation expired', 'error' => true];
285
-			$status = Http::STATUS_BAD_REQUEST;
286
-			return new JSONResponse($response, $status);
287
-		}
288
-		$localUser = $this->userManager->get($invitation->getUserId());
289
-		if ($localUser === null) {
290
-			$response = ['message' => 'Invalid or non existing token', 'error' => true];
291
-			$status = Http::STATUS_BAD_REQUEST;
292
-			$response = new JSONResponse($response, $status);
293
-			$response->throttle();
294
-			return $response;
295
-		}
296
-
297
-		$sharedFromEmail = $localUser->getEMailAddress();
298
-		if ($sharedFromEmail === null) {
299
-			$response = ['message' => 'Invalid or non existing token', 'error' => true];
300
-			$status = Http::STATUS_BAD_REQUEST;
301
-			$response = new JSONResponse($response, $status);
302
-			$response->throttle();
303
-			return $response;
304
-		}
305
-		$sharedFromDisplayName = $localUser->getDisplayName();
306
-
307
-		$response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
308
-		$status = Http::STATUS_OK;
309
-
310
-		$invitation->setAccepted(true);
311
-		$invitation->setRecipientEmail($email);
312
-		$invitation->setRecipientName($name);
313
-		$invitation->setRecipientProvider($recipientProvider);
314
-		$invitation->setRecipientUserId($userID);
315
-		$invitation->setAcceptedAt($updated);
316
-		$invitation = $this->federatedInviteMapper->update($invitation);
317
-
318
-		$event = new FederatedInviteAcceptedEvent($invitation);
319
-		$this->dispatcher->dispatchTyped($event);
320
-
321
-		return new JSONResponse($response, $status);
322
-	}
323
-
324
-	/**
325
-	 * Send a notification about an existing share
326
-	 *
327
-	 * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED
328
-	 * @param string $resourceType calendar, file, contact,...
329
-	 * @param string|null $providerId ID of the share
330
-	 * @param array<string, mixed>|null $notification The actual payload of the notification
331
-	 *
332
-	 * @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{}>
333
-	 *
334
-	 * 201: The notification was successfully received
335
-	 * 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing
336
-	 * 403: Getting resource is not allowed
337
-	 * 501: The resource type is not supported
338
-	 */
339
-	#[NoCSRFRequired]
340
-	#[PublicPage]
341
-	#[BruteForceProtection(action: 'receiveFederatedShareNotification')]
342
-	public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
343
-		// check if all required parameters are set
344
-		if (
345
-			$notificationType === null
346
-			|| $resourceType === null
347
-			|| $providerId === null
348
-			|| !is_array($notification)
349
-		) {
350
-			return new JSONResponse(
351
-				[
352
-					'message' => 'Missing arguments',
353
-					'validationErrors' => [],
354
-				],
355
-				Http::STATUS_BAD_REQUEST
356
-			);
357
-		}
358
-
359
-		if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
360
-			try {
361
-				// if request is signed and well signed, no exception are thrown
362
-				// if request is not signed and host is known for not supporting signed request, no exception are thrown
363
-				$signedRequest = $this->getSignedRequest();
364
-				$this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
365
-			} catch (IncomingRequestException $e) {
366
-				$this->logger->warning('incoming request exception', ['exception' => $e]);
367
-				return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
368
-			}
369
-		}
370
-
371
-		try {
372
-			$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
373
-			$result = $provider->notificationReceived($notificationType, $providerId, $notification);
374
-		} catch (ProviderDoesNotExistsException $e) {
375
-			return new JSONResponse(
376
-				[
377
-					'message' => $e->getMessage(),
378
-					'validationErrors' => [],
379
-				],
380
-				Http::STATUS_BAD_REQUEST
381
-			);
382
-		} catch (ShareNotFound $e) {
383
-			$response = new JSONResponse(
384
-				[
385
-					'message' => $e->getMessage(),
386
-					'validationErrors' => [],
387
-				],
388
-				Http::STATUS_BAD_REQUEST
389
-			);
390
-			$response->throttle();
391
-			return $response;
392
-		} catch (ActionNotSupportedException $e) {
393
-			return new JSONResponse(
394
-				['message' => $e->getMessage()],
395
-				Http::STATUS_NOT_IMPLEMENTED
396
-			);
397
-		} catch (BadRequestException $e) {
398
-			return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST);
399
-		} catch (AuthenticationFailedException $e) {
400
-			$response = new JSONResponse(['message' => 'RESOURCE_NOT_FOUND'], Http::STATUS_FORBIDDEN);
401
-			$response->throttle();
402
-			return $response;
403
-		} catch (\Exception $e) {
404
-			$this->logger->warning('incoming notification exception', ['exception' => $e]);
405
-			return new JSONResponse(
406
-				[
407
-					'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
408
-					'validationErrors' => [],
409
-				],
410
-				Http::STATUS_BAD_REQUEST
411
-			);
412
-		}
413
-
414
-		return new JSONResponse($result, Http::STATUS_CREATED);
415
-	}
416
-
417
-	/**
418
-	 * map login name to internal LDAP UID if a LDAP backend is in use
419
-	 *
420
-	 * @param string $uid
421
-	 * @return string mixed
422
-	 */
423
-	private function mapUid($uid) {
424
-		// FIXME this should be a method in the user management instead
425
-		$this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]);
426
-		Util::emitHook(
427
-			'\OCA\Files_Sharing\API\Server2Server',
428
-			'preLoginNameUsedAsUserName',
429
-			['uid' => &$uid]
430
-		);
431
-		$this->logger->debug('shareWith after, ' . $uid, ['app' => $this->appName]);
432
-
433
-		return $uid;
434
-	}
435
-
436
-
437
-	/**
438
-	 * returns signed request if available.
439
-	 * throw an exception:
440
-	 * - if request is signed, but wrongly signed
441
-	 * - if request is not signed but instance is configured to only accept signed ocm request
442
-	 *
443
-	 * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request
444
-	 * @throws IncomingRequestException
445
-	 */
446
-	private function getSignedRequest(): ?IIncomingSignedRequest {
447
-		try {
448
-			$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
449
-			$this->logger->debug('signed request available', ['signedRequest' => $signedRequest]);
450
-			return $signedRequest;
451
-		} catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
452
-			$this->logger->debug('remote does not support signed request', ['exception' => $e]);
453
-			// remote does not support signed request.
454
-			// currently we still accept unsigned request until lazy appconfig
455
-			// core.enforce_signed_ocm_request is set to true (default: false)
456
-			if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
457
-				$this->logger->notice('ignored unsigned request', ['exception' => $e]);
458
-				throw new IncomingRequestException('Unsigned request');
459
-			}
460
-		} catch (SignatureException $e) {
461
-			$this->logger->warning('wrongly signed request', ['exception' => $e]);
462
-			throw new IncomingRequestException('Invalid signature');
463
-		}
464
-		return null;
465
-	}
466
-
467
-
468
-	/**
469
-	 * confirm that the value related to $key entry from the payload is in format userid@hostname
470
-	 * and compare hostname with the origin of the signed request.
471
-	 *
472
-	 * If request is not signed, we still verify that the hostname from the extracted value does,
473
-	 * actually, not support signed request
474
-	 *
475
-	 * @param IIncomingSignedRequest|null $signedRequest
476
-	 * @param string $key entry from data available in data
477
-	 * @param string $value value itself used in case request is not signed
478
-	 *
479
-	 * @throws IncomingRequestException
480
-	 */
481
-	private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
482
-		if ($signedRequest === null) {
483
-			$instance = $this->getHostFromFederationId($value);
484
-			try {
485
-				$this->signatureManager->getSignatory($instance);
486
-				throw new IncomingRequestException('instance is supposed to sign its request');
487
-			} catch (SignatoryNotFoundException) {
488
-				return;
489
-			}
490
-		}
491
-
492
-		$body = json_decode($signedRequest->getBody(), true) ?? [];
493
-		$entry = trim($body[$key] ?? '', '@');
494
-		if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) {
495
-			throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']');
496
-		}
497
-	}
498
-
499
-	/**
500
-	 *  confirm identity of the remote instance on notification, based on the share token.
501
-	 *
502
-	 *  If request is not signed, we still verify that the hostname from the extracted value does,
503
-	 *  actually, not support signed request
504
-	 *
505
-	 * @param IIncomingSignedRequest|null $signedRequest
506
-	 * @param string $resourceType
507
-	 *
508
-	 * @throws IncomingRequestException
509
-	 * @throws BadRequestException
510
-	 */
511
-	private function confirmNotificationIdentity(
512
-		?IIncomingSignedRequest $signedRequest,
513
-		string $resourceType,
514
-		array $notification,
515
-	): void {
516
-		$sharedSecret = $notification['sharedSecret'] ?? '';
517
-		if ($sharedSecret === '') {
518
-			throw new BadRequestException(['sharedSecret']);
519
-		}
520
-
521
-		try {
522
-			$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
523
-			if ($provider instanceof ISignedCloudFederationProvider) {
524
-				$identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
525
-			} else {
526
-				$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
527
-				return;
528
-			}
529
-		} catch (\Exception $e) {
530
-			throw new IncomingRequestException($e->getMessage(), previous: $e);
531
-		}
532
-
533
-		$this->confirmNotificationEntry($signedRequest, $identity);
534
-	}
535
-
536
-
537
-	/**
538
-	 * @param IIncomingSignedRequest|null $signedRequest
539
-	 * @param string $entry
540
-	 *
541
-	 * @return void
542
-	 * @throws IncomingRequestException
543
-	 */
544
-	private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
545
-		$instance = $this->getHostFromFederationId($entry);
546
-		if ($signedRequest === null) {
547
-			try {
548
-				$this->signatureManager->getSignatory($instance);
549
-				throw new IncomingRequestException('instance is supposed to sign its request');
550
-			} catch (SignatoryNotFoundException) {
551
-				return;
552
-			}
553
-		} elseif ($instance !== $signedRequest->getOrigin()) {
554
-			throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin());
555
-		}
556
-	}
557
-
558
-	/**
559
-	 * @param string $entry
560
-	 * @return string
561
-	 * @throws IncomingRequestException
562
-	 */
563
-	private function getHostFromFederationId(string $entry): string {
564
-		if (!str_contains($entry, '@')) {
565
-			throw new IncomingRequestException('entry ' . $entry . ' does not contain @');
566
-		}
567
-		$rightPart = substr($entry, strrpos($entry, '@') + 1);
568
-
569
-		// in case the full scheme is sent; getting rid of it
570
-		$rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart);
571
-		try {
572
-			return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart);
573
-		} catch (IdentityNotFoundException) {
574
-			throw new IncomingRequestException('invalid host within federation id: ' . $entry);
575
-		}
576
-	}
62
+    public function __construct(
63
+        string $appName,
64
+        IRequest $request,
65
+        private LoggerInterface $logger,
66
+        private IUserManager $userManager,
67
+        private IGroupManager $groupManager,
68
+        private IURLGenerator $urlGenerator,
69
+        private ICloudFederationProviderManager $cloudFederationProviderManager,
70
+        private Config $config,
71
+        private IEventDispatcher $dispatcher,
72
+        private FederatedInviteMapper $federatedInviteMapper,
73
+        private readonly AddressHandler $addressHandler,
74
+        private readonly IAppConfig $appConfig,
75
+        private ICloudFederationFactory $factory,
76
+        private ICloudIdManager $cloudIdManager,
77
+        private readonly ISignatureManager $signatureManager,
78
+        private readonly OCMSignatoryManager $signatoryManager,
79
+        private ITimeFactory $timeFactory,
80
+    ) {
81
+        parent::__construct($appName, $request);
82
+    }
83
+
84
+    /**
85
+     * Add share
86
+     *
87
+     * @param string $shareWith The user who the share will be shared with
88
+     * @param string $name The resource name (e.g. document.odt)
89
+     * @param string|null $description Share description
90
+     * @param string $providerId Resource UID on the provider side
91
+     * @param string $owner Provider specific UID of the user who owns the resource
92
+     * @param string|null $ownerDisplayName Display name of the user who shared the item
93
+     * @param string|null $sharedBy Provider specific UID of the user who shared the resource
94
+     * @param string|null $sharedByDisplayName Display name of the user who shared the resource
95
+     * @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]
96
+     * @param string $shareType 'group' or 'user' share
97
+     * @param string $resourceType 'file', 'calendar',...
98
+     *
99
+     * @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
100
+     *
101
+     * 201: The notification was successfully received. The display name of the recipient might be returned in the body
102
+     * 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing
103
+     * 501: Share type or the resource type is not supported
104
+     */
105
+    #[PublicPage]
106
+    #[NoCSRFRequired]
107
+    #[BruteForceProtection(action: 'receiveFederatedShare')]
108
+    public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
109
+        if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
110
+            try {
111
+                // if request is signed and well signed, no exception are thrown
112
+                // if request is not signed and host is known for not supporting signed request, no exception are thrown
113
+                $signedRequest = $this->getSignedRequest();
114
+                $this->confirmSignedOrigin($signedRequest, 'owner', $owner);
115
+            } catch (IncomingRequestException $e) {
116
+                $this->logger->warning('incoming request exception', ['exception' => $e]);
117
+                return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
118
+            }
119
+        }
120
+
121
+        // check if all required parameters are set
122
+        if (
123
+            $shareWith === null
124
+            || $name === null
125
+            || $providerId === null
126
+            || $resourceType === null
127
+            || $shareType === null
128
+            || !is_array($protocol)
129
+            || !isset($protocol['name'])
130
+            || !isset($protocol['options'])
131
+            || !is_array($protocol['options'])
132
+            || !isset($protocol['options']['sharedSecret'])
133
+        ) {
134
+            return new JSONResponse(
135
+                [
136
+                    'message' => 'Missing arguments',
137
+                    'validationErrors' => [],
138
+                ],
139
+                Http::STATUS_BAD_REQUEST
140
+            );
141
+        }
142
+
143
+        $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType);
144
+        if (!in_array($shareType, $supportedShareTypes)) {
145
+            return new JSONResponse(
146
+                ['message' => 'Share type "' . $shareType . '" not implemented'],
147
+                Http::STATUS_NOT_IMPLEMENTED
148
+            );
149
+        }
150
+
151
+        $cloudId = $this->cloudIdManager->resolveCloudId($shareWith);
152
+        $shareWith = $cloudId->getUser();
153
+
154
+        if ($shareType === 'user') {
155
+            $shareWith = $this->mapUid($shareWith);
156
+
157
+            if (!$this->userManager->userExists($shareWith)) {
158
+                $response = new JSONResponse(
159
+                    [
160
+                        'message' => 'User "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
161
+                        'validationErrors' => [],
162
+                    ],
163
+                    Http::STATUS_BAD_REQUEST
164
+                );
165
+                $response->throttle();
166
+                return $response;
167
+            }
168
+        }
169
+
170
+        if ($shareType === 'group') {
171
+            if (!$this->groupManager->groupExists($shareWith)) {
172
+                $response = new JSONResponse(
173
+                    [
174
+                        'message' => 'Group "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
175
+                        'validationErrors' => [],
176
+                    ],
177
+                    Http::STATUS_BAD_REQUEST
178
+                );
179
+                $response->throttle();
180
+                return $response;
181
+            }
182
+        }
183
+
184
+        // if no explicit display name is given, we use the uid as display name
185
+        $ownerDisplayName = $ownerDisplayName === null ? $owner : $ownerDisplayName;
186
+        $sharedByDisplayName = $sharedByDisplayName === null ? $sharedBy : $sharedByDisplayName;
187
+
188
+        // sharedBy* parameter is optional, if nothing is set we assume that it is the same user as the owner
189
+        if ($sharedBy === null) {
190
+            $sharedBy = $owner;
191
+            $sharedByDisplayName = $ownerDisplayName;
192
+        }
193
+
194
+        try {
195
+            $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
196
+            $share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType);
197
+            $share->setProtocol($protocol);
198
+            $provider->shareReceived($share);
199
+        } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) {
200
+            return new JSONResponse(
201
+                ['message' => $e->getMessage()],
202
+                Http::STATUS_NOT_IMPLEMENTED
203
+            );
204
+        } catch (\Exception $e) {
205
+            $this->logger->error($e->getMessage(), ['exception' => $e]);
206
+            return new JSONResponse(
207
+                [
208
+                    'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
209
+                    'validationErrors' => [],
210
+                ],
211
+                Http::STATUS_BAD_REQUEST
212
+            );
213
+        }
214
+
215
+        $responseData = ['recipientDisplayName' => ''];
216
+        if ($shareType === 'user') {
217
+            $user = $this->userManager->get($shareWith);
218
+            if ($user) {
219
+                $responseData = [
220
+                    'recipientDisplayName' => $user->getDisplayName(),
221
+                    'recipientUserId' => $user->getUID(),
222
+                ];
223
+            }
224
+        }
225
+
226
+        return new JSONResponse($responseData, Http::STATUS_CREATED);
227
+    }
228
+
229
+    /**
230
+     * Inform the sender that an invitation was accepted to start sharing
231
+     *
232
+     * Inform about an accepted invitation so the user on the sender provider's side
233
+     * can initiate the OCM share creation. To protect the identity of the parties,
234
+     * for shares created following an OCM invitation, the user id MAY be hashed,
235
+     * and recipients implementing the OCM invitation workflow MAY refuse to process
236
+     * shares coming from unknown parties.
237
+     * @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
238
+     *
239
+     * @param string $recipientProvider The address of the recipent's provider
240
+     * @param string $token The token used for the invitation
241
+     * @param string $userID The userID of the recipient at the recipient's provider
242
+     * @param string $email The email address of the recipient
243
+     * @param string $name The display name of the recipient
244
+     *
245
+     * @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{}>
246
+     *
247
+     * Note: Not implementing 404 Invitation token does not exist, instead using 400
248
+     * 200: Invitation accepted
249
+     * 400: Invalid token
250
+     * 403: Invitation token does not exist
251
+     * 409: User is already known by the OCM provider
252
+     */
253
+    #[PublicPage]
254
+    #[NoCSRFRequired]
255
+    #[BruteForceProtection(action: 'inviteAccepted')]
256
+    public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
257
+        $this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
258
+
259
+        $updated = $this->timeFactory->getTime();
260
+
261
+        if ($token === '') {
262
+            $response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
263
+            $response->throttle();
264
+            return $response;
265
+        }
266
+
267
+        try {
268
+            $invitation = $this->federatedInviteMapper->findByToken($token);
269
+        } catch (DoesNotExistException) {
270
+            $response = ['message' => 'Invalid or non existing token', 'error' => true];
271
+            $status = Http::STATUS_BAD_REQUEST;
272
+            $response = new JSONResponse($response, $status);
273
+            $response->throttle();
274
+            return $response;
275
+        }
276
+
277
+        if ($invitation->isAccepted() === true) {
278
+            $response = ['message' => 'Invite already accepted', 'error' => true];
279
+            $status = Http::STATUS_CONFLICT;
280
+            return new JSONResponse($response, $status);
281
+        }
282
+
283
+        if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
284
+            $response = ['message' => 'Invitation expired', 'error' => true];
285
+            $status = Http::STATUS_BAD_REQUEST;
286
+            return new JSONResponse($response, $status);
287
+        }
288
+        $localUser = $this->userManager->get($invitation->getUserId());
289
+        if ($localUser === null) {
290
+            $response = ['message' => 'Invalid or non existing token', 'error' => true];
291
+            $status = Http::STATUS_BAD_REQUEST;
292
+            $response = new JSONResponse($response, $status);
293
+            $response->throttle();
294
+            return $response;
295
+        }
296
+
297
+        $sharedFromEmail = $localUser->getEMailAddress();
298
+        if ($sharedFromEmail === null) {
299
+            $response = ['message' => 'Invalid or non existing token', 'error' => true];
300
+            $status = Http::STATUS_BAD_REQUEST;
301
+            $response = new JSONResponse($response, $status);
302
+            $response->throttle();
303
+            return $response;
304
+        }
305
+        $sharedFromDisplayName = $localUser->getDisplayName();
306
+
307
+        $response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
308
+        $status = Http::STATUS_OK;
309
+
310
+        $invitation->setAccepted(true);
311
+        $invitation->setRecipientEmail($email);
312
+        $invitation->setRecipientName($name);
313
+        $invitation->setRecipientProvider($recipientProvider);
314
+        $invitation->setRecipientUserId($userID);
315
+        $invitation->setAcceptedAt($updated);
316
+        $invitation = $this->federatedInviteMapper->update($invitation);
317
+
318
+        $event = new FederatedInviteAcceptedEvent($invitation);
319
+        $this->dispatcher->dispatchTyped($event);
320
+
321
+        return new JSONResponse($response, $status);
322
+    }
323
+
324
+    /**
325
+     * Send a notification about an existing share
326
+     *
327
+     * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED
328
+     * @param string $resourceType calendar, file, contact,...
329
+     * @param string|null $providerId ID of the share
330
+     * @param array<string, mixed>|null $notification The actual payload of the notification
331
+     *
332
+     * @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{}>
333
+     *
334
+     * 201: The notification was successfully received
335
+     * 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing
336
+     * 403: Getting resource is not allowed
337
+     * 501: The resource type is not supported
338
+     */
339
+    #[NoCSRFRequired]
340
+    #[PublicPage]
341
+    #[BruteForceProtection(action: 'receiveFederatedShareNotification')]
342
+    public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
343
+        // check if all required parameters are set
344
+        if (
345
+            $notificationType === null
346
+            || $resourceType === null
347
+            || $providerId === null
348
+            || !is_array($notification)
349
+        ) {
350
+            return new JSONResponse(
351
+                [
352
+                    'message' => 'Missing arguments',
353
+                    'validationErrors' => [],
354
+                ],
355
+                Http::STATUS_BAD_REQUEST
356
+            );
357
+        }
358
+
359
+        if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
360
+            try {
361
+                // if request is signed and well signed, no exception are thrown
362
+                // if request is not signed and host is known for not supporting signed request, no exception are thrown
363
+                $signedRequest = $this->getSignedRequest();
364
+                $this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
365
+            } catch (IncomingRequestException $e) {
366
+                $this->logger->warning('incoming request exception', ['exception' => $e]);
367
+                return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
368
+            }
369
+        }
370
+
371
+        try {
372
+            $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
373
+            $result = $provider->notificationReceived($notificationType, $providerId, $notification);
374
+        } catch (ProviderDoesNotExistsException $e) {
375
+            return new JSONResponse(
376
+                [
377
+                    'message' => $e->getMessage(),
378
+                    'validationErrors' => [],
379
+                ],
380
+                Http::STATUS_BAD_REQUEST
381
+            );
382
+        } catch (ShareNotFound $e) {
383
+            $response = new JSONResponse(
384
+                [
385
+                    'message' => $e->getMessage(),
386
+                    'validationErrors' => [],
387
+                ],
388
+                Http::STATUS_BAD_REQUEST
389
+            );
390
+            $response->throttle();
391
+            return $response;
392
+        } catch (ActionNotSupportedException $e) {
393
+            return new JSONResponse(
394
+                ['message' => $e->getMessage()],
395
+                Http::STATUS_NOT_IMPLEMENTED
396
+            );
397
+        } catch (BadRequestException $e) {
398
+            return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST);
399
+        } catch (AuthenticationFailedException $e) {
400
+            $response = new JSONResponse(['message' => 'RESOURCE_NOT_FOUND'], Http::STATUS_FORBIDDEN);
401
+            $response->throttle();
402
+            return $response;
403
+        } catch (\Exception $e) {
404
+            $this->logger->warning('incoming notification exception', ['exception' => $e]);
405
+            return new JSONResponse(
406
+                [
407
+                    'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
408
+                    'validationErrors' => [],
409
+                ],
410
+                Http::STATUS_BAD_REQUEST
411
+            );
412
+        }
413
+
414
+        return new JSONResponse($result, Http::STATUS_CREATED);
415
+    }
416
+
417
+    /**
418
+     * map login name to internal LDAP UID if a LDAP backend is in use
419
+     *
420
+     * @param string $uid
421
+     * @return string mixed
422
+     */
423
+    private function mapUid($uid) {
424
+        // FIXME this should be a method in the user management instead
425
+        $this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]);
426
+        Util::emitHook(
427
+            '\OCA\Files_Sharing\API\Server2Server',
428
+            'preLoginNameUsedAsUserName',
429
+            ['uid' => &$uid]
430
+        );
431
+        $this->logger->debug('shareWith after, ' . $uid, ['app' => $this->appName]);
432
+
433
+        return $uid;
434
+    }
435
+
436
+
437
+    /**
438
+     * returns signed request if available.
439
+     * throw an exception:
440
+     * - if request is signed, but wrongly signed
441
+     * - if request is not signed but instance is configured to only accept signed ocm request
442
+     *
443
+     * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request
444
+     * @throws IncomingRequestException
445
+     */
446
+    private function getSignedRequest(): ?IIncomingSignedRequest {
447
+        try {
448
+            $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
449
+            $this->logger->debug('signed request available', ['signedRequest' => $signedRequest]);
450
+            return $signedRequest;
451
+        } catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
452
+            $this->logger->debug('remote does not support signed request', ['exception' => $e]);
453
+            // remote does not support signed request.
454
+            // currently we still accept unsigned request until lazy appconfig
455
+            // core.enforce_signed_ocm_request is set to true (default: false)
456
+            if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
457
+                $this->logger->notice('ignored unsigned request', ['exception' => $e]);
458
+                throw new IncomingRequestException('Unsigned request');
459
+            }
460
+        } catch (SignatureException $e) {
461
+            $this->logger->warning('wrongly signed request', ['exception' => $e]);
462
+            throw new IncomingRequestException('Invalid signature');
463
+        }
464
+        return null;
465
+    }
466
+
467
+
468
+    /**
469
+     * confirm that the value related to $key entry from the payload is in format userid@hostname
470
+     * and compare hostname with the origin of the signed request.
471
+     *
472
+     * If request is not signed, we still verify that the hostname from the extracted value does,
473
+     * actually, not support signed request
474
+     *
475
+     * @param IIncomingSignedRequest|null $signedRequest
476
+     * @param string $key entry from data available in data
477
+     * @param string $value value itself used in case request is not signed
478
+     *
479
+     * @throws IncomingRequestException
480
+     */
481
+    private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
482
+        if ($signedRequest === null) {
483
+            $instance = $this->getHostFromFederationId($value);
484
+            try {
485
+                $this->signatureManager->getSignatory($instance);
486
+                throw new IncomingRequestException('instance is supposed to sign its request');
487
+            } catch (SignatoryNotFoundException) {
488
+                return;
489
+            }
490
+        }
491
+
492
+        $body = json_decode($signedRequest->getBody(), true) ?? [];
493
+        $entry = trim($body[$key] ?? '', '@');
494
+        if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) {
495
+            throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']');
496
+        }
497
+    }
498
+
499
+    /**
500
+     *  confirm identity of the remote instance on notification, based on the share token.
501
+     *
502
+     *  If request is not signed, we still verify that the hostname from the extracted value does,
503
+     *  actually, not support signed request
504
+     *
505
+     * @param IIncomingSignedRequest|null $signedRequest
506
+     * @param string $resourceType
507
+     *
508
+     * @throws IncomingRequestException
509
+     * @throws BadRequestException
510
+     */
511
+    private function confirmNotificationIdentity(
512
+        ?IIncomingSignedRequest $signedRequest,
513
+        string $resourceType,
514
+        array $notification,
515
+    ): void {
516
+        $sharedSecret = $notification['sharedSecret'] ?? '';
517
+        if ($sharedSecret === '') {
518
+            throw new BadRequestException(['sharedSecret']);
519
+        }
520
+
521
+        try {
522
+            $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
523
+            if ($provider instanceof ISignedCloudFederationProvider) {
524
+                $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
525
+            } else {
526
+                $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
527
+                return;
528
+            }
529
+        } catch (\Exception $e) {
530
+            throw new IncomingRequestException($e->getMessage(), previous: $e);
531
+        }
532
+
533
+        $this->confirmNotificationEntry($signedRequest, $identity);
534
+    }
535
+
536
+
537
+    /**
538
+     * @param IIncomingSignedRequest|null $signedRequest
539
+     * @param string $entry
540
+     *
541
+     * @return void
542
+     * @throws IncomingRequestException
543
+     */
544
+    private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
545
+        $instance = $this->getHostFromFederationId($entry);
546
+        if ($signedRequest === null) {
547
+            try {
548
+                $this->signatureManager->getSignatory($instance);
549
+                throw new IncomingRequestException('instance is supposed to sign its request');
550
+            } catch (SignatoryNotFoundException) {
551
+                return;
552
+            }
553
+        } elseif ($instance !== $signedRequest->getOrigin()) {
554
+            throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin());
555
+        }
556
+    }
557
+
558
+    /**
559
+     * @param string $entry
560
+     * @return string
561
+     * @throws IncomingRequestException
562
+     */
563
+    private function getHostFromFederationId(string $entry): string {
564
+        if (!str_contains($entry, '@')) {
565
+            throw new IncomingRequestException('entry ' . $entry . ' does not contain @');
566
+        }
567
+        $rightPart = substr($entry, strrpos($entry, '@') + 1);
568
+
569
+        // in case the full scheme is sent; getting rid of it
570
+        $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart);
571
+        try {
572
+            return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart);
573
+        } catch (IdentityNotFoundException) {
574
+            throw new IncomingRequestException('invalid host within federation id: ' . $entry);
575
+        }
576
+    }
577 577
 }
Please login to merge, or discard this patch.