diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fddaabd..261edb250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Update npm dependencies [#1123](https://github.com/nextcloud/integration_openproject/pull/1123) +- Make OAuth2 Client class usage compatible with older Nextcloud versions [#1148](https://github.com/nextcloud/integration_openproject/pull/1148) ### Removed diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 92ede69ee..4613bea6b 100755 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -12,8 +12,8 @@ use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use OC\User\NoUserException; -use OCA\OAuth2\Controller\SettingsController; use OCA\OAuth2\Exceptions\ClientNotFoundException; +use OCA\OAuth2\Service\ClientService; use OCA\OpenProject\AppInfo\Application; use OCA\OpenProject\Exception\OpenprojectErrorException; use OCA\OpenProject\Exception\OpenprojectGroupfolderSetupConflictException; @@ -71,11 +71,6 @@ class ConfigController extends Controller { */ private $oauthService; - /** - * @var SettingsController - */ - private $oauthSettingsController; - private SettingsService $settingsService; public function __construct( @@ -88,7 +83,7 @@ public function __construct( OpenProjectAPIService $openprojectAPIService, LoggerInterface $logger, OauthService $oauthService, - SettingsController $oauthSettingsController, + private readonly ClientService $clientService, SettingsService $settingsService, ?string $userId ) { @@ -101,7 +96,6 @@ public function __construct( $this->logger = $logger; $this->userId = $userId; $this->oauthService = $oauthService; - $this->oauthSettingsController = $oauthSettingsController; $this->settingsService = $settingsService; } @@ -583,7 +577,7 @@ private function deleteOauthClient(): void { if ($oauthClientInternalId !== '') { $id = (int) $oauthClientInternalId; try { - $this->oauthSettingsController->deleteClient($id); + $this->clientService->deleteClient($id); } catch (ClientNotFoundException $e) { } $this->config->deleteAppValue(Application::APP_ID, 'nc_oauth_client_id'); diff --git a/lib/Service/OauthService.php b/lib/Service/OauthService.php index bd4c5b520..973257a83 100644 --- a/lib/Service/OauthService.php +++ b/lib/Service/OauthService.php @@ -49,20 +49,24 @@ public function __construct(ClientMapper $clientMapper, */ public function createNcOauthClient(string $name, string $redirectUri): array { $clientId = $this->secureRandom->generate(64, self::validChars); + $clientSecret = $this->secureRandom->generate(64, self::validChars); + $hashedClientSecret = bin2hex($this->crypto->calculateHMAC($clientSecret)); + $redirectUri = sprintf($redirectUri, $clientId); + $client = new Client(); - $client->setName($name); - $client->setRedirectUri(sprintf($redirectUri, $clientId)); - $secret = $this->secureRandom->generate(64, self::validChars); - $client->setSecret(bin2hex($this->crypto->calculateHMAC($secret))); - $client->setClientIdentifier($clientId); + $this->setClientProperty($client, 'name', $name); + $this->setClientProperty($client, 'redirectUri', $redirectUri); + $this->setClientProperty($client, 'clientIdentifier', $clientId); + $this->setClientProperty($client, 'secret', $hashedClientSecret); + $client = $this->clientMapper->insert($client); return [ - 'id' => $client->getId(), - 'nextcloud_oauth_client_name' => $client->getName(), - 'openproject_redirect_uri' => $client->getRedirectUri(), - 'nextcloud_client_id' => $client->getClientIdentifier(), - 'nextcloud_client_secret' => $secret, + 'id' => $this->getClientProperty($client, 'id'), + 'nextcloud_oauth_client_name' => $this->getClientProperty($client, 'name'), + 'openproject_redirect_uri' => $this->getClientProperty($client, 'redirectUri'), + 'nextcloud_client_id' => $this->getClientProperty($client, 'clientIdentifier'), + 'nextcloud_client_secret' => $clientSecret, ]; } @@ -74,10 +78,10 @@ public function getClientInfo(int $id): ?array { try { $client = $this->clientMapper->getByUid($id); return [ - 'id' => $client->getId(), - 'nextcloud_oauth_client_name' => $client->getName(), - 'openproject_redirect_uri' => $client->getRedirectUri(), - 'nextcloud_client_id' => $client->getClientIdentifier() + 'id' => $this->getClientProperty($client, 'id'), + 'nextcloud_oauth_client_name' => $this->getClientProperty($client, 'name'), + 'openproject_redirect_uri' => $this->getClientProperty($client, 'redirectUri'), + 'nextcloud_client_id' => $this->getClientProperty($client, 'clientIdentifier') ]; } catch (ClientNotFoundException $e) { return null; @@ -92,13 +96,53 @@ public function getClientInfo(int $id): ?array { public function setClientRedirectUri(int $id, string $opUrl): bool { try { $client = $this->clientMapper->getByUid($id); - $clientId = $client->getClientIdentifier(); + $clientId = $this->getClientProperty($client, 'clientIdentifier'); + $redirectUri = rtrim($opUrl, '/') .'/oauth_clients/'.$clientId.'/callback'; - $client->setRedirectUri($redirectUri); + $client = $this->setClientProperty($client, 'redirectUri', $redirectUri); + $this->clientMapper->update($client); return true; } catch (ClientNotFoundException $e) { return false; } } + + /** + * @param Client $client + * @param string $property + * @param string|int $value + * + * @return Client + */ + public function setClientProperty(Client $client, string $property, string|int $value): Client { + $fn = 'set' . ucfirst($property); + // In NC35, OAuth2 Client class changed to attribute-based entity. + // NOTE: we can remove setClientProperty and getClientProperty methods + // once we drop support for NC34 and below. + if (\method_exists(Client::class, 'addType')) { + $client->$fn($value); + } else { + $client->{$property} = $value; + } + + return $client; + } + + /** + * @param Client $client + * @param string $property + * + * @return string|int + */ + public function getClientProperty(Client $client, string $property): string|int { + $fn = 'get' . ucfirst($property); + // In NC35, OAuth2 Client class changed to attribute-based entity. + // NOTE: we can remove setClientProperty and getClientProperty methods + // once we drop support for NC34 and below. + if (\method_exists(Client::class, 'addType')) { + return $client->$fn(); + } + return $client->{$property}; + } } diff --git a/tests/lib/Controller/ConfigControllerTest.php b/tests/lib/Controller/ConfigControllerTest.php index 318204b38..dc0181db5 100644 --- a/tests/lib/Controller/ConfigControllerTest.php +++ b/tests/lib/Controller/ConfigControllerTest.php @@ -8,7 +8,11 @@ namespace OCA\OpenProject\Controller; use GuzzleHttp\Exception\ConnectException; -use OCA\OAuth2\Controller\SettingsController; +use OC\Authentication\Token\IProvider; +use OCA\OAuth2\Db\AccessTokenMapper; +use OCA\OAuth2\Db\Client; +use OCA\OAuth2\Db\ClientMapper; +use OCA\OAuth2\Service\ClientService; use OCA\OpenProject\AppInfo\Application; use OCA\OpenProject\Exception\OpenprojectErrorException; use OCA\OpenProject\Service\OauthService; @@ -25,6 +29,7 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -126,6 +131,32 @@ public function getSettingsService(): SettingsService { ); } + /** + * @param ClientMapper|null $clientMapper + * @param IUserManager|null $userManager + * @param AccessTokenMapper|null $accessTokenMapper + * + * @return ClientService + */ + public function getClientService( + ClientMapper $clientMapper = null, + AccessTokenMapper $accessTokenMapper = null, + ): ClientService { + if ($clientMapper === null) { + $clientMapper = $this->createMock(ClientMapper::class); + $clientMapper->method('getByUid')->willReturn(new Client()); + } + return new ClientService( + $this->createMock(ISecureRandom::class), + $this->createMock(ICrypto::class), + $clientMapper, + $this->createMock(IUserManager::class), + $this->createMock(IProvider::class), + $this->createMock(LoggerInterface::class), + $accessTokenMapper ?? $this->createMock(AccessTokenMapper::class), + ); + } + /** * Format has to be [ => ] with the first being the constructor parameter name and the second one the mock. * Example: ['config' => $createdMockObject] @@ -144,7 +175,7 @@ private function getConfigControllerConstructArgs(array $constructParams = []): 'openprojectAPIService' => $this->createMock(OpenProjectAPIService::class), 'loggerInterface' => $this->createMock(LoggerInterface::class), 'oauthService' => $this->createMock(OauthService::class), - 'settingsController' => $this->createMock(SettingsController::class), + 'clientService' => $this->getClientService(), 'settingsService' => $this->getSettingsService(), 'userId' => 'testUser' ]; @@ -768,9 +799,7 @@ public function testSetAdminConfigClearUserDataChangeNCOauthClient( $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->getMockBuilder(SettingsController::class) - ->disableOriginalConstructor() - ->getMock(); + $configMock ->method('getAppValue') ->willReturnMap([ @@ -788,23 +817,31 @@ public function testSetAdminConfigClearUserDataChangeNCOauthClient( [$testUser, Application::APP_ID, 'token', '', 'testtoken'], ]); + $clientService = $this->getClientService(); if ($updateNCOAuthClient) { if ($updateNCOAuthClient === 'change') { $oauthServiceMock ->expects($this->once()) ->method('setClientRedirectUri') ->with(123, $credsToUpdate['openproject_instance_url']); - $oauthSettingsControllerMock - ->expects($this->never()) - ->method('deleteClient'); + + $clientMapperMock = $this->createMock(ClientMapper::class); + $clientMapperMock->expects($this->never())->method('getByUid'); + $clientMapperMock->expects($this->never())->method('delete'); + $accessTokenMapperMock = $this->createMock(AccessTokenMapper::class); + $accessTokenMapperMock->expects($this->never())->method('deleteByClientId'); + $clientService = $this->getClientService($clientMapperMock, $accessTokenMapperMock); } else { // delete the client $oauthServiceMock ->expects($this->never()) ->method('setClientRedirectUri'); - $oauthSettingsControllerMock - ->expects($this->once()) - ->method('deleteClient') - ->with(123); + + $clientMapperMock = $this->createMock(ClientMapper::class); + $clientMapperMock->expects($this->once())->method('getByUid')->willReturn(new Client()); + $clientMapperMock->expects($this->once())->method('delete'); + $accessTokenMapperMock = $this->createMock(AccessTokenMapper::class); + $accessTokenMapperMock->expects($this->once())->method('deleteByClientId'); + $clientService = $this->getClientService($clientMapperMock, $accessTokenMapperMock); } } else { $oauthServiceMock->expects($this->never())->method('setClientRedirectUri'); @@ -845,7 +882,7 @@ public function testSetAdminConfigClearUserDataChangeNCOauthClient( 'userManager' => $userManager, 'openprojectAPIService' => $apiService, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, + 'clientService' => $clientService, 'userId' => 'test101' ]); $configController = new ConfigController(...$constructArgs); @@ -984,7 +1021,6 @@ public function testSetAdminConfigForOPOAuthTokenRevoke(array $oldConfig, array ->getMock(); $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->createMock('OCA\OAuth2\Controller\SettingsController'); if ($mode === "reset") { $this->expectMethodCalls($configMock, 'deleteAppValue', [ @@ -1060,7 +1096,6 @@ public function testSetAdminConfigForOPOAuthTokenRevoke(array $oldConfig, array 'userManager' => $userManager, 'openprojectAPIService' => $apiService, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, 'userId' => 'test101' ]); $configController = new ConfigController(...$constructArgs); @@ -1118,7 +1153,6 @@ public function testOPOAuthTokenRevokeErrors($errorCode, $exception, $errMessage ->getMock(); $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->createMock('OCA\OAuth2\Controller\SettingsController'); $loggerInterfaceMock = $this->createMock(LoggerInterface::class); $this->expectMethodCalls($configMock, 'getAppValue', [ @@ -1196,7 +1230,6 @@ public function testOPOAuthTokenRevokeErrors($errorCode, $exception, $errMessage 'openprojectAPIService' => $apiService, 'loggerInterface' => $loggerInterfaceMock, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, 'userId' => 'admin' ]); $configController = new ConfigController(...$constructArgs); @@ -1228,7 +1261,6 @@ public function testOPOAuthTokenRevokeDoesNotOccurIfNoOPOAuthClientHasChanged() ->getMock(); $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->createMock('OCA\OAuth2\Controller\SettingsController'); $loggerInterfaceMock = $this->createMock(LoggerInterface::class); $configMock @@ -1256,7 +1288,6 @@ public function testOPOAuthTokenRevokeDoesNotOccurIfNoOPOAuthClientHasChanged() 'openprojectAPIService' => $apiService, 'loggerInterface' => $loggerInterfaceMock, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, 'userId' => 'admin' ]); $configController = new ConfigController(...$constructArgs); @@ -1476,9 +1507,6 @@ public function testSetAdminConfigOIDCAuthSetting( $userManager = $this->checkForUsersCountBeforeTest(); $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->getMockBuilder(SettingsController::class) - ->disableOriginalConstructor() - ->getMock(); $configMock ->method('getAppValue') ->willReturnMap([ @@ -1504,7 +1532,6 @@ public function testSetAdminConfigOIDCAuthSetting( 'userManager' => $userManager, 'openprojectAPIService' => $apiService, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, 'userId' => 'test101' ]); $configController = new ConfigController(...$constructArgs); @@ -1563,9 +1590,6 @@ public function testSetAdminConfigForOAuth2AlreadyConfigured( $configMock = $this->createMock(IConfig::class); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->getMockBuilder(SettingsController::class) - ->disableOriginalConstructor() - ->getMock(); $this->expectMethodCalls($configMock, 'getAppValue', [ [['integration_openproject', 'openproject_instance_url', ''], $oldCreds['openproject_instance_url']], [['integration_openproject', 'authorization_method', ''], $oldCreds['authorization_method']], @@ -1578,14 +1602,7 @@ public function testSetAdminConfigForOAuth2AlreadyConfigured( [['integration_openproject', 'openproject_client_secret', ''], $credsToUpdate['openproject_client_secret']], [['integration_openproject', 'openproject_instance_url', ''], $credsToUpdate['openproject_instance_url']], ]); - $oauthSettingsControllerMock - ->expects($this->once()) - ->method('deleteClient') - ->with(123); - $oauthSettingsControllerMock - ->expects($this->once()) - ->method('deleteClient') - ->with(123); + $configMock ->expects($this->exactly(12)) ->method('deleteUserValue') @@ -1604,6 +1621,13 @@ public function testSetAdminConfigForOAuth2AlreadyConfigured( [$this->user1->getUID(), 'integration_openproject', 'token_expires_at', null], ]); + $clientMapperMock = $this->createMock(ClientMapper::class); + $clientMapperMock->expects($this->once())->method('getByUid')->willReturn(new Client()); + $clientMapperMock->expects($this->once())->method('delete'); + $accessTokenMapperMock = $this->createMock(AccessTokenMapper::class); + $accessTokenMapperMock->expects($this->once())->method('deleteByClientId'); + $clientService = $this->getClientService($clientMapperMock, $accessTokenMapperMock); + $apiService = $this->getMockBuilder(OpenProjectAPIService::class) ->disableOriginalConstructor() ->getMock(); @@ -1613,7 +1637,7 @@ public function testSetAdminConfigForOAuth2AlreadyConfigured( 'userManager' => $userManager, 'openprojectAPIService' => $apiService, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, + 'clientService' => $clientService, 'userId' => 'test101' ]); $configController = new ConfigController(...$constructArgs); @@ -1674,9 +1698,6 @@ public function testSetAdminConfigForOIDCAlreadyConfigured( $this->user1 = $userManager->createUser($testUser, $testUser); $configMock = $this->getMockBuilder(IConfig::class)->getMock(); $oauthServiceMock = $this->createMock(OauthService::class); - $oauthSettingsControllerMock = $this->getMockBuilder(SettingsController::class) - ->disableOriginalConstructor() - ->getMock(); $this->expectMethodCalls($configMock, 'getAppValue', [ [['integration_openproject', 'openproject_instance_url', ''], $oldConfig['openproject_instance_url']], @@ -1714,7 +1735,6 @@ public function testSetAdminConfigForOIDCAlreadyConfigured( 'userManager' => $userManager, 'openprojectAPIService' => $apiService, 'oauthService' => $oauthServiceMock, - 'settingsController' => $oauthSettingsControllerMock, 'userId' => $testUser, ]); diff --git a/tests/lib/Service/OauthServiceTest.php b/tests/lib/Service/OauthServiceTest.php index 78464da9f..a89b34448 100644 --- a/tests/lib/Service/OauthServiceTest.php +++ b/tests/lib/Service/OauthServiceTest.php @@ -42,17 +42,52 @@ protected function getOauthServiceMock(MockObject $clientMapperMock = null): Oau /** * @return Client */ - protected function getClient(int $clientId): Client { + protected function getClient(int $clientDbId): Client { $client = new Client(); - $client->setId($clientId); - $client->setName('Test Client'); - $client->setRedirectUri('https://example.com/callback'); - $client->setClientIdentifier('randomString'); - $client->setSecret('randomString'); + $name = 'Test Client'; + $redirectUri = 'https://example.com/callback'; + $clientId = 'randomString'; + $clientSecret = 'randomString'; + + $this->setClientProperty($client, 'id', $clientDbId); + $this->setClientProperty($client, 'name', $name); + $this->setClientProperty($client, 'redirectUri', $redirectUri); + $this->setClientProperty($client, 'clientIdentifier', $clientId); + $this->setClientProperty($client, 'secret', $clientSecret); + return $client; + } + /** + * @param Client $client + * @param string $property + * @param string|int $value + * + * @return Client + */ + protected function setClientProperty(Client $client, string $property, string|int $value): Client { + $fn = 'set' . ucfirst($property); + if (\method_exists(Client::class, 'addType')) { + $client->$fn($value); + } else { + $client->{$property} = $value; + } return $client; } + /** + * @param Client $client + * @param string $property + * + * @return string|int + */ + protected function getClientProperty(Client $client, string $property): string|int { + $fn = 'get' . ucfirst($property); + if (\method_exists(Client::class, 'addType')) { + return $client->$fn(); + } + return $client->{$property}; + } + /** * @return void */ @@ -61,23 +96,25 @@ public function testCreateNcOauthClient(): void { $testClient = $this->getClient($clientId); $expectedClient = [ 'id' => $clientId, - 'nextcloud_oauth_client_name' => $testClient->getName(), - 'openproject_redirect_uri' => $testClient->getRedirectUri(), - 'nextcloud_client_id' => $testClient->getClientIdentifier(), - 'nextcloud_client_secret' => $testClient->getSecret(), + 'nextcloud_oauth_client_name' => $this->getClientProperty($testClient, 'name'), + 'openproject_redirect_uri' => $this->getClientProperty($testClient, 'redirectUri'), + 'nextcloud_client_id' => $this->getClientProperty($testClient, 'clientIdentifier'), + 'nextcloud_client_secret' => $this->getClientProperty($testClient, 'secret'), ]; $clientMapperMock = $this->createMock(ClientMapper::class); $clientMapperMock->expects($this->once())->method('insert') ->with($this->isInstanceOf(Client::class)) ->willReturnCallback(function ($client) use ($clientId) { - $client->setId($clientId); - return $client; + return $this->setClientProperty($client, 'id', $clientId); }); $oauthService = $this->getOauthServiceMock($clientMapperMock); - $clientInfo = $oauthService->createNcOauthClient($testClient->getName(), $testClient->getRedirectUri()); + $clientInfo = $oauthService->createNcOauthClient( + (string)$this->getClientProperty($testClient, 'name'), + (string)$this->getClientProperty($testClient, 'redirectUri') + ); $this->assertSame($expectedClient, $clientInfo); } @@ -89,9 +126,9 @@ public function testGetClientInfo(): void { $client = $this->getClient($clientId); $expectedClient = [ 'id' => $clientId, - 'nextcloud_oauth_client_name' => $client->getName(), - 'openproject_redirect_uri' => $client->getRedirectUri(), - 'nextcloud_client_id' => $client->getClientIdentifier(), + 'nextcloud_oauth_client_name' => $this->getClientProperty($client, 'name'), + 'openproject_redirect_uri' => $this->getClientProperty($client, 'redirectUri'), + 'nextcloud_client_id' => $this->getClientProperty($client, 'clientIdentifier'), ]; $clientMapperMock = $this->createMock(ClientMapper::class); @@ -138,7 +175,10 @@ public function testSetClientRedirectUri(): void { $oauthService = $this->getOauthServiceMock($clientMapperMock); - $result = $oauthService->setClientRedirectUri($clientId, $client->getRedirectUri()); + $result = $oauthService->setClientRedirectUri( + $clientId, + (string)$this->getClientProperty($client, 'redirectUri'), + ); $this->assertTrue($result); } @@ -157,7 +197,10 @@ public function testSetClientRedirectUriError(): void { $oauthService = $this->getOauthServiceMock($clientMapperMock); - $result = $oauthService->setClientRedirectUri($clientId, $client->getRedirectUri()); + $result = $oauthService->setClientRedirectUri( + $clientId, + (string)$this->getClientProperty($client, 'redirectUri'), + ); $this->assertFalse($result); } }