diff --git a/3rdparty b/3rdparty index e1dc48ae9d9b7..8f97d8cef37b3 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit e1dc48ae9d9b7b1e8c7b59d1c8cc3b6a9c50f83a +Subproject commit 8f97d8cef37b32d25e36c16fc2f7be36f1a46901 diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index bfccb2fe20eaf..9ee30927383fd 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -7,11 +7,13 @@ namespace OCA\CloudFederationAPI\Controller; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; use OCA\CloudFederationAPI\ResponseDefinitions; +use OCA\DAV\Db\OcmTokenMapMapper; use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -43,6 +45,7 @@ use OCP\Security\Signature\Exceptions\SignatoryNotFoundException; use OCP\Security\Signature\IIncomingSignedRequest; use OCP\Security\Signature\ISignatureManager; +use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Util; use Psr\Log\LoggerInterface; @@ -91,7 +94,7 @@ public function __construct( * @param string|null $ownerDisplayName Display name of the user who shared the item * @param string|null $sharedBy Provider specific UID of the user who shared the resource * @param string|null $sharedByDisplayName Display name of the user who shared the resource - * @param array{name: list, options: array} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]] + * @param array{name: string, options?: array, webdav?: array} $protocol Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]] * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * @@ -126,9 +129,6 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ || $shareType === null || !is_array($protocol) || !isset($protocol['name']) - || !isset($protocol['options']) - || !is_array($protocol['options']) - || !isset($protocol['options']['sharedSecret']) ) { return new JSONResponse( [ @@ -139,6 +139,33 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ ); } + $protocolName = $protocol['name']; + $hasOldFormat = isset($protocol['options']) && is_array($protocol['options']) && isset($protocol['options']['sharedSecret']); + $hasNewFormat = isset($protocol[$protocolName]) && is_array($protocol[$protocolName]) && isset($protocol[$protocolName]['sharedSecret']); + + // For multi-protocol, we only consider webdav + $hasMultiFormat = false; + if ($protocolName === 'multi') { + if (isset($protocol['webdav']) && is_array($protocol['webdav']) && isset($protocol['webdav']['sharedSecret'])) { + $hasMultiFormat = true; + $protocol = [ + 'name' => 'webdav', + 'webdav' => $protocol['webdav'] + ]; + $protocolName = 'webdav'; + } + } + + if (!$hasOldFormat && !$hasNewFormat && !$hasMultiFormat) { + return new JSONResponse( + [ + 'message' => 'Missing sharedSecret in protocol', + 'validationErrors' => [], + ], + Http::STATUS_BAD_REQUEST + ); + } + $supportedShareTypes = $this->config->getSupportedShareTypes($resourceType); if (!in_array($shareType, $supportedShareTypes)) { return new JSONResponse( @@ -148,6 +175,7 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ } $cloudId = $this->cloudIdManager->resolveCloudId($shareWith); + $shareWithCloudId = $shareWith; // preserve full cloud ID for factory capability discovery $shareWith = $cloudId->getUser(); if ($shareType === 'user') { @@ -192,7 +220,10 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ try { $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); - $share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); + // Pass the original cloud ID so the factory can discover capabilities without warning. + // Then reset shareWith to the local username that shareReceived() needs for user lookup. + $share = $this->factory->getCloudFederationShare($shareWithCloudId, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType); + $share->setShareWith($shareWith); $share->setProtocol($protocol); $provider->shareReceived($share); } catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) { @@ -490,6 +521,12 @@ private function confirmNotificationIdentity( $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); if ($provider instanceof ISignedCloudFederationProvider) { $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification); + if ($identity === '') { + $tokenProvider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $tokenProvider->getToken($sharedSecret); + $mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId()); + $identity = $provider->getFederationIdFromSharedSecret($mapping->getRefreshToken(), $notification); + } } else { $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]); return; diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index 21f669a2c5f79..a284ee1fcce1b 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -161,23 +161,25 @@ }, "protocol": { "type": "object", - "description": "e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]", + "description": "Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]]", "required": [ - "name", - "options" + "name" ], "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, "options": { "type": "object", "additionalProperties": { "type": "object" } + }, + "webdav": { + "type": "object", + "additionalProperties": { + "type": "object" + } } } }, diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 6f17473623d6d..1a040c251b26e 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -31,6 +31,7 @@ OCA\DAV\BackgroundJob\CalendarRetentionJob OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob OCA\DAV\BackgroundJob\FederatedCalendarPeriodicSyncJob + OCA\DAV\BackgroundJob\CleanupExpiredOcmTokensJob diff --git a/apps/dav/appinfo/v1/publicwebdav.php b/apps/dav/appinfo/v1/publicwebdav.php index 7b576496cc89f..6788933ad1473 100644 --- a/apps/dav/appinfo/v1/publicwebdav.php +++ b/apps/dav/appinfo/v1/publicwebdav.php @@ -9,6 +9,7 @@ use OC\Files\Storage\Wrapper\PermissionsMask; use OC\Files\View; use OCA\DAV\Connector\LegacyPublicAuth; +use OCA\DAV\Connector\Sabre\BearerAuth; use OCA\DAV\Connector\Sabre\ServerFactory; use OCA\DAV\Files\Sharing\FilesDropPlugin; use OCA\DAV\Files\Sharing\PublicLinkCheckPlugin; @@ -49,7 +50,14 @@ Server::get(ISession::class), Server::get(IThrottler::class) ); +$bearerAuthBackend = new BearerAuth( + Server::get(IUserSession::class), + Server::get(ISession::class), + Server::get(IRequest::class), + Server::get(IConfig::class), +); $authPlugin = new \Sabre\DAV\Auth\Plugin($authBackend); +$authPlugin->addBackend($bearerAuthBackend); /** @var IEventDispatcher $eventDispatcher */ $eventDispatcher = Server::get(IEventDispatcher::class); @@ -80,6 +88,7 @@ $authPlugin, function (\Sabre\DAV\Server $server) use ( $authBackend, + $bearerAuthBackend, $linkCheckPlugin, $filesDropPlugin ) { @@ -90,8 +99,11 @@ function (\Sabre\DAV\Server $server) use ( // this is what is thrown when trying to access a non-existing share throw new \Sabre\DAV\Exception\NotAuthenticated(); } - - $share = $authBackend->getShare(); + try { + $share = $authBackend->getShare(); + } catch (AssertionError $e) { + $share = $bearerAuthBackend->getShare(); + } $isReadable = $share->getPermissions() & Constants::PERMISSION_READ; $fileId = $share->getNodeId(); diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 361a57cee407b..d9678a504cc3a 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -15,6 +15,7 @@ 'OCA\\DAV\\BackgroundJob\\BuildReminderIndexBackgroundJob' => $baseDir . '/../lib/BackgroundJob/BuildReminderIndexBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\CalendarRetentionJob' => $baseDir . '/../lib/BackgroundJob/CalendarRetentionJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupDirectLinksJob' => $baseDir . '/../lib/BackgroundJob/CleanupDirectLinksJob.php', + 'OCA\\DAV\\BackgroundJob\\CleanupExpiredOcmTokensJob' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => $baseDir . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => $baseDir . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php', 'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => $baseDir . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php', @@ -263,6 +264,7 @@ 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php', + 'OCA\\DAV\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => $baseDir . '/../lib/Controller/UpcomingEventsController.php', 'OCA\\DAV\\DAV\\CustomPropertiesBackend' => $baseDir . '/../lib/DAV/CustomPropertiesBackend.php', 'OCA\\DAV\\DAV\\GroupPrincipalBackend' => $baseDir . '/../lib/DAV/GroupPrincipalBackend.php', @@ -281,6 +283,8 @@ 'OCA\\DAV\\Db\\AbsenceMapper' => $baseDir . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => $baseDir . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => $baseDir . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php', + 'OCA\\DAV\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php', 'OCA\\DAV\\Db\\Property' => $baseDir . '/../lib/Db/Property.php', 'OCA\\DAV\\Db\\PropertyMapper' => $baseDir . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => $baseDir . '/../lib/Direct/DirectFile.php', @@ -391,6 +395,7 @@ 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => $baseDir . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => $baseDir . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 959860e3312a8..fedfcb98076dc 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -30,6 +30,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\BackgroundJob\\BuildReminderIndexBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/BuildReminderIndexBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\CalendarRetentionJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CalendarRetentionJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupDirectLinksJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupDirectLinksJob.php', + 'OCA\\DAV\\BackgroundJob\\CleanupExpiredOcmTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php', 'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php', @@ -278,6 +279,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php', + 'OCA\\DAV\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => __DIR__ . '/..' . '/../lib/Controller/UpcomingEventsController.php', 'OCA\\DAV\\DAV\\CustomPropertiesBackend' => __DIR__ . '/..' . '/../lib/DAV/CustomPropertiesBackend.php', 'OCA\\DAV\\DAV\\GroupPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/GroupPrincipalBackend.php', @@ -296,6 +298,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Db\\AbsenceMapper' => __DIR__ . '/..' . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => __DIR__ . '/..' . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => __DIR__ . '/..' . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php', + 'OCA\\DAV\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php', 'OCA\\DAV\\Db\\Property' => __DIR__ . '/..' . '/../lib/Db/Property.php', 'OCA\\DAV\\Db\\PropertyMapper' => __DIR__ . '/..' . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => __DIR__ . '/..' . '/../lib/Direct/DirectFile.php', @@ -406,6 +410,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php', + 'OCA\\DAV\\Migration\\Version1037Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1037Date20260306120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php new file mode 100644 index 0000000000000..32f7c06c43738 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/CleanupExpiredOcmTokensJob.php @@ -0,0 +1,36 @@ +setInterval(6 * 60 * 60); // run every 6 hours + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + protected function run($argument): void { + $this->mapper->deleteExpired($this->time->getTime()); + } +} diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 23453ae8efbab..c4bb0121ce98e 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -12,6 +12,9 @@ use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; +use OCP\Server; +use OCP\Share\IManager; +use OCP\Share\IShare; use Sabre\DAV\Auth\Backend\AbstractBearer; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -23,6 +26,7 @@ public function __construct( private IRequest $request, private IConfig $config, private string $principalPrefix = 'principals/users/', + private string $token = '', ) { // setup realm $defaults = new Defaults(); @@ -40,17 +44,26 @@ private function setupUserFs($userId) { */ public function validateBearerToken($bearerToken) { \OC_Util::setupFS(); + $this->token = $bearerToken; - if (!$this->userSession->isLoggedIn()) { + $loggedIn = $this->userSession->isLoggedIn(); + if (!$loggedIn) { $this->userSession->tryTokenLogin($this->request); + $loggedIn = $this->userSession->isLoggedIn(); } - if ($this->userSession->isLoggedIn()) { + if ($loggedIn) { return $this->setupUserFs($this->userSession->getUser()->getUID()); } return false; } + public function getShare(): IShare { + $shareManager = Server::get(IManager::class); + $share = $shareManager->getShareByToken($this->token); + return $share; + } + /** * \Sabre\DAV\Auth\Backend\AbstractBearer::challenge sets an WWW-Authenticate * header which some DAV clients can't handle. Thus we override this function diff --git a/apps/dav/lib/Controller/TokenController.php b/apps/dav/lib/Controller/TokenController.php new file mode 100644 index 0000000000000..76265543f2359 --- /dev/null +++ b/apps/dav/lib/Controller/TokenController.php @@ -0,0 +1,214 @@ +signatureManager->getIncomingSignedRequest($this->signatoryManager); + $this->logger->debug('Token request signature verified', [ + 'origin' => $signedRequest->getOrigin() + ]); + return $signedRequest; + } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { + $this->logger->debug('Token request not signed', ['exception' => $e]); + + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { + $this->logger->notice('Rejected unsigned token request', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request not allowed'); + } + return null; + } catch (SignatureException $e) { + $this->logger->warning('Invalid token request signature', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature'); + } + } + + /** + * Exchange a refresh token for a short-lived access token + * + * @return DataResponse|DataResponse + * + * 200: Access token successfully generated + * 400: Bad request - missing refresh token or invalid request format + * 401: Unauthorized - invalid or expired refresh token, or invalid signature + * 500: Internal server error + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')] + public function accessToken(): DataResponse { + try { + $signedRequest = $this->verifySignedRequest(); + } catch (IncomingRequestException $e) { + $this->logger->warning('Token request signature verification failed', [ + 'exception' => $e + ]); + return new DataResponse( + ['error' => 'invalid_request'], + Http::STATUS_UNAUTHORIZED + ); + } + + $body = file_get_contents('php://input'); + parse_str($body, $data); + + $refreshToken = $data['code'] ?? ''; + $grantType = $data['grant_type'] ?? ''; + + if ($grantType !== 'authorization_code') { + return new DataResponse( + ['error' => 'unsupported_grant_type'], + Http::STATUS_BAD_REQUEST + ); + } + + if (empty($refreshToken)) { + return new DataResponse( + ['error' => 'refresh_token is required'], + Http::STATUS_BAD_REQUEST + ); + } + + try { + $token = $this->tokenProvider->getToken($refreshToken); + + if ($token->getType() !== IToken::PERMANENT_TOKEN) { + $this->logger->warning('Attempted to use non-permanent token as refresh token', [ + 'tokenId' => $token->getId(), + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } + + // Revoke the previous access token for this refresh token, if any. + $existingMapping = $this->ocmTokenMapMapper->findByRefreshToken($refreshToken); + if ($existingMapping !== null) { + try { + $this->tokenProvider->invalidateTokenById( + $token->getUID(), + $existingMapping->getAccessTokenId() + ); + } catch (\Exception) { + // Token may already be gone; ignore. + } + $this->ocmTokenMapMapper->delete($existingMapping); + } + + $accessTokenString = $this->random->generate( + 64, + ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS + ); + + $expiresIn = 3600; // 1 hour in seconds + $expiresAt = $this->timeFactory->getTime() + $expiresIn; + + $accessToken = $this->tokenProvider->generateToken( + $accessTokenString, + $token->getUID(), + $token->getLoginName(), + null, // No password for access tokens + 'OCM Access Token', + IToken::TEMPORARY_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + $accessToken->setExpires($expiresAt); + $this->tokenProvider->updateToken($accessToken); + + $mapping = new OcmTokenMap(); + $mapping->setAccessTokenId($accessToken->getId()); + $mapping->setRefreshToken($refreshToken); + $mapping->setExpires($expiresAt); + $this->ocmTokenMapMapper->insert($mapping); + + return new DataResponse([ + 'access_token' => $accessTokenString, + 'token_type' => 'Bearer', + 'expires_in' => $expiresIn, + ], Http::STATUS_OK); + } catch (InvalidTokenException $e) { + $this->logger->info('Invalid refresh token provided', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } catch (ExpiredTokenException $e) { + $this->logger->info('Expired refresh token provided', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'invalid_grant'], + Http::STATUS_UNAUTHORIZED + ); + } catch (\Exception $e) { + $this->logger->error('Error generating access token', [ + 'exception' => $e, + ]); + return new DataResponse( + ['error' => 'server_error'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/apps/dav/lib/Db/OcmTokenMap.php b/apps/dav/lib/Db/OcmTokenMap.php new file mode 100644 index 0000000000000..afd91ffc225a3 --- /dev/null +++ b/apps/dav/lib/Db/OcmTokenMap.php @@ -0,0 +1,41 @@ +addType('accessTokenId', Types::INTEGER); + $this->addType('refreshToken', Types::STRING); + $this->addType('expires', Types::INTEGER); + } +} diff --git a/apps/dav/lib/Db/OcmTokenMapMapper.php b/apps/dav/lib/Db/OcmTokenMapMapper.php new file mode 100644 index 0000000000000..535e80b0b131a --- /dev/null +++ b/apps/dav/lib/Db/OcmTokenMapMapper.php @@ -0,0 +1,58 @@ + + */ +class OcmTokenMapMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'dav_ocm_token_map', OcmTokenMap::class); + } + + /** + * @throws DoesNotExistException + */ + public function getByAccessTokenId(int $accessTokenId): OcmTokenMap { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('access_token_id', $qb->createNamedParameter($accessTokenId))); + + return $this->findEntity($qb); + } + + /** + * Find the current mapping for a given refresh token, if any. + */ + public function findByRefreshToken(string $refreshToken): ?OcmTokenMap { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('refresh_token', $qb->createNamedParameter($refreshToken))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } + } + + public function deleteExpired(int $time): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('expires', $qb->createNamedParameter($time))); + $qb->executeStatement(); + } +} diff --git a/apps/dav/lib/Migration/Version1037Date20260306120000.php b/apps/dav/lib/Migration/Version1037Date20260306120000.php new file mode 100644 index 0000000000000..04fe9000da0de --- /dev/null +++ b/apps/dav/lib/Migration/Version1037Date20260306120000.php @@ -0,0 +1,53 @@ +hasTable('dav_ocm_token_map')) { + return null; + } + + $table = $schema->createTable('dav_ocm_token_map'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('access_token_id', Types::INTEGER, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('refresh_token', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('expires', Types::INTEGER, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['access_token_id'], 'dav_ocm_tkmap_atid'); + $table->addIndex(['expires'], 'dav_ocm_tkmap_exp'); + + return $schema; + } +} diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 344d37815318c..8e7f6b548e069 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1165,5 +1165,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "token", + "description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens" + } + ] } diff --git a/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php b/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php new file mode 100644 index 0000000000000..aeda2171fb58e --- /dev/null +++ b/apps/dav/tests/unit/BackgroundJob/CleanupExpiredOcmTokensJobTest.php @@ -0,0 +1,44 @@ +timeFactory = $this->createMock(ITimeFactory::class); + $this->mapper = $this->createMock(OcmTokenMapMapper::class); + + $this->job = new CleanupExpiredOcmTokensJob($this->timeFactory, $this->mapper); + } + + public function testRunDeletesExpiredTokens(): void { + $now = 1700000000; + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('deleteExpired') + ->with($now); + + $method = new \ReflectionMethod(CleanupExpiredOcmTokensJob::class, 'run'); + $method->invoke($this->job, []); + } +} diff --git a/apps/dav/tests/unit/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php new file mode 100644 index 0000000000000..50e04aac47ac8 --- /dev/null +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -0,0 +1,390 @@ +request = $this->createMock(IRequest::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->random = $this->createMock(ISecureRandom::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->ocmTokenMapMapper = $this->createMock(OcmTokenMapMapper::class); + + $this->controller = new TokenController( + $this->request, + $this->tokenProvider, + $this->random, + $this->timeFactory, + $this->logger, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + $this->ocmTokenMapMapper, + ); + } + + public function testAccessTokenSuccess(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->with($this->signatoryManager) + ->willReturn($signedRequest); + + $refreshTokenMock = $this->createMock(IToken::class); + $refreshTokenMock->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshTokenMock->method('getId')->willReturn(123); + $refreshTokenMock->method('getUID')->willReturn('testuser'); + $refreshTokenMock->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken') + ->with('valid-refresh-token') + ->willReturn($refreshTokenMock); + + $this->random->method('generate') + ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) + ->willReturn('generated-access-token'); + + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $accessToken->method('getId')->willReturn(456); + $this->tokenProvider->method('generateToken') + ->with( + 'generated-access-token', + 'testuser', + 'testuser', + null, + 'OCM Access Token', + IToken::TEMPORARY_TOKEN, + IToken::DO_NOT_REMEMBER + ) + ->willReturn($accessToken); + + $accessToken->expects($this->once()) + ->method('setExpires') + ->with(1000000 + 3600); + + $this->tokenProvider->expects($this->once()) + ->method('updateToken') + ->with($accessToken); + + $this->ocmTokenMapMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function ($mapping) { + return $mapping->getAccessTokenId() === 456 + && $mapping->getRefreshToken() === 'valid-refresh-token' + && $mapping->getExpires() === 1000000 + 3600; + })); + + // Simulate POST body + $this->simulatePostBody('grant_type=authorization_code&code=valid-refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertInstanceOf(DataResponse::class, $result); + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $this->assertEquals([ + 'access_token' => 'generated-access-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], $result->getData()); + } + + public function testAccessTokenWithoutSignatureEnforcementDisabled(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(false); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken') + ->willReturn($refreshToken); + + $this->random->method('generate')->willReturn('generated-access-token'); + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $this->tokenProvider->method('generateToken')->willReturn($accessToken); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + } + + public function testAccessTokenWithoutSignatureEnforcementEnabled(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(true); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_request'], $result->getData()); + } + + public function testAccessTokenInvalidSignature(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatureException('Invalid signature')); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_request'], $result->getData()); + } + + public function testAccessTokenUnsupportedGrantType(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('grant_type=password&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'unsupported_grant_type'], $result->getData()); + } + + public function testAccessTokenMissingGrantType(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'unsupported_grant_type'], $result->getData()); + } + + public function testAccessTokenMissingRefreshToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->simulatePostBody('grant_type=authorization_code'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus()); + $this->assertEquals(['error' => 'refresh_token is required'], $result->getData()); + } + + public function testAccessTokenNonPermanentToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::TEMPORARY_TOKEN); + $refreshToken->method('getId')->willReturn(123); + + $this->tokenProvider->method('getToken') + ->with('non-permanent-token') + ->willReturn($refreshToken); + + $this->simulatePostBody('grant_type=authorization_code&code=non-permanent-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenInvalidToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->with('invalid-token') + ->willThrowException(new InvalidTokenException()); + + $this->simulatePostBody('grant_type=authorization_code&code=invalid-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenExpiredToken(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->with('expired-token') + ->willThrowException(new ExpiredTokenException($this->createMock(IToken::class))); + + $this->simulatePostBody('grant_type=authorization_code&code=expired-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + $this->assertEquals(['error' => 'invalid_grant'], $result->getData()); + } + + public function testAccessTokenServerError(): void { + $signedRequest = $this->createMock(IIncomingSignedRequest::class); + $signedRequest->method('getOrigin')->willReturn('remote.example.com'); + + $this->signatureManager->method('getIncomingSignedRequest') + ->willReturn($signedRequest); + + $this->tokenProvider->method('getToken') + ->willThrowException(new \RuntimeException('Database connection failed')); + + $this->simulatePostBody('grant_type=authorization_code&code=some-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $result->getStatus()); + $this->assertEquals(['error' => 'server_error'], $result->getData()); + } + + public function testAccessTokenWithSignatoryNotFoundException(): void { + $this->signatureManager->method('getIncomingSignedRequest') + ->willThrowException(new SignatoryNotFoundException()); + + $this->appConfig->method('getValueBool') + ->with('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, false, true) + ->willReturn(false); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken')->willReturn($refreshToken); + $this->random->method('generate')->willReturn('generated-access-token'); + $this->timeFactory->method('getTime')->willReturn(1000000); + + $accessToken = $this->createMock(IToken::class); + $this->tokenProvider->method('generateToken')->willReturn($accessToken); + + $this->simulatePostBody('grant_type=authorization_code&code=refresh-token'); + + $result = $this->controller->accessToken(); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + } + + private function simulatePostBody(string $body): void { + // We need to use a stream wrapper to simulate php://input + stream_wrapper_unregister('php'); + stream_wrapper_register('php', TestPhpInputStream::class); + TestPhpInputStream::$body = $body; + } + + protected function tearDown(): void { + // Restore the original php stream wrapper + stream_wrapper_restore('php'); + parent::tearDown(); + } +} + +/** + * Helper class to simulate php://input + */ +class TestPhpInputStream { + public static string $body = ''; + private int $position = 0; + public mixed $context = null; + + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { + if ($path === 'php://input') { + $this->position = 0; + return true; + } + return false; + } + + public function stream_read(int $count): string { + $result = substr(self::$body, $this->position, $count); + $this->position += strlen($result); + return $result; + } + + public function stream_eof(): bool { + return $this->position >= strlen(self::$body); + } + + public function stream_stat(): array { + return []; + } +} diff --git a/apps/federatedfilesharing/composer/composer/autoload_classmap.php b/apps/federatedfilesharing/composer/composer/autoload_classmap.php index a5ec2ecd82288..27a3710e120e0 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_classmap.php +++ b/apps/federatedfilesharing/composer/composer/autoload_classmap.php @@ -17,6 +17,7 @@ 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => $baseDir . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => $baseDir . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => $baseDir . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => $baseDir . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => $baseDir . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => $baseDir . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => $baseDir . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/composer/composer/autoload_static.php b/apps/federatedfilesharing/composer/composer/autoload_static.php index c415c51b5929f..77ce59fe0054f 100644 --- a/apps/federatedfilesharing/composer/composer/autoload_static.php +++ b/apps/federatedfilesharing/composer/composer/autoload_static.php @@ -32,6 +32,7 @@ class ComposerStaticInitFederatedFileSharing 'OCA\\FederatedFileSharing\\Listeners\\LoadAdditionalScriptsListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScriptsListener.php', 'OCA\\FederatedFileSharing\\Migration\\Version1010Date20200630191755' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630191755.php', 'OCA\\FederatedFileSharing\\Migration\\Version1011Date20201120125158' => __DIR__ . '/..' . '/../lib/Migration/Version1011Date20201120125158.php', + 'OCA\\FederatedFileSharing\\Migration\\Version1012Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1012Date20260306120000.php', 'OCA\\FederatedFileSharing\\Notifications' => __DIR__ . '/..' . '/../lib/Notifications.php', 'OCA\\FederatedFileSharing\\Notifier' => __DIR__ . '/..' . '/../lib/Notifier.php', 'OCA\\FederatedFileSharing\\OCM\\CloudFederationProviderFiles' => __DIR__ . '/..' . '/../lib/OCM/CloudFederationProviderFiles.php', diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 1302f2ca980af..fcc2e078e6a09 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -7,8 +7,12 @@ */ namespace OCA\FederatedFileSharing; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; +use OCA\DAV\Db\OcmTokenMapMapper; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; @@ -22,6 +26,8 @@ use OCP\IDBConnection; use OCP\IL10N; use OCP\IUserManager; +use OCP\Security\ISecureRandom; +use OCP\Server; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IShare; @@ -170,7 +176,15 @@ public function create(IShare $share): IShare { * @throws \Exception */ protected function createFederatedShare(IShare $share): string { - $token = $this->tokenHandler->generateToken(); + + $provider = Server::get(PublicKeyTokenProvider::class); + $token = Server::get(ISecureRandom::class)->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $uid = $share->getSharedBy(); + $user = $this->userManager->get($uid); + $name = $user?->getDisplayName() ?? $uid; + $pass = $share->getPassword(); + + $dbToken = $provider->generateToken($token, $uid, $uid, $pass, $name, type: IToken::PERMANENT_TOKEN); $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), @@ -712,6 +726,24 @@ public function getShareByToken(string $token): IShare { $data = $cursor->fetchAssociative(); + if ($data === false) { + // Token not found as refresh token, try looking it up as access token + try { + $accessTokenDb = Server::get(PublicKeyTokenProvider::class)->getToken($token); + $mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId()); + + $qb2 = $this->dbConnection->getQueryBuilder(); + $cursor = $qb2->select('*') + ->from('share') + ->where($qb2->expr()->in('share_type', $qb2->createNamedParameter($this->supportedShareType, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb2->expr()->eq('token', $qb2->createNamedParameter($mapping->getRefreshToken()))) + ->executeQuery(); + + $data = $cursor->fetch(); + } catch (InvalidTokenException|\OCP\AppFramework\Db\DoesNotExistException) { + // Token is not a valid access token or has no mapping, share not found + } + } if ($data === false) { throw new ShareNotFound('Share not found', $this->l->t('Could not find share')); } diff --git a/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php new file mode 100644 index 0000000000000..4837dbae63d9b --- /dev/null +++ b/apps/federatedfilesharing/lib/Migration/Version1012Date20260306120000.php @@ -0,0 +1,117 @@ +getQueryBuilder(); + $result = $qb->select('id', 'token', 'uid_initiator') + ->from('share') + ->where($qb->expr()->in( + 'share_type', + $qb->createNamedParameter( + [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], + IQueryBuilder::PARAM_INT_ARRAY + ) + )) + ->executeQuery(); + + $registered = 0; + $skipped = 0; + + while ($row = $result->fetchAssociative()) { + $shareId = (int)$row['id']; + $token = (string)$row['token']; + $uid = (string)$row['uid_initiator']; + + if (strlen($token) < PublicKeyTokenProvider::TOKEN_MIN_LENGTH) { + // Old short token from TokenHandler — leave it as-is. + // Replacing it would invalidate the token stored on the receiving instance, + // breaking Basic-auth access to those shares. These shares keep working via + // Basic auth and are simply not eligible for the OCM token exchange flow. + $skipped++; + continue; + } + + // Long token — check if it's already in oc_authtoken. + try { + $tokenProvider->getToken($token); + $skipped++; + continue; + } catch (InvalidTokenException) { + // Not registered yet — fall through to create it. + } + + $user = $userManager->get($uid); + $name = $user?->getDisplayName() ?? $uid; + + try { + $tokenProvider->generateToken( + $token, + $uid, + $uid, + null, + $name, + IToken::PERMANENT_TOKEN, + ); + $registered++; + } catch (\Exception $e) { + $output->warning(sprintf( + 'Could not register auth token for share %d (uid=%s): %s', + $shareId, + $uid, + $e->getMessage() + )); + } + } + + $result->closeCursor(); + + $output->info(sprintf( + 'Federated share token migration: %d registered, %d skipped (already up-to-date or legacy short token).', + $registered, + $skipped + )); + } +} diff --git a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index 3ae6366fabe63..4bf6d1e0b0b10 100644 --- a/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php +++ b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php @@ -8,6 +8,7 @@ use OC\AppFramework\Http; use OC\Files\Filesystem; +use OC\OCM\OCMSignatoryManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Federation\TrustedServers; @@ -33,12 +34,16 @@ use OCP\Files\ISetupManager; use OCP\Files\NotFoundException; use OCP\HintException; +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Notification\IManager as INotificationManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; @@ -70,6 +75,11 @@ public function __construct( private readonly IProviderFactory $shareProviderFactory, private readonly ISetupManager $setupManager, private readonly ExternalShareMapper $externalShareMapper, + private readonly IOCMDiscoveryService $discoveryService, + private readonly IClientService $clientService, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private readonly IAppConfig $appConfig, ) { } @@ -106,6 +116,31 @@ public function shareReceived(ICloudFederationShare $share): string { $ownerFederatedId = $share->getOwner(); $shareType = $this->mapShareTypeToNextcloud($share->getShareType()); + // Check for must-exchange-token requirement + $requirements = $protocol['webdav']['requirements'] ?? $protocol['options']['requirements'] ?? []; + $mustExchangeToken = in_array('must-exchange-token', $requirements); + $accessToken = ''; + + if ($mustExchangeToken) { + // Exchange the sharedSecret for an access token (required) + $accessToken = $this->exchangeToken($remote, $token); + if ($accessToken === null) { + throw new ProviderCouldNotAddShareException('Failed to exchange token as required by must-exchange-token', '', Http::STATUS_BAD_REQUEST); + } + } else { + // Check if remote has exchange-token capability and try to exchange (optional) + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + $capabilities = $ocmProvider->getCapabilities(); + if (in_array('exchange-token', $capabilities)) { + $accessToken = $this->exchangeToken($remote, $token) ?? ''; + $this->logger->debug('Exchanged token for remote with exchange-token capability', ['remote' => $remote, 'success' => !empty($accessToken)]); + } + } catch (\Exception $e) { + $this->logger->debug('Could not discover remote capabilities for token exchange', ['remote' => $remote, 'exception' => $e]); + } + } + // if no explicit information about the person who created the share was sent // we assume that the share comes from the owner if ($sharedByFederatedId === null) { @@ -146,8 +181,8 @@ public function shareReceived(ICloudFederationShare $share): string { $externalShare->generateId(); $externalShare->setRemote($remote); $externalShare->setRemoteId($remoteId); - $externalShare->setShareToken($token); - $externalShare->setPassword(''); + $externalShare->setShareToken($token); // refresh token (sharedSecret) + $externalShare->setAccessToken($accessToken ?: null); $externalShare->setName($name); $externalShare->setOwner($owner); $externalShare->setShareType($shareType); @@ -684,4 +719,98 @@ public function getFederationIdFromSharedSecret( return $share->getShareOwner(); } } + + /** + * Exchange a sharedSecret (refresh token) for an access token via the remote server's token endpoint + * + * @param string $remote The remote server URL + * @param string $sharedSecret The shared secret to exchange + * @return string|null The access token, or null on failure + */ + private function exchangeToken(string $remote, #[SensitiveParameter] string $sharedSecret): ?string { + try { + $ocmProvider = $this->discoveryService->discover(rtrim($remote, '/')); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndpoint === '') { + $this->logger->warning('Remote server does not expose tokenEndPoint', ['remote' => $remote]); + return null; + } + + $client = $this->clientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); + + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $sharedSecret, + ]; + + $options = [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['remote' => $remote]); + } catch (\Exception $e) { + $this->logger->error('Failed to sign token request', [ + 'remote' => $remote, + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + return null; + } + + $response = $client->post($tokenEndpoint, $options); + + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + $this->logger->warning('Token exchange returned unexpected HTTP status', [ + 'remote' => $remote, + 'status' => $statusCode, + ]); + return null; + } + + $data = json_decode($response->getBody(), true); + + if (!is_array($data)) { + $this->logger->warning('Token exchange response is not valid JSON', ['remote' => $remote]); + return null; + } + + $accessToken = $data['access_token'] ?? null; + $tokenType = $data['token_type'] ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + $this->logger->warning('Token exchange response missing or invalid access_token', ['remote' => $remote]); + return null; + } + + if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') { + $this->logger->warning('Token exchange response has unexpected token_type', [ + 'remote' => $remote, + 'token_type' => $tokenType, + ]); + return null; + } + + $this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]); + return $accessToken; + } catch (\Exception $e) { + $this->logger->warning('Failed to exchange token', ['remote' => $remote, 'exception' => $e]); + return null; + } + } } diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index da37617c121dd..e39f2e03eb152 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -9,11 +9,13 @@ namespace OCA\FederatedFileSharing\Tests; use LogicException; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Federation\CloudIdManager; use OCA\FederatedFileSharing\AddressHandler; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\Notifications; use OCA\FederatedFileSharing\TokenHandler; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\Contacts\IManager as IContactsManager; use OCP\EventDispatcher\IEventDispatcher; @@ -27,6 +29,7 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\Security\ISecureRandom; use OCP\Server; use OCP\Share\IManager; use OCP\Share\IShare; @@ -87,6 +90,23 @@ protected function setUp(): void { $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + // Mock ISecureRandom to return predictable tokens (must be 32+ chars) + $secureRandom = $this->createMock(ISecureRandom::class); + $tokenCounter = 0; + $secureRandom->method('generate') + ->willReturnCallback(function () use (&$tokenCounter) { + $tokenCounter++; + return 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'token' . $tokenCounter . 'ab'; + }); + $this->overwriteService(ISecureRandom::class, $secureRandom); + + // Mock PublicKeyTokenProvider to avoid database token creation + $tokenProvider = $this->createMock(PublicKeyTokenProvider::class); + $mockToken = $this->createMock(IToken::class); + $tokenProvider->method('generateToken') + ->willReturn($mockToken); + $this->overwriteService(PublicKeyTokenProvider::class, $tokenProvider); + $this->provider = new FederatedShareProvider( $this->connection, $this->addressHandler, @@ -146,7 +166,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -184,7 +204,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate 'file_source' => 42, 'permissions' => 19, 'accepted' => 0, - 'token' => 'token', + 'token' => 'token1token1token1token1token1ab', 'expiration' => $expectedDataDate, ]; foreach (array_keys($expected) as $key) { @@ -199,7 +219,7 @@ public function testCreate(?\DateTime $expirationDate, ?string $expectedDataDate $this->assertEquals('file', $share->getNodeType()); $this->assertEquals(42, $share->getNodeId()); $this->assertEquals(19, $share->getPermissions()); - $this->assertEquals('token', $share->getToken()); + $this->assertEquals('token1token1token1token1token1ab', $share->getToken()); $this->assertEquals($expirationDate, $share->getExpirationDate()); } @@ -229,7 +249,7 @@ public function testCreateCouldNotFindServer(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -283,7 +303,7 @@ public function testCreateException(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->matchesRegularExpression('/^[A-Za-z0-9]{32}$/'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -373,7 +393,7 @@ public function testCreateAlreadyShared(): void { $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -445,7 +465,7 @@ public function testUpdate(string $owner, string $sharedBy, ?\DateTime $expirati $this->notifications->expects($this->once()) ->method('sendRemoteShare') ->with( - $this->equalTo('token'), + $this->equalTo('token1token1token1token1token1ab'), $this->equalTo('user@server.com'), $this->equalTo('myFile'), $this->anything(), @@ -884,9 +904,9 @@ public function testGetAccessList(): void { $folder1 = $rootFolder->getUserFolder($u1->getUID())->newFolder('foo'); $file1 = $folder1->newFile('bar1'); - $this->tokenHandler->expects($this->exactly(2)) - ->method('generateToken') - ->willReturnOnConsecutiveCalls('token1', 'token2'); + // Token generation now uses ISecureRandom instead of tokenHandler + $this->tokenHandler->expects($this->never()) + ->method('generateToken'); $this->notifications->expects($this->atLeastOnce()) ->method('sendRemoteShare') ->willReturn(true); @@ -927,11 +947,11 @@ public function testGetAccessList(): void { $result = $this->provider->getAccessList([$file1], true); $this->assertEquals(['remote' => [ 'user@server.com' => [ - 'token' => 'token1', + 'token' => 'token1token1token1token1token1ab', 'node_id' => $file1->getId(), ], 'foobar@localhost' => [ - 'token' => 'token2', + 'token' => 'token2token2token2token2token2ab', 'node_id' => $file1->getId(), ], ]], $result); diff --git a/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php new file mode 100644 index 0000000000000..2593bf496aa0d --- /dev/null +++ b/apps/federatedfilesharing/tests/OCM/CloudFederationProviderFilesTest.php @@ -0,0 +1,310 @@ +appManager = $this->createMock(IAppManager::class); + $this->federatedShareProvider = $this->createMock(FederatedShareProvider::class); + $this->addressHandler = $this->createMock(AddressHandler::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->shareManager = $this->createMock(IManager::class); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->activityManager = $this->createMock(IActivityManager::class); + $this->notificationManager = $this->createMock(INotificationManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); + $this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->config = $this->createMock(IConfig::class); + $this->externalShareManager = $this->createMock(Manager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->filenameValidator = $this->createMock(IFilenameValidator::class); + $this->shareProviderFactory = $this->createMock(IProviderFactory::class); + $this->setupManager = $this->createMock(ISetupManager::class); + $this->externalShareMapper = $this->createMock(ExternalShareMapper::class); + $this->discoveryService = $this->createMock(IOCMDiscoveryService::class); + $this->clientService = $this->createMock(IClientService::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->provider = new CloudFederationProviderFiles( + $this->appManager, + $this->federatedShareProvider, + $this->addressHandler, + $this->userManager, + $this->shareManager, + $this->cloudIdManager, + $this->activityManager, + $this->notificationManager, + $this->urlGenerator, + $this->cloudFederationFactory, + $this->cloudFederationProviderManager, + $this->groupManager, + $this->config, + $this->externalShareManager, + $this->logger, + $this->filenameValidator, + $this->shareProviderFactory, + $this->setupManager, + $this->externalShareMapper, + $this->discoveryService, + $this->clientService, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + ); + } + + private function enableS2S(): void { + $this->appManager->method('isEnabledForUser') + ->with('files_sharing') + ->willReturn(true); + $this->federatedShareProvider->method('isIncomingServer2serverShareEnabled') + ->willReturn(true); + } + + private function buildShare(array $requirements = []): ICloudFederationShare&MockObject { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getProtocol')->willReturn([ + 'name' => 'webdav', + 'webdav' => ['requirements' => $requirements], + ]); + $share->method('getOwner')->willReturn('owner@example.com'); + $share->method('getOwnerDisplayName')->willReturn('Owner Name'); + $share->method('getShareSecret')->willReturn('refresh-token-abc'); + $share->method('getResourceName')->willReturn('/SharedFolder'); + $share->method('getShareWith')->willReturn('localuser'); + $share->method('getProviderId')->willReturn('42'); + $share->method('getSharedBy')->willReturn('owner@example.com'); + $share->method('getShareType')->willReturn('user'); + return $share; + } + + /** + * When must-exchange-token is required but the remote has no token endpoint, + * shareReceived must throw rather than silently accept the share. + */ + public function testShareReceivedMustExchangeTokenThrowsWhenExchangeFails(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn(''); + + $this->discoveryService->method('discover') + ->willReturn($ocmProvider); + + $this->expectException(ProviderCouldNotAddShareException::class); + + $this->provider->shareReceived($share); + } + + /** + * When must-exchange-token is required and the token exchange succeeds, + * the access token is stored on the share (we drive through share creation + * up to the "user does not exist" guard to avoid a full integration setup). + */ + public function testShareReceivedMustExchangeTokenStoresAccessToken(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(['must-exchange-token']); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn([]); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + // Exchange succeeds → share creation continues; we stop it at the user + // lookup stage to avoid a full integration setup. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present but the discovery service throws, + * shareReceived must not propagate the exception — the token exchange is optional. + */ + public function testShareReceivedOptionalExchangeGracefulOnDiscoveryFailure(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + // Build a share with no must-exchange-token requirement + $share = $this->buildShare(); + + $this->discoveryService->method('discover') + ->willThrowException(new \Exception('network error')); + + // Discovery failure is caught and logged; share creation continues. + // We stop it at the user lookup stage. + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } + + /** + * When exchange-token capability is present and the exchange succeeds, + * the access token is set (we stop at user-not-found to avoid full setup). + */ + public function testShareReceivedOptionalExchangeStoresAccessTokenOnSuccess(): void { + $this->enableS2S(); + + $this->addressHandler->method('splitUserRemote') + ->with('owner@example.com') + ->willReturn(['owner', 'https://example.com/']); + + $share = $this->buildShare(); + + $tokenEndpoint = 'https://example.com/index.php/ocm/token'; + + $ocmProvider = $this->createMock(IOCMProvider::class); + $ocmProvider->method('getTokenEndPoint')->willReturn($tokenEndpoint); + $ocmProvider->method('getCapabilities')->willReturn(['exchange-token']); + + $this->discoveryService->method('discover')->willReturn($ocmProvider); + + $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://local.example/'); + + $signedOptions = [ + 'body' => 'grant_type=authorization_code&client_id=local.example&code=refresh-token-abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded', 'Signature' => 'sig'], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + $this->signatureManager->method('signOutgoingRequestIClientPayload') + ->willReturn($signedOptions); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode([ + 'access_token' => 'access-token-xyz', + 'token_type' => 'Bearer', + ])); + + $httpClient = $this->createMock(\OCP\Http\Client\IClient::class); + $httpClient->method('post')->willReturn($response); + $this->clientService->method('newClient')->willReturn($httpClient); + + $this->userManager->method('get')->with('localuser')->willReturn(null); + $this->filenameValidator->method('isFilenameValid')->willReturn(true); + + $this->expectException(ProviderCouldNotAddShareException::class); + $this->expectExceptionMessage('User does not exists'); + + $this->provider->shareReceived($share); + } +} diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 5962eab0d5f50..34f96f36bee0b 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -87,6 +87,8 @@ 'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => $baseDir . '/../lib/Migration/Version31000Date20240821142813.php', 'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => $baseDir . '/../lib/Migration/Version32000Date20251017081948.php', 'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => $baseDir . '/../lib/Migration/Version33000Date20251030081948.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => $baseDir . '/../lib/Migration/Version33000Date20260306120000.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306130000' => $baseDir . '/../lib/Migration/Version33000Date20260306130000.php', 'OCA\\Files_Sharing\\MountProvider' => $baseDir . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 51895e2be730a..fae99831318ec 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -102,6 +102,8 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Migration\\Version31000Date20240821142813' => __DIR__ . '/..' . '/../lib/Migration/Version31000Date20240821142813.php', 'OCA\\Files_Sharing\\Migration\\Version32000Date20251017081948' => __DIR__ . '/..' . '/../lib/Migration/Version32000Date20251017081948.php', 'OCA\\Files_Sharing\\Migration\\Version33000Date20251030081948' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20251030081948.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306120000.php', + 'OCA\\Files_Sharing\\Migration\\Version33000Date20260306130000' => __DIR__ . '/..' . '/../lib/Migration/Version33000Date20260306130000.php', 'OCA\\Files_Sharing\\MountProvider' => __DIR__ . '/..' . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', diff --git a/apps/files_sharing/lib/External/ExternalShare.php b/apps/files_sharing/lib/External/ExternalShare.php index 07a9a119fa051..5b3614c261e93 100644 --- a/apps/files_sharing/lib/External/ExternalShare.php +++ b/apps/files_sharing/lib/External/ExternalShare.php @@ -30,6 +30,10 @@ * @method void setShareToken(string $shareToken) * @method string|null getPassword() * @method void setPassword(?string $password) + * @method string|null getAccessToken() + * @method void setAccessToken(?string $accessToken) + * @method int|null getAccessTokenExpires() + * @method void setAccessTokenExpires(?int $accessTokenExpires) * @method string getName() * @method string getOwner() * @method void setOwner(string $owner) @@ -50,6 +54,8 @@ class ExternalShare extends SnowflakeAwareEntity implements \JsonSerializable { protected ?string $remoteId = null; protected ?string $shareToken = null; protected ?string $password = null; + protected ?string $accessToken = null; + protected ?int $accessTokenExpires = null; protected ?string $name = null; protected ?string $owner = null; protected ?string $user = null; @@ -65,6 +71,8 @@ public function __construct() { $this->addType('remoteId', Types::STRING); $this->addType('shareToken', Types::STRING); $this->addType('password', Types::STRING); + $this->addType('accessToken', Types::STRING); + $this->addType('accessTokenExpires', Types::INTEGER); $this->addType('name', Types::STRING); $this->addType('owner', Types::STRING); $this->addType('user', Types::STRING); diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php index 9693e52439b89..ee532defff023 100644 --- a/apps/files_sharing/lib/External/Manager.php +++ b/apps/files_sharing/lib/External/Manager.php @@ -113,6 +113,8 @@ public function addShare(ExternalShare $externalShare, IUser|IGroup|null $shareW 'remote' => $externalShare->getRemote(), 'token' => $externalShare->getShareToken(), 'password' => $externalShare->getPassword(), + 'access_token' => $externalShare->getAccessToken(), + 'access_token_expires' => $externalShare->getAccessTokenExpires(), 'mountpoint' => $externalShare->getMountpoint(), 'owner' => $externalShare->getOwner(), 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), @@ -190,6 +192,7 @@ private function updateSubShare(ExternalShare $externalShare, IUser $user, ?stri $subShare->generateId(); $subShare->setRemote($externalShare->getRemote()); $subShare->setPassword($externalShare->getPassword()); + $subShare->setAccessToken($externalShare->getAccessToken()); $subShare->setName($externalShare->getName()); $subShare->setOwner($externalShare->getOwner()); $subShare->setUser($user->getUID()); @@ -566,4 +569,24 @@ public function getAcceptedShares(): array { return []; } } + + /** + * Update the access token for a share. + * + * @param string $shareToken The share token (refresh token) to identify the share + * @param string $accessToken The new access token to store + */ + public function updateAccessToken(string $shareToken, string $accessToken, int $expiresAt): void { + try { + $share = $this->externalShareMapper->getShareByToken($shareToken); + $share->setAccessToken($accessToken); + $share->setAccessTokenExpires($expiresAt); + $this->externalShareMapper->update($share); + $this->logger->debug('Updated access token for share', ['shareToken' => substr($shareToken, 0, 8) . '...']); + } catch (DoesNotExistException $e) { + $this->logger->warning('Could not find share to update access token', ['shareToken' => substr($shareToken, 0, 8) . '...']); + } catch (Exception $e) { + $this->logger->error('Failed to update access token', ['exception' => $e]); + } + } } diff --git a/apps/files_sharing/lib/External/MountProvider.php b/apps/files_sharing/lib/External/MountProvider.php index 1f3620c5644be..df4d5ecc0a11d 100644 --- a/apps/files_sharing/lib/External/MountProvider.php +++ b/apps/files_sharing/lib/External/MountProvider.php @@ -59,7 +59,7 @@ private function getMount(IUser $user, array $data, IStorageFactory $storageFact public function getMountsForUser(IUser $user, IStorageFactory $loader): array { $qb = $this->connection->getQueryBuilder(); - $qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner') + $qb->select('id', 'remote', 'share_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner') ->from('share_external') ->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID()))) ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))); @@ -99,7 +99,7 @@ public function getMountsForPath( } $qb = $this->connection->getQueryBuilder(); - $qb->select('id', 'remote', 'share_token', 'password', 'mountpoint', 'owner') + $qb->select('id', 'remote', 'share_token', 'password', 'access_token', 'access_token_expires', 'mountpoint', 'owner') ->from('share_external') ->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID()))) ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))); diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index 68c69e9e7c87f..c88c4fdd58d2a 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -50,11 +50,21 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, private bool $updateChecked = false; private ExternalShareManager $manager; private IConfig $config; - private IAppConfig $appConfig; + protected IAppConfig $appConfig; private IShareManager $shareManager; + private bool $tokenRefreshed = false; + /** Unix timestamp until which the current access token is considered valid (0 = unknown/expired) */ + private int $tokenExpiresAt = 0; + /** Number of consecutive token exchange failures (resets on success or DB-reuse) */ + private int $refreshFailureCount = 0; + /** Unix timestamp before which the next exchange attempt must not be made (0 = no wait) */ + private int $refreshBackoffUntil = 0; + + private const REFRESH_MAX_ATTEMPTS = 3; + private const REFRESH_BACKOFF_SECONDS = 5; /** - * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options + * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, access_token: ?string, access_token_expires: ?int}|array $options */ public function __construct($options) { $this->memcacheFactory = Server::get(ICacheFactory::class); @@ -72,14 +82,30 @@ public function __construct($options) { $ocmProvider = $discoveryService->discover($this->cloudId->getRemote()); $webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav'); $remote = $ocmProvider->getEndPoint(); + $authType = \Sabre\DAV\Client::AUTH_BASIC; } catch (OCMProviderException|OCMArgumentException $e) { $this->logger->notice('exception while retrieving webdav endpoint', ['exception' => $e]); $webDavEndpoint = '/public.php/webdav'; $remote = $this->cloudId->getRemote(); + $authType = \Sabre\DAV\Client::AUTH_BASIC; + } + + // Only use Bearer auth when an access token is already stored. + // Shares created before the exchange-token capability was introduced have no + // stored token and must keep using basic auth for backwards compatibility. + if (!empty($options['access_token'])) { + $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; } $host = parse_url($remote, PHP_URL_HOST); + // If host extraction fails (e.g., endpoint has no scheme), fall back to cloudId's remote + if ($host === null) { + $host = parse_url($this->cloudId->getRemote(), PHP_URL_HOST); + } $port = parse_url($remote, PHP_URL_PORT); + if ($port === null) { + $port = parse_url($this->cloudId->getRemote(), PHP_URL_PORT); + } $host .= ($port === null) ? '' : ':' . $port; // we add port if available // in case remote NC is on a sub folder and using deprecated ocm provider @@ -90,20 +116,108 @@ public function __construct($options) { $this->mountPoint = $options['mountpoint']; $this->token = $options['token']; + $this->tokenExpiresAt = (int)($options['access_token_expires'] ?? 0); + + // Determine scheme - fall back to cloudId's remote if $remote has no scheme + $scheme = parse_url($remote, PHP_URL_SCHEME) ?? parse_url($this->cloudId->getRemote(), PHP_URL_SCHEME) ?? 'https'; parent::__construct( [ - 'secure' => ((parse_url($remote, PHP_URL_SCHEME) ?? 'https') === 'https'), + 'secure' => ($scheme === 'https'), 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), 'host' => $host, 'root' => $webDavEndpoint, 'user' => $options['token'], - 'authType' => \Sabre\DAV\Client::AUTH_BASIC, - 'password' => (string)$options['password'] + 'authType' => $authType, + 'password' => $authType === \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER + ? (string)($options['access_token'] ?? '') + : (string)($options['password'] ?? ''), + 'discoveryService' => $discoveryService, ] ); } + /** + * Refresh the bearer token. Extends parent to also persist to database. + * + * Uses expiry timestamps instead of a boolean flag so that concurrent + * processes can detect that another process already obtained a fresh token + * and reuse it rather than performing a redundant exchange. + * + * After a failed exchange, a 60-second backoff is applied so that + * subsequent file operations do not hammer the remote token endpoint. + * The DB is still consulted during backoff in case a concurrent process + * succeeded; only the outgoing exchange call is suppressed. + * + * @return bool True if token was refreshed (or reused from DB) successfully + */ + protected function refreshBearerToken(): bool { + $now = time(); + + // Fast path: in-memory token is still valid (single-process guard). + if ($this->tokenExpiresAt > $now) { + return false; + } + + // Slow path: check DB — a concurrent process may have already refreshed. + $share = $this->manager->getShareByToken($this->token); + if ($share !== false) { + $dbExpiry = $share->getAccessTokenExpires(); + $dbToken = $share->getAccessToken(); + if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) { + // Another process already refreshed — reuse DB token and reset failure state. + $this->password = $dbToken; + $this->tokenExpiresAt = $dbExpiry; + $this->refreshFailureCount = 0; + $this->refreshBackoffUntil = 0; + $this->ready = false; + $this->client = null; + $this->init(); + $this->logger->debug('Reused access token refreshed by another process', ['app' => 'files_sharing']); + return true; + } + } + + // Gave up after max attempts: stop trying for the lifetime of this instance. + if ($this->refreshFailureCount >= self::REFRESH_MAX_ATTEMPTS) { + return false; + } + + // Still within the inter-attempt wait: don't hit the endpoint yet. + if ($this->refreshBackoffUntil > $now) { + return false; + } + + // No valid token in DB — perform the exchange ourselves. + try { + $expiresAt = $now + 3600; // access tokens are valid for 1 hour + $newAccessToken = $this->exchangeRefreshToken(); + $this->password = $newAccessToken; + $this->tokenExpiresAt = $expiresAt; + $this->refreshFailureCount = 0; + $this->refreshBackoffUntil = 0; + + $this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt); + + $this->ready = false; + $this->client = null; + $this->init(); + + $this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']); + return true; + } catch (\Exception $e) { + $this->refreshFailureCount++; + $this->refreshBackoffUntil = $now + self::REFRESH_BACKOFF_SECONDS; + $this->logger->warning('Failed to refresh access token (attempt {attempt}/{max})', [ + 'app' => 'files_sharing', + 'attempt' => $this->refreshFailureCount, + 'max' => self::REFRESH_MAX_ATTEMPTS, + 'exception' => $e, + ]); + return false; + } + } + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; diff --git a/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php b/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php new file mode 100644 index 0000000000000..4171add5b2705 --- /dev/null +++ b/apps/files_sharing/lib/Migration/Version33000Date20260306120000.php @@ -0,0 +1,41 @@ +getTable('share_external'); + + if ($table->hasColumn('access_token')) { + return null; + } + + $table->addColumn('access_token', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 512, + ]); + + return $schema; + } +} diff --git a/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php b/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php new file mode 100644 index 0000000000000..ec1fe15d42f42 --- /dev/null +++ b/apps/files_sharing/lib/Migration/Version33000Date20260306130000.php @@ -0,0 +1,40 @@ +getTable('share_external'); + + if ($table->hasColumn('access_token_expires')) { + return null; + } + + $table->addColumn('access_token_expires', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + + return $schema; + } +} diff --git a/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php b/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php new file mode 100644 index 0000000000000..2886c52fc5a6a --- /dev/null +++ b/apps/files_sharing/tests/External/ManagerUpdateAccessTokenTest.php @@ -0,0 +1,113 @@ +externalShareMapper = $this->createMock(ExternalShareMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $this->manager = new Manager( + $this->createMock(IDBConnection::class), + $this->createMock(\OC\Files\Mount\Manager::class), + $this->createMock(IStorageFactory::class), + $this->createMock(IClientService::class), + $this->createMock(INotificationManager::class), + $this->createMock(IDiscoveryService::class), + $this->createMock(ICloudFederationProviderManager::class), + $this->createMock(ICloudFederationFactory::class), + $this->createMock(IGroupManager::class), + $userSession, + $this->createMock(IEventDispatcher::class), + $this->logger, + $this->createMock(IRootFolder::class), + $this->createMock(ISetupManager::class), + $this->createMock(ICertificateManager::class), + $this->externalShareMapper, + $this->createMock(IConfig::class), + ); + } + + public function testUpdateAccessTokenUpdatesShareInDb(): void { + $share = new ExternalShare(); + $share->setShareToken('refresh-token'); + + $this->externalShareMapper->expects($this->once()) + ->method('getShareByToken') + ->with('refresh-token') + ->willReturn($share); + + $this->externalShareMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (ExternalShare $s) { + return $s->getAccessToken() === 'new-access-token' + && $s->getAccessTokenExpires() === 9999; + })); + + $this->manager->updateAccessToken('refresh-token', 'new-access-token', 9999); + } + + public function testUpdateAccessTokenLogsWarningWhenShareNotFound(): void { + $this->externalShareMapper->method('getShareByToken') + ->willThrowException(new DoesNotExistException('not found')); + + $this->externalShareMapper->expects($this->never())->method('update'); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Could not find share')); + + $this->manager->updateAccessToken('missing-token', 'access', 0); + } + + public function testUpdateAccessTokenLogsErrorOnDbException(): void { + $this->externalShareMapper->method('getShareByToken') + ->willThrowException(new Exception('db error')); + + $this->externalShareMapper->expects($this->never())->method('update'); + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Failed to update access token')); + + $this->manager->updateAccessToken('some-token', 'access', 0); + } +} diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index aba3d3090beca..414f7d61579d5 100644 --- a/build/integration/features/bootstrap/Sharing.php +++ b/build/integration/features/bootstrap/Sharing.php @@ -316,7 +316,8 @@ public function isFieldInResponse($field, $contentExpected) { if (count($data->element) > 0) { foreach ($data as $element) { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$element->$field) == 15); + $tokenLength = strlen((string)$element->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$element->$field); } elseif ($contentExpected == 'AN_URL') { @@ -331,7 +332,8 @@ public function isFieldInResponse($field, $contentExpected) { return false; } else { if ($contentExpected == 'A_TOKEN') { - return (strlen((string)$data->$field) == 15); + $tokenLength = strlen((string)$data->$field); + return $tokenLength == 15 || $tokenLength == 32; } elseif ($contentExpected == 'A_NUMBER') { return is_numeric((string)$data->$field); } elseif ($contentExpected == 'AN_URL') { @@ -617,9 +619,10 @@ private function assertFieldIsInReturnedShare(string $field, string $contentExpe if ($contentExpected === 'A_NUMBER') { Assert::assertTrue(is_numeric((string)$returnedShare->$field), "Field '$field' is not a number: " . $returnedShare->$field); } elseif ($contentExpected === 'A_TOKEN') { - // A token is composed by 15 characters from - // ISecureRandom::CHAR_HUMAN_READABLE. - Assert::assertMatchesRegularExpression('/^[abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789]{15}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); + // A token is either: + // - 15 characters from ISecureRandom::CHAR_HUMAN_READABLE (legacy), or + // - 32 characters from ISecureRandom::CHAR_ALPHANUMERIC (new OCM tokens) + Assert::assertMatchesRegularExpression('/^[a-zA-Z0-9]{15,32}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); } elseif (strpos($contentExpected, 'REGEXP ') === 0) { Assert::assertMatchesRegularExpression(substr($contentExpected, strlen('REGEXP ')), (string)$returnedShare->$field, "Field '$field' does not match"); } else { diff --git a/lib/private/Authentication/Token/PublicKeyToken.php b/lib/private/Authentication/Token/PublicKeyToken.php index cf3a8b16141bc..29348e5d2d435 100644 --- a/lib/private/Authentication/Token/PublicKeyToken.php +++ b/lib/private/Authentication/Token/PublicKeyToken.php @@ -191,6 +191,10 @@ public function getRemember(): int { return parent::getRemember(); } + public function getType(): int { + return $this->getter('type'); + } + public function setToken(string $token): void { parent::setToken($token); } diff --git a/lib/private/Federation/CloudFederationFactory.php b/lib/private/Federation/CloudFederationFactory.php index d06de0f2f588e..cf39852a891f8 100644 --- a/lib/private/Federation/CloudFederationFactory.php +++ b/lib/private/Federation/CloudFederationFactory.php @@ -9,8 +9,18 @@ use OCP\Federation\ICloudFederationFactory; use OCP\Federation\ICloudFederationNotification; use OCP\Federation\ICloudFederationShare; +use OCP\Federation\ICloudIdManager; +use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; +use Psr\Log\LoggerInterface; class CloudFederationFactory implements ICloudFederationFactory { + public function __construct( + private IOCMDiscoveryService $ocmDiscoveryService, + private ICloudIdManager $cloudIdManager, + private LoggerInterface $logger, + ) { + } /** * get a CloudFederationShare Object to prepare a share you want to send * @@ -30,7 +40,52 @@ class CloudFederationFactory implements ICloudFederationFactory { * @since 14.0.0 */ public function getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $sharedSecret, $shareType, $resourceType) { - return new CloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $shareType, $resourceType, $sharedSecret); + $useExchangeToken = false; + $remoteDomain = null; + + try { + $cloudId = $this->cloudIdManager->resolveCloudId($shareWith); + $remoteDomain = $cloudId->getRemote(); + + try { + $remoteProvider = $this->ocmDiscoveryService->discover($remoteDomain); + $capabilities = $remoteProvider->getCapabilities(); + + $useExchangeToken = in_array('exchange-token', $capabilities, true); + + $this->logger->debug('OCM provider capabilities discovered', [ + 'remote' => $remoteDomain, + 'capabilities' => $capabilities, + 'useExchangeToken' => $useExchangeToken, + ]); + } catch (OCMProviderException $e) { + $this->logger->warning('Failed to discover OCM provider, using legacy share method', [ + 'remote' => $remoteDomain, + 'exception' => $e->getMessage(), + ]); + } + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid cloud ID format, using legacy share method', [ + 'shareWith' => $shareWith, + 'exception' => $e->getMessage(), + ]); + } + + return new CloudFederationShare( + $shareWith, + $name, + $description, + $providerId, + $owner, + $ownerDisplayName, + $sharedBy, + $sharedByDisplayName, + $shareType, + $resourceType, + $sharedSecret, + $useExchangeToken, + $remoteDomain + ); } /** diff --git a/lib/private/Federation/CloudFederationShare.php b/lib/private/Federation/CloudFederationShare.php index 6bd35cea763e4..2befc99cb5035 100644 --- a/lib/private/Federation/CloudFederationShare.php +++ b/lib/private/Federation/CloudFederationShare.php @@ -40,6 +40,8 @@ class CloudFederationShare implements ICloudFederationShare { * @param string $shareType ('group' or 'user' share) * @param string $resourceType ('file', 'calendar',...) * @param string $sharedSecret + * @param bool $useExchangeToken whether to use exchange-token protocol (new way) or sharedSecret (old way) + * @param string|null $remoteDomain remote domain for constructing webdav URI */ public function __construct($shareWith = '', $name = '', @@ -52,6 +54,8 @@ public function __construct($shareWith = '', $shareType = '', $resourceType = '', $sharedSecret = '', + $useExchangeToken = false, + $remoteDomain = null, ) { $this->setShareWith($shareWith); $this->setResourceName($name); @@ -61,13 +65,27 @@ public function __construct($shareWith = '', $this->setOwnerDisplayName($ownerDisplayName); $this->setSharedBy($sharedBy); $this->setSharedByDisplayName($sharedByDisplayName); - $this->setProtocol([ - 'name' => 'webdav', - 'options' => [ - 'sharedSecret' => $sharedSecret, - 'permissions' => '{http://open-cloud-mesh.org/ns}share-permissions' - ] - ]); + + if ($useExchangeToken) { + $webdavUri = $remoteDomain ? 'https://' . $remoteDomain . '/public.php/webdav/' : ''; + $this->setProtocol([ + 'name' => 'webdav', + 'webdav' => [ + 'uri' => $webdavUri, + 'sharedSecret' => $sharedSecret, + 'permissions' => ['{http://open-cloud-mesh.org/ns}share-permissions'] + ] + ]); + } else { + $this->setProtocol([ + 'name' => 'webdav', + 'options' => [ + 'sharedSecret' => $sharedSecret, + 'permissions' => '{http://open-cloud-mesh.org/ns}share-permissions' + ] + ]); + } + $this->setShareType($shareType); $this->setResourceType($resourceType); } @@ -328,7 +346,19 @@ public function getShareType() { * @since 14.0.0 */ public function getShareSecret() { - return $this->share['protocol']['options']['sharedSecret']; + $protocol = $this->share['protocol']; + if (isset($protocol['options']['sharedSecret'])) { + return $protocol['options']['sharedSecret']; + } + + if (isset($protocol['name'])) { + $protocolName = $protocol['name']; + if (isset($protocol[$protocolName]['sharedSecret'])) { + return $protocol[$protocolName]['sharedSecret']; + } + } + + return ''; } /** diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 8ccfe30513686..2b855067424fe 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -12,6 +12,7 @@ use Icewind\Streams\IteratorDirectory; use OC\Files\Filesystem; use OC\MemCache\ArrayCache; +use OC\OCM\OCMSignatoryManager; use OCP\AppFramework\Http; use OCP\Constants; use OCP\Diagnostics\IEventLogger; @@ -24,10 +25,16 @@ use OCP\Files\StorageNotAvailableException; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\ICertificateManager; use OCP\IConfig; use OCP\ITempManager; +use OCP\IURLGenerator; use OCP\Lock\LockedException; +use OCP\OCM\Exceptions\OCMArgumentException; +use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\Signature\ISignatureManager; use OCP\Server; use OCP\Util; use Psr\Http\Message\ResponseInterface; @@ -38,6 +45,42 @@ use Sabre\HTTP\ClientHttpException; use Sabre\HTTP\RequestInterface; +/** + * Class BearerAuthAwareSabreClient + * + * This is an extension of the Sabre HTTP Client + * to provide it with the ability to make bearer authn requests. + * + * @package OC\Files\Storage + */ +class BearerAuthAwareSabreClient extends Client { + /** + * Bearer authentication. + */ + public const AUTH_BEARER = 8; + + /** + * Constructor. + * + * See Sabre\DAV\Client + * + */ + public function __construct(array $settings) { + parent::__construct($settings); + + if (isset($settings['userName']) && isset($settings['authType']) && ($settings['authType'] & self::AUTH_BEARER)) { + $userName = $settings['userName']; + + /** @psalm-suppress InvalidArrayOffset */ + $curlType = $this->curlSettings[CURLOPT_HTTPAUTH]; + $curlType |= CURLAUTH_BEARER; + + $this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType); + $this->addCurlSetting(CURLOPT_XOAUTH2_BEARER, $userName); + } + } +} + /** * Class DAV * @@ -48,7 +91,7 @@ class DAV extends Common { protected $password; /** @var string */ protected $user; - /** @var string|null */ + /** @var int|null */ protected $authType; /** @var string */ protected $host; @@ -61,6 +104,8 @@ class DAV extends Common { protected $certPath; /** @var bool */ protected $ready; + /** @var string The resolved bearer token for AUTH_BEARER (access token or exchanged token) */ + protected $bearerToken; /** @var Client */ protected $client; /** @var ArrayCache */ @@ -72,6 +117,11 @@ class DAV extends Common { protected LoggerInterface $logger; protected IEventLogger $eventLogger; protected IMimeTypeDetector $mimeTypeDetector; + protected IOCMDiscoveryService $discoveryService; + protected ISignatureManager $signatureManager; + protected OCMSignatoryManager $signatoryManager; + protected IAppConfig $appConfig; + protected IURLGenerator $urlGenerator; /** @var int */ private $timeout; @@ -82,6 +132,7 @@ class DAV extends Common { '{DAV:}getcontenttype', '{http://owncloud.org/ns}permissions', '{http://open-collaboration-services.org/ns}share-permissions', + '{http://open-cloud-mesh.org/ns}share-permissions', '{DAV:}resourcetype', '{DAV:}getetag', '{DAV:}quota-available-bytes', @@ -94,6 +145,11 @@ class DAV extends Common { public function __construct(array $parameters) { $this->statCache = new ArrayCache(); $this->httpClientService = Server::get(IClientService::class); + if (isset($parameters['discoveryService'])) { + $this->discoveryService = $parameters['discoveryService']; + } else { + $this->discoveryService = Server::get(IOCMDiscoveryService::class); + } if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) { $host = $parameters['host']; //remove leading http[s], will be generated in createBaseUri() @@ -134,6 +190,10 @@ public function __construct(array $parameters) { // This timeout value will be used for the download and upload of files $this->timeout = Server::get(IConfig::class)->getSystemValueInt('davstorage.request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT); $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + $this->signatureManager = Server::get(ISignatureManager::class); + $this->signatoryManager = Server::get(OCMSignatoryManager::class); + $this->appConfig = Server::get(IAppConfig::class); + $this->urlGenerator = Server::get(IURLGenerator::class); } protected function init(): void { @@ -142,9 +202,21 @@ protected function init(): void { } $this->ready = true; + // If using Bearer auth, use stored access token or exchange refresh token for access token + $userName = $this->user; + if ($this->authType !== null && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER)) { + // Check if we already have an access token stored (password field) + if (!empty($this->password)) { + $userName = $this->password; + } else { + $userName = $this->exchangeRefreshToken(); + } + $this->bearerToken = $userName; + } + $settings = [ 'baseUri' => $this->createBaseUri(), - 'userName' => $this->user, + 'userName' => $userName, 'password' => $this->password, ]; if ($this->authType !== null) { @@ -156,7 +228,7 @@ protected function init(): void { $settings['proxy'] = $proxy; } - $this->client = new Client($settings); + $this->client = new BearerAuthAwareSabreClient($settings); $this->client->setThrowExceptions(true); if ($this->secure === true) { @@ -185,6 +257,180 @@ protected function init(): void { }); } + /** + * Exchange refresh token for access token via the remote server's token endpoint + * + * @return string The access token + * @throws StorageNotAvailableException If token exchange fails + */ + protected function exchangeRefreshToken(): string { + try { + $host = 'https://' . $this->host; + $ocmProvider = $this->discoveryService->discover($host); + $tokenEndpoint = $ocmProvider->getTokenEndPoint(); + + if ($tokenEndpoint === '') { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } + + $client = $this->httpClientService->newClient(); + $clientId = parse_url($this->urlGenerator->getAbsoluteURL('/'), PHP_URL_HOST); + $payload = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'code' => $this->user, // refresh token is stored in user field + ]; + + $options = [ + 'body' => http_build_query($payload), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + + try { + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + 'post', + $tokenEndpoint + ); + $this->logger->debug('Token request signed successfully', ['app' => 'dav']); + } catch (\Exception $e) { + $this->logger->error('Failed to sign token request', [ + 'app' => 'dav', + 'exception' => $e, + 'endpoint' => $tokenEndpoint, + ]); + throw new StorageNotAvailableException('Could not sign token request: ' . $e->getMessage()); + } + + $response = $client->post($tokenEndpoint, $options); + + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + $this->logger->error('Token exchange returned unexpected HTTP status', [ + 'app' => 'dav', + 'status' => $statusCode, + ]); + throw new StorageNotAvailableException('Could not obtain access token: unexpected HTTP status ' . $statusCode); + } + + $data = json_decode($response->getBody(), true); + + if (!is_array($data)) { + $this->logger->error('Token exchange response is not valid JSON', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token: invalid response format'); + } + + $accessToken = $data['access_token'] ?? null; + $tokenType = $data['token_type'] ?? null; + + if (!is_string($accessToken) || $accessToken === '') { + $this->logger->error('Token exchange response missing or invalid access_token', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token: missing access_token field'); + } + + if (!is_string($tokenType) || strtolower($tokenType) !== 'bearer') { + $this->logger->error('Token exchange response has unexpected token_type', [ + 'app' => 'dav', + 'token_type' => $tokenType, + ]); + throw new StorageNotAvailableException('Could not obtain access token: unexpected token_type'); + } + + $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); + return $accessToken; + } catch (OCMProviderException|OCMArgumentException $e) { + $this->logger->error('OCM provider response missing tokenEndPoint', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not discover token endpoint'); + } catch (StorageNotAvailableException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->error('Error exchanging refresh token for access token: ' . $e->getMessage(), [ + 'app' => 'dav', + 'exception' => $e, + ]); + throw new StorageNotAvailableException('Could not obtain access token: ' . $e->getMessage()); + } + } + + /** + * Check if bearer authentication is being used + */ + protected function isBearerAuth(): bool { + return $this->authType !== null + && ($this->authType & BearerAuthAwareSabreClient::AUTH_BEARER); + } + + /** + * @var bool Flag to prevent infinite retry loops during token refresh + */ + private bool $retryingAuth = false; + + /** + * Execute an operation with automatic retry on 401 Unauthorized when using Bearer auth. + * Handles both Sabre ClientHttpException and Guzzle ClientException. + * + * @template T + * @param callable(): T $operation The operation to execute + * @return T The result of the operation + * @throws ClientHttpException + * @throws \GuzzleHttp\Exception\ClientException + */ + protected function withAuthRetry(callable $operation): mixed { + try { + return $operation(); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 401 && !$this->retryingAuth && $this->isBearerAuth()) { + return $this->retryWithFreshToken($operation); + } + throw $e; + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 401 + && !$this->retryingAuth && $this->isBearerAuth()) { + return $this->retryWithFreshToken($operation); + } + throw $e; + } + } + + /** + * Refresh the bearer token and retry the operation. + * + * @template T + * @param callable(): T $operation The operation to retry + * @return T The result of the operation + */ + private function retryWithFreshToken(callable $operation): mixed { + $this->retryingAuth = true; + try { + if (!$this->refreshBearerToken()) { + throw new StorageNotAvailableException('Failed to refresh bearer token'); + } + return $operation(); + } finally { + $this->retryingAuth = false; + } + } + + /** + * Refresh the bearer token. Override in subclasses to add persistence logic. + * + * @return bool True if token was refreshed successfully + */ + protected function refreshBearerToken(): bool { + $this->logger->debug('Bearer token expired, refreshing token', ['app' => 'dav']); + $this->ready = false; + $this->password = ''; // Clear to force token exchange in init() + $this->init(); + return true; + } + /** * Clear the stat cache */ @@ -290,10 +536,10 @@ protected function propfind(string $path): array|false { $this->init(); $response = false; try { - $response = $this->client->propFind( + $response = $this->withAuthRetry(fn () => $this->client->propFind( $this->encodePath($path), $this->getPropfindProperties() - ); + )); $this->statCache->set($path, $response); } catch (ClientHttpException $e) { if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) { @@ -363,22 +609,30 @@ public function fopen(string $path, string $mode) { case 'r': case 'rb': try { - $response = $this->httpClientService - ->newClient() - ->get($this->createBaseUri() . $this->encodePath($path), [ - 'auth' => [$this->user, $this->password], - 'stream' => true, - // set download timeout for users with slow connections or large files - 'timeout' => $this->timeout, - 'verify' => $this->verify, - ]); + $response = $this->withAuthRetry(function () use ($path) { + $auth = [$this->user, $this->password]; + $headers = []; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; + } + return $this->httpClientService + ->newClient() + ->get($this->createBaseUri() . $this->encodePath($path), [ + 'headers' => $headers, + 'auth' => $auth, + 'stream' => true, + // set download timeout for users with slow connections or large files + 'timeout' => $this->timeout, + 'verify' => $this->verify, + ]); + }); } catch (\GuzzleHttp\Exception\ClientException $e) { if ($e->getResponse() instanceof ResponseInterface && $e->getResponse()->getStatusCode() === 404) { return false; - } else { - throw $e; } + throw $e; } if ($response->getStatusCode() !== Http::STATUS_OK) { @@ -473,9 +727,9 @@ public function touch(string $path, ?int $mtime = null): bool { if ($this->file_exists($path)) { try { $this->statCache->remove($path); - $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]); + $this->withAuthRetry(fn () => $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime])); // non-owncloud clients might not have accepted the property, need to recheck it - $response = $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0); + $response = $this->withAuthRetry(fn () => $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0)); if (isset($response['{DAV:}getlastmodified'])) { $remoteMtime = strtotime($response['{DAV:}getlastmodified']); if ($remoteMtime !== $mtime) { @@ -512,17 +766,26 @@ protected function uploadFile(string $path, string $target): void { // invalidate $target = $this->cleanPath($target); $this->statCache->remove($target); - $source = fopen($path, 'r'); - - $this->httpClientService - ->newClient() - ->put($this->createBaseUri() . $this->encodePath($target), [ - 'body' => $source, - 'auth' => [$this->user, $this->password], - // set upload timeout for users with slow connections or large files - 'timeout' => $this->timeout, - 'verify' => $this->verify, - ]); + + $this->withAuthRetry(function () use ($path, $target) { + $source = fopen($path, 'r'); + $auth = [$this->user, $this->password]; + $headers = []; + if ($this->authType === BearerAuthAwareSabreClient::AUTH_BEARER) { + $auth = []; + $headers = ['Authorization' => 'Bearer ' . $this->bearerToken]; + } + $this->httpClientService + ->newClient() + ->put($this->createBaseUri() . $this->encodePath($target), [ + 'body' => $source, + 'headers' => $headers, + 'auth' => $auth, + // set upload timeout for users with slow connections or large files + 'timeout' => $this->timeout, + 'verify' => $this->verify, + ]); + }); $this->removeCachedFile($target); } @@ -537,14 +800,14 @@ public function rename(string $source, string $target): bool { // needs trailing slash in destination $target = rtrim($target, '/') . '/'; } - $this->client->request( + $this->withAuthRetry(fn () => $this->client->request( 'MOVE', $this->encodePath($source), null, [ 'Destination' => $this->createBaseUri() . $this->encodePath($target), ] - ); + )); $this->statCache->clear($source . '/'); $this->statCache->clear($target . '/'); $this->statCache->set($source, false); @@ -552,6 +815,8 @@ public function rename(string $source, string $target): bool { $this->removeCachedFile($source); $this->removeCachedFile($target); return true; + } catch (ClientHttpException $e) { + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } @@ -568,18 +833,20 @@ public function copy(string $source, string $target): bool { // needs trailing slash in destination $target = rtrim($target, '/') . '/'; } - $this->client->request( + $this->withAuthRetry(fn () => $this->client->request( 'COPY', $this->encodePath($source), null, [ 'Destination' => $this->createBaseUri() . $this->encodePath($target), ] - ); + )); $this->statCache->clear($target . '/'); $this->statCache->set($target, true); $this->removeCachedFile($target); return true; + } catch (ClientHttpException $e) { + $this->convertException($e); } catch (\Exception $e) { $this->convertException($e); } @@ -687,7 +954,7 @@ protected function encodePath(string $path): string { protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool { $path = $this->cleanPath($path); try { - $response = $this->client->request($method, $this->encodePath($path), $body); + $response = $this->withAuthRetry(fn () => $this->client->request($method, $this->encodePath($path), $body)); return $response['statusCode'] === $expected; } catch (ClientHttpException $e) { if ($e->getHttpStatus() === 404 && $method === 'DELETE') { @@ -856,11 +1123,11 @@ public function getDirectoryContent(string $directory): \Traversable { $this->init(); $directory = $this->cleanPath($directory); try { - $responses = $this->client->propFind( + $responses = $this->withAuthRetry(fn () => $this->client->propFind( $this->encodePath($directory), $this->getPropfindProperties(), 1 - ); + )); array_shift($responses); //the first entry is the current directory if (!$this->statCache->hasKey($directory)) { @@ -874,6 +1141,8 @@ public function getDirectoryContent(string $directory): \Traversable { $this->statCache->set($file, $response); yield $this->getMetaFromPropfind($file, $response); } + } catch (ClientHttpException $e) { + $this->convertException($e, $directory); } catch (\Exception $e) { $this->convertException($e, $directory); } diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index bbbace0d882c6..928aa74613642 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -24,6 +24,7 @@ class OCMProvider implements IOCMProvider { private string $inviteAcceptDialog = ''; private array $capabilities = []; private string $endPoint = ''; + private string $tokenEndPoint = ''; /** @var IOCMResource[] */ private array $resourceTypes = []; private ?Signatory $signatory = null; @@ -111,6 +112,27 @@ public function getEndPoint(): string { return $this->endPoint; } + /** + * @param string $tokenEndPoint + * + * @return $this + */ + public function setTokenEndPoint(string $endPoint): static { + $this->tokenEndPoint = $endPoint; + + return $this; + } + + /** + * @return string + */ + public function getTokenEndPoint(): string { + if (in_array('exchange-token', $this->capabilities)) { + return $this->tokenEndPoint; + } + return ''; + } + /** * @return string */ @@ -250,6 +272,12 @@ public function import(array $data): static { $this->setSignatory($signatory); } } + if (isset($data['capabilities'])) { + $this->setCapabilities($data['capabilities']); + } + if (isset($data['tokenEndPoint'])) { + $this->setTokenEndPoint($data['tokenEndPoint']); + } if (!$this->looksValid()) { throw new OCMProviderException('remote provider does not look valid'); @@ -289,6 +317,10 @@ public function jsonSerialize(): array { if ($capabilities) { $response['capabilities'] = $capabilities; } + $tokenEndpoint = $this->getTokenEndPoint(); + if ($tokenEndpoint) { + $response['tokenEndPoint'] = $tokenEndpoint; + } $inviteAcceptDialog = $this->getInviteAcceptDialog(); if ($inviteAcceptDialog !== '') { $response['inviteAcceptDialog'] = $inviteAcceptDialog; diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 17a84c12d5007..00e3842a2807e 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -49,7 +49,7 @@ #[Consumable(since: '28.0.0')] final class OCMDiscoveryService implements IOCMDiscoveryService { private ICache $cache; - public const API_VERSION = '1.1.0'; + public const API_VERSION = '1.1.2'; private ?IOCMProvider $localProvider = null; /** @var array */ private array $remoteProviders = []; @@ -91,6 +91,7 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider } if (array_key_exists($remote, $this->remoteProviders)) { + return $this->remoteProviders[$remote]; } @@ -127,7 +128,6 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider $remote . '/ocm-provider', ]; - foreach ($urls as $url) { $exception = null; $body = null; @@ -191,6 +191,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { } $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); + $tokenUrl = $this->urlGenerator->linkToRouteAbsolute('dav.Token.accessToken'); $pos = strrpos($url, '/'); if ($pos === false) { $this->logger->debug('generated route should contain a slash character'); @@ -200,7 +201,8 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider { $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', 'exchange-token']); + $provider->setTokenEndPoint($tokenUrl); // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); diff --git a/lib/private/Server.php b/lib/private/Server.php index e24d47f3f0611..636ed60f22e98 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1213,7 +1213,11 @@ public function __construct( $this->registerAlias(\OCP\GlobalScale\IConfig::class, \OC\GlobalScale\Config::class); $this->registerAlias(ICloudFederationProviderManager::class, CloudFederationProviderManager::class); $this->registerService(ICloudFederationFactory::class, function (Server $c) { - return new CloudFederationFactory(); + return new CloudFederationFactory( + $c->get(\OCP\OCM\IOCMDiscoveryService::class), + $c->get(\OCP\Federation\ICloudIdManager::class), + $c->get(\Psr\Log\LoggerInterface::class) + ); }); $this->registerAlias(IControllerMethodReflector::class, ControllerMethodReflector::class); diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index db5fcf5ae94e3..91e627e36cf90 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -8,6 +8,8 @@ namespace OC\Share20; use ArrayIterator; +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Core\AppInfo\ConfigLexicon; use OC\Files\Filesystem; use OC\Files\Mount\MoveableMount; @@ -44,6 +46,7 @@ use OCP\Security\IHasher; use OCP\Security\ISecureRandom; use OCP\Security\PasswordContext; +use OCP\Server; use OCP\Share; use OCP\Share\Events\BeforeShareCreatedEvent; use OCP\Share\Events\BeforeShareDeletedEvent; @@ -1435,6 +1438,19 @@ public function getShareByToken(string $token): IShare { } } + // Try to fetch a federated share by access token + if ($share === null) { + try { + $provider = $this->factory->getProviderForType(IShare::TYPE_REMOTE); + $tokenProvider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $tokenProvider->getToken($token); + $refreshToken = $accessTokenDb->getUID(); + $share = $provider->getShareByToken($refreshToken); + } catch (ProviderException|ShareNotFound|InvalidTokenException $e) { + } + } + + // If it is not a link share try to fetch a mail share by token if ($share === null && $this->shareProviderExists(IShare::TYPE_EMAIL)) { try { diff --git a/lib/private/User/Session.php b/lib/private/User/Session.php index 811c5ba4bc326..c5744e90aa9b4 100644 --- a/lib/private/User/Session.php +++ b/lib/private/User/Session.php @@ -619,14 +619,35 @@ private function loginWithToken($token) { // Ignore and use empty string instead } - $this->manager->emit('\OC\User', 'preLogin', [$dbToken->getLoginName(), $password]); - $user = $this->manager->get($uid); if (is_null($user)) { + // Maybe this is an access token. We keep the refresh tokens as UID of access tokens + try { + $token = $uid; + $dbToken = $this->tokenProvider->getToken($token); + } catch (InvalidTokenException $ex) { + return false; + } + $uid = $dbToken->getUID(); + + // When logging in with token, the password must be decrypted first before passing to login hook + $password = ''; + try { + $password = $this->tokenProvider->getPassword($dbToken, $token); + } catch (PasswordlessTokenException $ex) { + // Ignore and use empty string instead + } // user does not exist - return false; + $user = $this->manager->get($uid); + if (is_null($user)) { + return false; + } } + $this->manager->emit('\OC\User', 'preLogin', [$dbToken->getLoginName(), $password]); + + // See line 173 in this module, needed for completeLogin + OC_User::setIncognitoMode(false); return $this->completeLogin( $user, [ @@ -830,7 +851,10 @@ public function tryTokenLogin(IRequest $request) { } else { return false; } + return $this->doTryTokenLogin($token); + } + private function doTryTokenLogin(string $token): bool { if (!$this->loginWithToken($token)) { return false; } @@ -850,6 +874,7 @@ public function tryTokenLogin(IRequest $request) { $this->session->set('app_password', $token); } elseif ($dbToken instanceof PublicKeyToken && $dbToken->getType() === IToken::ONETIME_TOKEN) { $this->tokenProvider->invalidateTokenById($dbToken->getUID(), $dbToken->getId()); + $request = \OCP\Server::get(IRequest::class); if ($request->getPathInfo() !== '/core/getapppassword-onetime') { return false; } diff --git a/lib/public/Authentication/Token/IToken.php b/lib/public/Authentication/Token/IToken.php index 546e6a4225550..8ad8f298f1942 100644 --- a/lib/public/Authentication/Token/IToken.php +++ b/lib/public/Authentication/Token/IToken.php @@ -130,4 +130,11 @@ public function setPassword(string $password): void; * @since 28.0.0 */ public function setExpires(?int $expires): void; + + /** + * Get the type of the token + * @return int One of IToken::TEMPORARY_TOKEN, IToken::PERMANENT_TOKEN, or IToken::WIPE_TOKEN + * @since 32.0.0 + */ + public function getType(): int; } diff --git a/lib/public/OCM/ICapabilityAwareOCMProvider.php b/lib/public/OCM/ICapabilityAwareOCMProvider.php index faf44067d1255..0bfc9aaad38a1 100644 --- a/lib/public/OCM/ICapabilityAwareOCMProvider.php +++ b/lib/public/OCM/ICapabilityAwareOCMProvider.php @@ -15,4 +15,21 @@ * @deprecated 33.0.0 {@see IOCMProvider} */ interface ICapabilityAwareOCMProvider extends IOCMProvider { + /** + * get the token endpoint URL + * + * @return string + * @since 32.0.0 + */ + public function getTokenEndPoint(): string; + + /** + * set the token endpoint URL + * + * @param string $endPoint + * + * @return $this + * @since 32.0.0 + */ + public function setTokenEndPoint(string $endPoint): static; } diff --git a/lib/public/OCM/IOCMProvider.php b/lib/public/OCM/IOCMProvider.php index 83f2871d5c5d1..12b620bcbc8f8 100644 --- a/lib/public/OCM/IOCMProvider.php +++ b/lib/public/OCM/IOCMProvider.php @@ -158,8 +158,25 @@ public function setCapabilities(array $capabilities): static; * @return $this * @since 33.0.0 */ - public function setInviteAcceptDialog(string $inviteAcceptDialog): static; + + /** + * get the token endpoint URL + * + * @return string + * @since 33.0.0 + */ + public function getTokenEndPoint(): string; + + /** + * set the token endpoint URL + * + * @param string $endPoint + * + * @return $this + * @since 33.0.0 + */ + public function setTokenEndPoint(string $endPoint): static; /** * extract a specific string value from the listing of protocols, based on resource-name and protocol-name * diff --git a/openapi.json b/openapi.json index 50c44e2bde168..85a472bf2bea7 100644 --- a/openapi.json +++ b/openapi.json @@ -41,6 +41,10 @@ "name": "cloud_federation_api/request_handler", "description": "Open-Cloud-Mesh-API" }, + { + "name": "dav/token", + "description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens" + }, { "name": "federatedfilesharing/mount_public_link", "description": "Class MountPublicLinkController convert public links to federated shares" @@ -16937,23 +16941,25 @@ }, "protocol": { "type": "object", - "description": "e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]", + "description": "Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]] or Multi format: ['name' => 'multi', 'webdav' => [...]]", "required": [ - "name", - "options" + "name" ], "properties": { "name": { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, "options": { "type": "object", "additionalProperties": { "type": "object" } + }, + "webdav": { + "type": "object", + "additionalProperties": { + "type": "object" + } } } }, diff --git a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php index 51915fc1d4b5a..3af393f3a3bd4 100644 --- a/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php +++ b/tests/lib/Authentication/Token/PublicKeyTokenProviderTest.php @@ -451,6 +451,7 @@ public function testRenewSessionTokenWithPassword(): void { public function testGetToken(): void { $token = new PublicKeyToken(); + $token->setType(IToken::TEMPORARY_TOKEN); $this->config->method('getSystemValue') ->with('secret') diff --git a/tests/lib/User/SessionTest.php b/tests/lib/User/SessionTest.php index 84d5bc898a057..9817f7fd734e9 100644 --- a/tests/lib/User/SessionTest.php +++ b/tests/lib/User/SessionTest.php @@ -331,11 +331,17 @@ public function testPasswordlessLoginNoLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); - $session->expects($this->never()) - ->method('set'); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('foo'); + $user->method('isEnabled')->willReturn(true); + $manager->method('get') + ->with('foo') + ->willReturn($user); + $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); @@ -369,11 +375,17 @@ public function testLoginLastCheckUpdate(): void { ->getMock(); $userSession = new Session($manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher); - $session->expects($this->never()) - ->method('set'); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('foo'); + $user->method('isEnabled')->willReturn(true); + $manager->method('get') + ->with('foo') + ->willReturn($user); + $session->expects($this->once()) ->method('regenerateId'); $token = new PublicKeyToken(); + $token->setId(1); $token->setLoginName('foo'); $token->setLastCheck(0); // Never $token->setUid('foo'); @@ -1324,4 +1336,5 @@ public function testLogClientInThrottlerEmail(): void { $this->assertFalse($userSession->logClientIn('john@foo.bar', 'I-AM-A-PASSWORD', $request, $this->throttler)); } + }