diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 192220cab..6958a6c4d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -22,6 +22,8 @@ use OCA\UserOIDC\Listener\TimezoneHandlingListener; use OCA\UserOIDC\Listener\TokenInvalidatedListener; use OCA\UserOIDC\Service\ID4MeService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Service\RequestClassificationService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -36,6 +38,7 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Container\ContainerInterface; use Throwable; class Application extends App implements IBootstrap { @@ -50,6 +53,11 @@ public function __construct(array $urlParams = []) { } public function register(IRegistrationContext $context): void { + // override registration of provisioning service to use event-based solution + $this->getContainer()->registerService(ProvisioningService::class, function (ContainerInterface $c): ProvisioningService { + return $c->get(ProvisioningEventService::class); + }); + /** @var IUserManager $userManager */ $userManager = $this->getContainer()->get(IUserManager::class); diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index abba810cc..44945864e 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -24,6 +24,7 @@ use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\OIDCService; use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Service\SettingsService; use OCA\UserOIDC\Service\TokenService; @@ -654,8 +655,21 @@ public function code(string $state = '', string $code = '', string $scope = '', $message = $this->l10n->t('User conflict'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); } + // use potential user from other backend, create it in our backend if it does not exist - $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + try { + $provisioningResult = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $existingUser); + } catch (ProvisioningDeniedException $denied) { + $redirectUrl = $denied->getRedirectUrl(); + + if ($redirectUrl === null) { + $message = $this->l10n->t('Failed to provision user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } + + return new RedirectResponse($redirectUrl); + } + $user = $provisioningResult['user']; if ($existingUser === null && $user !== null) { // we know we just created a user diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 000000000..718e3296a --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,59 @@ +result = new UserAccountChangeResult(); + } + + public function getUid(): string { + return $this->uid; + } + + public function getDisplayName(): ?string { + return $this->displayName; + } + + public function getMainEmail(): ?string { + return $this->mainEmail; + } + + public function getQuota(): ?string { + return $this->quota; + } + + public function getClaims(): object { + return $this->claims; + } + + public function getResult(): UserAccountChangeResult { + return $this->result; + } + + public function setResult(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null): void { + $this->result = new UserAccountChangeResult($accessAllowed, $reason, $redirectUrl); + } +} diff --git a/lib/Event/UserAccountChangeResult.php b/lib/Event/UserAccountChangeResult.php new file mode 100644 index 000000000..1a8bb43ab --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,50 @@ +accessAllowed !== null; + } + + public function isAccessAllowed(): bool { + return $this->accessAllowed === true; + } + + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } + + public function getReason(): string { + return $this->reason; + } + + public function setReason(string $reason): void { + $this->reason = $reason; + } + + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } +} diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php new file mode 100644 index 000000000..35cc4ac60 --- /dev/null +++ b/lib/Service/ProvisioningDeniedException.php @@ -0,0 +1,31 @@ +redirectUrl; + } + + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + + return self::class . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php new file mode 100644 index 000000000..a3bf8d71d --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,200 @@ +providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + + if (!is_string($mappedUserId) || trim($mappedUserId) === '') { + throw new AttributeValueException('Mapped uid is empty or invalid'); + } + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + if (!is_string($value) || trim($value) === '') { + throw new AttributeValueException('Mapped uid is empty or invalid'); + } + + return $value; + } + + protected function mapDispatchDisplayname(int $providerId, object $payload): ?string { + $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (is_string($mappedDisplayName) && $mappedDisplayName !== '') { + $mappedDisplayName = mb_substr($mappedDisplayName, 0, 255); + } elseif ($mappedDisplayName !== null) { + $mappedDisplayName = (string)$mappedDisplayName; + } + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $mappedDisplayName); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function mapDispatchEmail(int $providerId, object $payload): ?string { + $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); + $mappedEmail = $payload->{$emailAttribute} ?? null; + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_EMAIL, $payload, $mappedEmail); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function mapDispatchQuota(int $providerId, object $payload): ?string { + $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); + $mappedQuota = $payload->{$quotaAttribute} ?? null; + + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_QUOTA, $payload, $mappedQuota); + $this->eventDispatcher->dispatchTyped($event); + + $value = $event->getValue(); + + return $value === null ? null : (string)$value; + } + + protected function dispatchUserAccountUpdate( + string $uid, + ?string $displayName, + ?string $email, + ?string $quota, + object $payload, + ): UserAccountChangeResult { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + + $result = $event->getResult(); + + if ($result->hasDecision() && !$result->isAccessAllowed()) { + throw new ProvisioningDeniedException( + $result->getReason(), + $result->getRedirectUrl(), + ); + } + + return $result; + } + + /** + * Trigger provisioning via event system. + * + * @return array{user: ?IUser, userData: array} + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser( + string $tokenUserId, + int $providerId, + object $idTokenPayload, + ?IUser $existingLocalUser = null, + ): array { + try { + $uid = $this->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $displayName = $this->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $e) { + $this->logger->info($tokenUserId . ': user rejected by OpenID web authorization, reason: ' . $e->getMessage()); + throw new ProvisioningDeniedException($e->getMessage()); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayName, $email, $quota, $idTokenPayload); + + if ($userReaction->hasDecision()) { + if ($userReaction->isAccessAllowed()) { + $this->logger->info($uid . ': account accepted, reason: ' . $userReaction->getReason()); + + return [ + 'user' => $existingLocalUser ?? $this->userManager->get($uid), + 'userData' => get_object_vars($idTokenPayload), + ]; + } + + $this->logger->info($uid . ': account rejected, reason: ' . $userReaction->getReason()); + + throw new ProvisioningDeniedException( + $userReaction->getReason(), + $userReaction->getRedirectUrl(), + ); + } + + return parent::provisionUser($tokenUserId, $providerId, $idTokenPayload, $existingLocalUser); + } +} diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 000000000..7d1a38949 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,115 @@ + */ + private array $realOidClaims = []; + + public function getProviderId(): int { + return 4711; + } + + /** @return array */ + public function getRealOidClaims(): array { + return $this->realOidClaims; + } + + public function getOidClientId(): string { + return 'USER_NC_OPENID_TEST'; + } + + public function getOidNonce(): string { + return 'CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K'; + } + + public function getOidClientSecret(): string { + return 'JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'; + } + + public function getOidServerKey(): string { + return Base64UrlSafe::encodeUnpadded('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); + } + + /** @return array */ + public function getOidPrivateServerKey(): array { + return [ + 'p' => '9US9kD6Q8nicR1se1U_iRI9x1iK0__HF7E9yhqrza9DHldC2h7PLuR7y9bITAUtcBmVvqEQlVUXRZPMrNUpLFI9hTdZXAACRqYBYGHg7Mvyzq-2JXhEE5CFDy9wSCPunc8bRq4TsY0ocSXugXKGjx-t1uO3fkF1UgNgNMjdzSPM', + 'kty' => 'RSA', + 'q' => '85auJF6W3c91EebGpjMX-g_U0fLBMgO2oxBsldus9x2diRd3wVvUnrTg5fQctODdr4if8dBCPDdLxBUKul4MXULC_nCkGkDjORdESb7j8amGnOvxnaVcQT6C5yHivAawa4R8NchR7n23VrQWO8fHhQBYUHTTy01G3A8D6dznCC8', + 'd' => 'tP-lT4FJBKrhhBUk7J1fR0638jVjn46yIfSaB5l_JlqNItmRJtbz3QWopy4oDfvrY_ccZIYG9tLvJH-3LHtuEddwxFsL-9MSUx5qxWB4sKpKA6EpxWNR5EFnFKxR_B2P2yFYiRDdbBh8h9pNaOuNjZU5iitAGvSOfW4X5hyJyu9t9zsEX9O6stEtP3yK5sx-bt7osGDMIguFBMcPVHbYw_Pl7-aNPuQ4ioxVXa3JlO6tTcDrcyMy7d3CWuGACj3juEnO-1n8E_OSR9sMp1k_L7i-qQ3OnLCOx07HeTWklCvNxz7U9qLcQXGcfpdWmhWZt6MO3SIXV4f6Md0U836v0Q', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => '0123456789', + 'qi' => 'T3-NLCpVoITdS6PB9XYCsXsfhQSiE_4eTQnZf_Zya5hSd0xZDrrwNiXL8Dzy3YLjsZAFC0U6wAeC2wTBJ8c-6VxdP34J0sGj2I_TNhFFArksLy9ZaRbskCxKAPLipEFi8b1H2-aaRFRLs6BQJbfesQ5mcX2kB5AItAX3R6tcc0A', + 'dp' => 'ExUtFor3phXiOt8JEBmuBh2PAtUidgNuncs0ouusEshkrvBVM0u23wlcZ-dZ-TDO0SSVQmdC7FaJSyxsQTItk0TwkijKDhL9Qk3dDNJV8MqehBLwLCRw1_sKllLiCFbkGWrvp0OpTLRYbRM0T-C3qHdWanP_f_DzAS9OH4kW7Cc', + 'alg' => 'RS256', + 'dq' => 'xr3XAWeHkhw0uVFgHLQtSOJn0pBM3qC2_95jqfVc7xZjtTnHhKSHGqIbqKL-VPnvBcvkK-iuUfEPyUEdyqb3UZQqAm0nByCQA8Ge_shXtJGLejbroKMNXVJCfZBhLOYMRP0IVt1FM9-wmXY_ebDrcfGxHJvlPcekG-HIYKPSgBM', + 'n' => '6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ', + ]; + } + + public function getOidPublicServerKey(): array { + return [ + '0123456789' => new \OCA\UserOIDC\Vendor\Firebase\JWT\Key( + $this->getOidClientSecret(), + 'HS256', + ), + ]; + } + + public function getOidTestCode(): string { + return '66844608'; + } + + public function getOidTestState(): string { + return '4VSL5T274MJEMLZI1810HUFDA07CEPXZ'; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $now = time(); + + $this->realOidClaims = [ + 'sub' => 'jgyros', + 'urn:custom.com:displayname' => 'Jonny G', + 'urn:custom.com:email' => 'jonny.gyros@x.y', + 'urn:custom.com:mainEmail' => 'jonny.gyuris@x.y.de', + 'iss' => 'https://accounts.login00.custom.de', + 'urn:custom.com:feat1' => '0', + 'urn:custom.com:uid' => '081500000001234', + 'urn:custom.com:feat2' => '1', + 'urn:custom.com:ext2' => '0', + 'urn:custom.com:feat3' => '1', + 'acr' => 'urn:custom:names:idm:THO:1.0:ac:classes:passid:00', + 'urn:custom.com:feat4' => '0', + 'urn:custom.com:ext4' => '0', + 'auth_time' => $now, + 'exp' => $now + 7200, + 'iat' => $now, + 'urn:custom.com:session_token' => 'ad0fff71-e013-11ec-9e17-39677d2c891c', + 'nonce' => $this->getOidNonce(), + 'aud' => [$this->getOidClientId()], + ]; + } + + protected function createSignToken(array $claims): string { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWT::encode( + $claims, + $this->getOidClientSecret(), + 'HS256', + '0123456789', + ); + } +} diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php new file mode 100644 index 000000000..728fab851 --- /dev/null +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -0,0 +1,613 @@ +createMock(IConfig::class); + + $config->expects($this->any()) + ->method('getSystemValue') + ->with($this->logicalOr($this->equalTo('user_oidc'), $this->equalTo('secret'))) + ->willReturnCallback(static function (string $key, mixed $default = null): mixed { + if ($key === 'user_oidc') { + return [ + 'auto_provisioning' => true, + 'auto_provision' => true, + 'soft_auto_provision' => true, + 'login_validation_audience_check' => false, + 'login_validation_azp_check' => false, + ]; + } + + if ($key === 'secret') { + return 'Streng_geheim'; + } + + return $default; + }); + + $config->expects($this->any()) + ->method('getSystemValueString') + ->willReturnCallback(static function (string $key, string $default = ''): string { + if ($key === 'version') { + return '32.0.0'; + } + + return $default; + }); + + $config->expects($this->any()) + ->method('setUserValue'); + + return $config; + } + + protected function getOidSessionSetup(): MockObject { + $session = $this->createMock(ISession::class); + + $session->expects($this->any()) + ->method('get') + ->willReturnCallback(function (string $key): mixed { + $state = $this->getOidTestState(); + $suffix = '-' . $state; + + $values = [ + 'oidc.state' . $suffix => $state, + 'oidc.login.providerid' . $suffix => $this->getProviderId(), + 'oidc.providerid' . $suffix => $this->getProviderId(), + 'oidc.nonce' . $suffix => $this->getOidNonce(), + 'oidc.redirect' . $suffix => 'https://welcome.to.magenta', + 'oidc.timestamp' . $suffix => time(), + 'oidc.code_verifier' . $suffix => 'test-code-verifier', + ]; + + return $values[$key] ?? null; + }); + + $session->expects($this->any()) + ->method('exists') + ->willReturnCallback(function (string $key): bool { + $state = $this->getOidTestState(); + $suffix = '-' . $state; + + return in_array($key, [ + 'oidc.state' . $suffix, + 'oidc.login.providerid' . $suffix, + 'oidc.providerid' . $suffix, + 'oidc.nonce' . $suffix, + 'oidc.redirect' . $suffix, + 'oidc.timestamp' . $suffix, + 'oidc.code_verifier' . $suffix, + ], true); + }); + + $session->expects($this->any()) + ->method('set'); + + $session->expects($this->any()) + ->method('remove'); + + $session->expects($this->any()) + ->method('getId') + ->willReturn('test-session-id'); + + return $session; + } + + protected function getProviderSetup(): Provider { + $provider = new Provider(); + $provider->setId($this->getProviderId()); + $provider->setIdentifier('telekom'); + $provider->setClientId($this->getOidClientId()); + $provider->setClientSecret($this->crypto->encrypt($this->getOidClientSecret())); + $provider->setScope('openid'); + $provider->setDiscoveryEndpoint('https://accounts.login00.custom.de/.well-known/openid-configuration'); + + $this->providerMapper->expects($this->any()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } + + protected function getProviderServiceSetup(): MockObject { + $providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([$this->appConfig, $this->providerMapper]) + ->getMock(); + + $providerService->expects($this->any()) + ->method('getSetting') + ->willReturnCallback(static function (int $providerId, string $key, string $default = ''): string { + $values = [ + ProviderService::SETTING_MAPPING_UID => 'sub', + ProviderService::SETTING_MAPPING_DISPLAYNAME => 'urn:custom.com:displayname', + ProviderService::SETTING_MAPPING_QUOTA => 'urn:custom.com:f556', + ProviderService::SETTING_MAPPING_EMAIL => 'urn:custom.com:mainEmail', + ProviderService::SETTING_MAPPING_GROUPS => '', + ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS => '0', + ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING => '0', + ProviderService::SETTING_EXTRA_CLAIMS => '', + ]; + + return $values[$key] ?? $default; + }); + + return $providerService; + } + + protected function getUserManagerSetup(): MockObject { + $userManager = $this->getMockForAbstractClass(IUserManager::class); + + $this->user = $this->getMockForAbstractClass(IUser::class); + $this->user->expects($this->any()) + ->method('canChangeAvatar') + ->willReturn(false); + $this->user->expects($this->any()) + ->method('getUID') + ->willReturn('jgyros'); + + return $userManager; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->config = $this->getConfigSetup(); + $this->appConfig = $this->createMock(IAppConfig::class); + + $this->appConfig->expects($this->any()) + ->method('getValueString') + ->willReturn('0'); + + $this->appConfig->expects($this->any()) + ->method('getValueBool') + ->willReturn(false); + + $this->crypto = $this->getMockBuilder(Crypto::class) + ->setConstructorArgs([$this->config]) + ->getMock(); + + $this->request = $this->getMockForAbstractClass(IRequest::class); + $this->request->expects($this->any()) + ->method('getServerProtocol') + ->willReturn('https'); + + $this->providerMapper = $this->getMockBuilder(ProviderMapper::class) + ->setConstructorArgs([$this->getMockForAbstractClass(IDBConnection::class)]) + ->getMock(); + + $this->provider = $this->getProviderSetup(); + $this->providerService = $this->getProviderServiceSetup(); + + $this->localIdService = $this->getMockBuilder(LocalIdService::class) + ->setConstructorArgs([ + $this->providerService, + $this->providerMapper, + ]) + ->getMock(); + + $this->userMapper = $this->getMockBuilder(UserMapper::class) + ->setConstructorArgs([ + $this->getMockForAbstractClass(IDBConnection::class), + $this->localIdService, + $this->config, + ]) + ->getMock(); + + $this->token = [ + 'id_token' => $this->createSignToken($this->getRealOidClaims()), + ]; + + $this->httpClientHelper = $this->getMockBuilder(HttpClientHelper::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->httpClientHelper->expects($this->any()) + ->method('post') + ->willReturn(json_encode($this->token, JSON_THROW_ON_ERROR)); + + $this->discoveryService = $this->getMockBuilder(DiscoveryService::class) + ->setConstructorArgs([ + $this->app->getContainer()->get(LoggerInterface::class), + $this->httpClientHelper, + $this->providerService, + $this->app->getContainer()->get(IConfig::class), + $this->app->getContainer()->get(ITimeFactory::class), + $this->app->getContainer()->get(ICacheFactory::class), + ]) + ->getMock(); + + $this->discoveryService->expects($this->any()) + ->method('obtainDiscovery') + ->willReturn([ + 'token_endpoint' => 'https://whatever.to.discover/token', + 'authorization_endpoint' => 'https://whatever.to.discover/auth', + 'issuer' => 'https://accounts.login00.custom.de', + ]); + + $this->discoveryService->expects($this->any()) + ->method('obtainJWK') + ->willReturn($this->getOidPublicServerKey()); + + $this->session = $this->getOidSessionSetup(); + + $this->sessionMapper = $this->getMockBuilder(SessionMapper::class) + ->setConstructorArgs([ + $this->createMock(IDBConnection::class), + $this->app->getContainer()->get(ICrypto::class), + ]) + ->getMock(); + + $this->sessionMapper->expects($this->any()) + ->method('createOrUpdateSession'); + + $this->usersession = $this->getMockBuilder(IUserSession::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'setUser', + 'login', + 'logout', + 'getUser', + 'isLoggedIn', + 'getImpersonatingUserID', + 'setImpersonatingUserID', + 'setVolatileActiveUser', + ]) + ->addMethods([ + 'completeLogin', + 'createSessionToken', + 'createRememberMeToken', + ]) + ->getMock(); + + $this->usersession->expects($this->any()) + ->method('isLoggedIn') + ->willReturn(false); + + $this->usermanager = $this->getUserManagerSetup(); + $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); + $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::class); + $this->l10nFactory = $this->app->getContainer()->get(IFactory::class); + + $this->provisioningService = new ProvisioningEventService( + $this->app->getContainer()->get(LocalIdService::class), + $this->providerService, + $this->userMapper, + $this->usermanager, + $this->groupmanager, + $this->dispatcher, + $this->app->getContainer()->get(LoggerInterface::class), + $this->app->getContainer()->get(IAccountManager::class), + $this->app->getContainer()->get(IClientService::class), + $this->app->getContainer()->get(IAvatarManager::class), + $this->config, + $this->session, + $this->l10nFactory, + $this->providerMapper, + $this->crypto, + ); + + $this->registrationContext = $this->app->getContainer() + ->get(Coordinator::class) + ->getRegistrationContext(); + + $this->settingsService = $this->getMockBuilder(SettingsService::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->settingsService->expects($this->any()) + ->method('getAllowMultipleUserBackEnds') + ->willReturn(true); + + $this->tokenService = $this->app->getContainer()->get(TokenService::class); + $this->oidcService = $this->app->getContainer()->get(OIDCService::class); + + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->expects($this->any()) + ->method('getTime') + ->willReturn(time()); + + $this->loginController = new LoginController( + $this->request, + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->settingsService, + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->httpClientHelper, + $this->app->getContainer()->get(IURLGenerator::class), + $this->usersession, + $this->usermanager, + $this->timeFactory, + $this->dispatcher, + $this->config, + $this->appConfig, + $this->app->getContainer()->get(IProvider::class), + $this->sessionMapper, + $this->provisioningService, + $this->app->getContainer()->get(IL10N::class), + $this->app->getContainer()->get(LoggerInterface::class), + $this->crypto, + $this->tokenService, + $this->oidcService, + ); + + $this->attributeListener = null; + $this->accountListener = null; + } + + public function tearDown(): void { + if ($this->accountListener !== null) { + $this->dispatcher->removeListener(UserAccountChangeEvent::class, $this->accountListener); + } + + if ($this->attributeListener !== null) { + $this->dispatcher->removeListener(AttributeMappedEvent::class, $this->attributeListener); + } + + parent::tearDown(); + } + + protected function mockAssertLoginSuccess(): void { + $this->usermanager->expects($this->once()) + ->method('get') + ->willReturn($this->user); + + $this->usersession->expects($this->once()) + ->method('setUser') + ->with($this->equalTo($this->user)); + + $this->usersession->expects($this->any()) + ->method('completeLogin') + ->with($this->anything(), $this->anything()); + + $this->usersession->expects($this->any()) + ->method('createSessionToken'); + + $this->usersession->expects($this->any()) + ->method('createRememberMeToken'); + } + + protected function assertLoginRedirect(mixed $result): void { + if ($result instanceof TemplateResponse) { + $this->fail( + 'Expected RedirectResponse, got TemplateResponse. Template: ' + . $result->getTemplateName() + . ' Params: ' + . json_encode($result->getParams(), JSON_THROW_ON_ERROR) + ); + } + + $this->assertInstanceOf(RedirectResponse::class, $result); + } + + protected function assertLogin403(mixed $result): void { + $this->assertInstanceOf( + TemplateResponse::class, + $result, + 'LoginController->code() did not end with 403 Forbidden' + ); + } + + public function testNoMap_AccessOk(): void { + $this->mockAssertLoginSuccess(); + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(true, 'ok', null); + }; + + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertNotEmpty($result->getRedirectURL()); + } + + public function testUidNoMapEvent_AccessOk(): void { + $this->mockAssertLoginSuccess(); + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(true, 'ok', 'https://welcome.to.darkside'); + }; + + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('http://localhost', $result->getRedirectURL()); + } + + public function testDisplaynameMapEvent_NOk_NoRedirect(): void { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent + && $event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME + ) { + $event->setValue('Lisa, Mona'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(false, 'not an original', null); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLogin403($result); + } + + public function testMainEmailMap_Nok_Redirect(): void { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent + && $event->getAttribute() === ProviderService::SETTING_MAPPING_EMAIL + ) { + $event->setValue('mona.lisa@louvre.fr'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Jonny G', $event->getDisplayName()); + $this->assertEquals('mona.lisa@louvre.fr', $event->getMainEmail()); + $this->assertNull($event->getQuota()); + + $event->setResult(false, 'under restoration', 'https://welcome.to.louvre'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('https://welcome.to.louvre', $result->getRedirectURL()); + } + + public function testDisplaynameUidQuotaMapped_AccessOK(): void { + $this->mockAssertLoginSuccess(); + + $this->attributeListener = function (Event $event): void { + if (!$event instanceof AttributeMappedEvent) { + return; + } + + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue('Lisa, Mona'); + } + + if ($event->getAttribute() === ProviderService::SETTING_MAPPING_QUOTA) { + $event->setValue('5 TB'); + } + }; + + $this->accountListener = function (Event $event): void { + $this->assertInstanceOf(UserAccountChangeEvent::class, $event); + $this->assertEquals('jgyros', $event->getUid()); + $this->assertEquals('Lisa, Mona', $event->getDisplayName()); + $this->assertEquals('jonny.gyuris@x.y.de', $event->getMainEmail()); + $this->assertEquals('5 TB', $event->getQuota()); + + $event->setResult(true, 'ok', 'https://welcome.to.louvre'); + }; + + $this->dispatcher->addListener(AttributeMappedEvent::class, $this->attributeListener); + $this->dispatcher->addListener(UserAccountChangeEvent::class, $this->accountListener); + + $result = $this->loginController->code($this->getOidTestState(), $this->getOidTestCode(), ''); + + $this->assertLoginRedirect($result); + $this->assertEquals('http://localhost', $result->getRedirectURL()); + } +} diff --git a/tests/unit/MagentaCloud/RegistrationsTest.php b/tests/unit/MagentaCloud/RegistrationsTest.php new file mode 100644 index 000000000..b5a2eac6e --- /dev/null +++ b/tests/unit/MagentaCloud/RegistrationsTest.php @@ -0,0 +1,33 @@ +app = new Application(); + + $coordinator = \OC::$server->get(Coordinator::class); + $this->app->register($coordinator->getRegistrationContext()->for(Application::APP_ID)); + } + + public function testProvisioningServiceRegistration(): void { + $provisioningService = $this->app->getContainer()->get(ProvisioningService::class); + + $this->assertInstanceOf(ProvisioningEventService::class, $provisioningService); + } +}