diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index bfccb2fe20eaf..7f323b9f3086c 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -7,6 +7,7 @@ namespace OCA\CloudFederationAPI\Controller; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\Db\FederatedInviteMapper; @@ -43,6 +44,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 +93,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 +128,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 +138,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( @@ -490,6 +516,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); + $refreshToken = $accessTokenDb->getUID(); + $identity = $provider->getFederationIdFromSharedSecret($refreshToken, $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/v1/publicwebdav.php b/apps/dav/appinfo/v1/publicwebdav.php index 2eaf05ca2d9b6..53ff67109756d 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(); + } $owner = $share->getShareOwner(); $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 6716b6c5ac1cd..22c356260a8cf 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -261,6 +261,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', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5b5b1e3fcb413..272c88aacf525 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -276,6 +276,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', diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 23453ae8efbab..e4eae2143cb65 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,30 @@ 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) { + $this->userSession->doTryTokenLogin($bearerToken); + $loggedIn = $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..fbad90dd629a8 --- /dev/null +++ b/apps/dav/lib/Controller/TokenController.php @@ -0,0 +1,191 @@ +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 + ); + } + + $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, + $refreshToken, // Keep refresh token with access token as UID + $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); + + 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/openapi.json b/apps/dav/openapi.json index 8b7c6221eaeba..6d00810c6dbab 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1153,5 +1153,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/Controller/TokenControllerTest.php b/apps/dav/tests/unit/Controller/TokenControllerTest.php new file mode 100644 index 0000000000000..17e4038955d53 --- /dev/null +++ b/apps/dav/tests/unit/Controller/TokenControllerTest.php @@ -0,0 +1,376 @@ +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->controller = new TokenController( + $this->request, + $this->tokenProvider, + $this->random, + $this->timeFactory, + $this->logger, + $this->signatureManager, + $this->signatoryManager, + $this->appConfig, + ); + } + + 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); + + $refreshToken = $this->createMock(IToken::class); + $refreshToken->method('getType')->willReturn(IToken::PERMANENT_TOKEN); + $refreshToken->method('getId')->willReturn(123); + $refreshToken->method('getLoginName')->willReturn('testuser'); + + $this->tokenProvider->method('getToken') + ->with('valid-refresh-token') + ->willReturn($refreshToken); + + $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); + $this->tokenProvider->method('generateToken') + ->with( + 'generated-access-token', + 'valid-refresh-token', + '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); + + // 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/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 1d1c00772c5bf..4b0ef725dc1a7 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -7,8 +7,11 @@ */ namespace OCA\FederatedFileSharing; +use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Token\IToken; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; @@ -22,6 +25,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 +175,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(), @@ -739,6 +752,25 @@ 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 { + $provider = Server::get(PublicKeyTokenProvider::class); + $accessTokenDb = $provider->getToken($token); + $refreshToken = $accessTokenDb->getUID(); + + $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($refreshToken))) + ->executeQuery(); + + $data = $cursor->fetch(); + } catch (InvalidTokenException) { + // Token is not a valid access token, 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/OCM/CloudFederationProviderFiles.php b/apps/federatedfilesharing/lib/OCM/CloudFederationProviderFiles.php index a5ae8c87abd25..bf642fcc639b5 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->setPassword($accessToken); // access token (empty if no token exchange) $externalShare->setName($name); $externalShare->setOwner($owner); $externalShare->setShareType($shareType); @@ -684,4 +719,73 @@ 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); + + $data = json_decode($response->getBody(), true); + + if (isset($data['access_token'])) { + $this->logger->debug('Successfully exchanged token for access token', ['remote' => $remote]); + return $data['access_token']; + } + + $this->logger->warning('Token exchange response missing access_token', ['remote' => $remote, 'response' => $data]); + return null; + } 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 0852a4b548904..1c4630c88201b 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, @@ -145,7 +165,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(), @@ -183,7 +203,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) { @@ -198,7 +218,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()); } @@ -227,7 +247,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(), @@ -280,7 +300,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(), @@ -369,7 +389,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(), @@ -440,7 +460,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(), @@ -873,9 +893,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); @@ -914,11 +934,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/files_external/lib/Migration/DummyUserSession.php b/apps/files_external/lib/Migration/DummyUserSession.php index 1ebf0e1ec4f85..3946d8fad381c 100644 --- a/apps/files_external/lib/Migration/DummyUserSession.php +++ b/apps/files_external/lib/Migration/DummyUserSession.php @@ -54,4 +54,8 @@ public function getImpersonatingUserID() : ?string { public function setImpersonatingUserID(bool $useCurrentUser = true): void { //no OP } + + public function doTryTokenLogin(string $token): bool { + return false; + } } diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php index 44bf97512ef98..fb39e2d10fd6a 100644 --- a/apps/files_sharing/lib/External/Manager.php +++ b/apps/files_sharing/lib/External/Manager.php @@ -563,4 +563,23 @@ 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): void { + try { + $share = $this->externalShareMapper->getShareByToken($shareToken); + $share->setPassword($accessToken); + $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/Storage.php b/apps/files_sharing/lib/External/Storage.php index e8de4bc63b5d3..4be7193c4ef54 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -48,6 +48,7 @@ class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, private bool $updateChecked = false; private ExternalShareManager $manager; private IConfig $config; + private bool $tokenRefreshed = false; /** * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options @@ -66,10 +67,22 @@ public function __construct($options) { $ocmProvider = $discoveryService->discover($this->cloudId->getRemote()); $webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav'); $remote = $ocmProvider->getEndPoint(); + $authType = \Sabre\DAV\Client::AUTH_BASIC; + $capabilities = $ocmProvider->getCapabilities(); + if (in_array('exchange-token', $capabilities)) { + $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; + } } 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; + } + + // If we have a stored access token (password), use Bearer auth regardless of discovery + // This handles the case where the share was created with must-exchange-token + if (!empty($options['password'])) { + $authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER; } $host = parse_url($remote, PHP_URL_HOST); @@ -92,12 +105,46 @@ public function __construct($options) { 'host' => $host, 'root' => $webDavEndpoint, 'user' => $options['token'], - 'authType' => \Sabre\DAV\Client::AUTH_BASIC, - 'password' => (string)$options['password'] + 'authType' => $authType, + 'password' => (string)$options['password'], + 'discoveryService' => $discoveryService, ] ); } + /** + * Refresh the bearer token. Extends parent to also persist to database. + * + * @return bool True if token was refreshed successfully + */ + protected function refreshBearerToken(): bool { + if ($this->tokenRefreshed) { + // only try to refresh once per request + return false; + } + $this->tokenRefreshed = true; + + try { + $newAccessToken = $this->exchangeRefreshToken(); + $this->password = $newAccessToken; + + $this->manager->updateAccessToken($this->token, $newAccessToken); + + $this->ready = false; + $this->client = null; + $this->init(); + + $this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']); + return true; + } catch (\Exception $e) { + $this->logger->warning('Failed to refresh access token', [ + 'app' => 'files_sharing', + 'exception' => $e, + ]); + return false; + } + } + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; diff --git a/build/integration/features/bootstrap/Sharing.php b/build/integration/features/bootstrap/Sharing.php index bd12f1f2455a6..9669d8f5648b6 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::assertRegExp('/^[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::assertRegExp('/^[a-zA-Z0-9]{15,32}$/', (string)$returnedShare->$field, "Field '$field' is not a token"); } elseif (strpos($contentExpected, 'REGEXP ') === 0) { Assert::assertRegExp(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 49a91cced9c28..fb8e1c6fba464 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; @@ -60,6 +103,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 */ @@ -71,6 +116,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; @@ -93,6 +143,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() @@ -131,6 +186,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 { @@ -139,9 +198,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) { @@ -153,7 +224,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) { @@ -179,6 +250,156 @@ 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); + + $body = $response->getBody(); + $data = json_decode($body, true); + + if (isset($data['access_token'])) { + $this->logger->debug('Successfully exchanged refresh token for access token', ['app' => 'dav']); + return $data['access_token']; + } else { + $this->logger->error('Failed to get access token from response', ['app' => 'dav']); + throw new StorageNotAvailableException('Could not obtain access token'); + } + } 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 */ @@ -284,10 +505,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) { @@ -357,21 +578,29 @@ 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 - ]); + $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 + ]); + }); } 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) { @@ -466,9 +695,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) { @@ -505,16 +734,25 @@ 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 - ]); + + $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 + ]); + }); $this->removeCachedFile($target); } @@ -529,14 +767,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); @@ -544,6 +782,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); } @@ -560,18 +800,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); } @@ -679,7 +921,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') { @@ -848,11 +1090,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)) { @@ -866,6 +1108,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 f608d3ee26d28..1f0fd189966e7 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1197,7 +1197,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 1d72ec63cb0ff..220226c83a760 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; @@ -42,6 +44,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; @@ -1390,6 +1393,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..6b1eaad18a4c8 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); + } + public 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/IUserSession.php b/lib/public/IUserSession.php index 42cc437aabae1..9d80bb95735ab 100644 --- a/lib/public/IUserSession.php +++ b/lib/public/IUserSession.php @@ -92,4 +92,13 @@ public function getImpersonatingUserID(): ?string; * @since 18.0.0 */ public function setImpersonatingUserID(bool $useCurrentUser = true): void; + + /** + * Try to login with the given token + * + * @param string $token + * @return bool true if successful + * @since 32.0.0 + */ + public function doTryTokenLogin(string $token): bool; } 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 d034244903413..4bbefec06331a 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" @@ -16788,23 +16792,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..cc6c42fa88c20 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,136 @@ public function testLogClientInThrottlerEmail(): void { $this->assertFalse($userSession->logClientIn('john@foo.bar', 'I-AM-A-PASSWORD', $request, $this->throttler)); } + + public function testDoTryTokenLoginSuccess(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(true); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::PERMANENT_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('valid-token') + ->willReturn($token); + + $appPasswordSet = false; + $session->expects($this->atLeastOnce()) + ->method('set') + ->willReturnCallback(function ($key, $value) use (&$appPasswordSet) { + // We expect app_password to be set for permanent tokens + if ($key === 'app_password') { + $appPasswordSet = true; + $this->assertEquals('valid-token', $value); + } + return true; + }); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertTrue($userSession->doTryTokenLogin('valid-token')); + $this->assertTrue($appPasswordSet, 'app_password should be set for permanent tokens'); + } + + public function testDoTryTokenLoginInvalidToken(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $this->tokenProvider->method('getToken') + ->with('invalid-token') + ->willThrowException(new InvalidTokenException()); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertFalse($userSession->doTryTokenLogin('invalid-token')); + } + + public function testDoTryTokenLoginTemporaryToken(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(true); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::TEMPORARY_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('temp-token') + ->willReturn($token); + + // app_password should NOT be set for temporary tokens + $session->expects($this->atLeastOnce()) + ->method('set') + ->willReturnCallback(function ($key, $value) { + $this->assertNotEquals('app_password', $key, 'app_password should not be set for temporary tokens'); + return true; + }); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->assertTrue($userSession->doTryTokenLogin('temp-token')); + } + + public function testDoTryTokenLoginDisabledUser(): void { + $manager = $this->createMock(Manager::class); + $session = $this->createMock(ISession::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $user->method('isEnabled')->willReturn(false); + + $manager->method('get') + ->with('testuser') + ->willReturn($user); + + $token = $this->createMock(PublicKeyToken::class); + $token->method('getUID')->willReturn('testuser'); + $token->method('getLoginName')->willReturn('testuser'); + $token->method('getType')->willReturn(\OCP\Authentication\Token\IToken::PERMANENT_TOKEN); + $token->method('getLastCheck')->willReturn($this->timeFactory->getTime()); + + $this->tokenProvider->method('getToken') + ->with('valid-token') + ->willReturn($token); + + /** @var Session $userSession */ + $userSession = $this->getMockBuilder(Session::class) + ->setConstructorArgs([$manager, $session, $this->timeFactory, $this->tokenProvider, $this->config, $this->random, $this->lockdownManager, $this->logger, $this->dispatcher]) + ->onlyMethods(['setMagicInCookie']) + ->getMock(); + + $this->expectException(LoginException::class); + $userSession->doTryTokenLogin('valid-token'); + } }