From 5b3d1a4bd5414bbc050099fb1bcb85e830ea1447 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Sat, 8 Jul 2023 11:46:20 +0200 Subject: [PATCH 01/10] Redesign event-based provisioning as non-invasive customizing --- lib/Controller/LoginController.php | 28 +++- lib/Event/UserAccountChangeEvent.php | 103 ++++++++++++ lib/Event/UserAccountChangeResult.php | 87 ++++++++++ lib/Service/ProvisioningDeniedException.php | 73 +++++++++ lib/Service/ProvisioningEventService.php | 149 ++++++++++++++++++ .../Service/EventProvisioningServiceTest.php | 87 ++++++++++ 6 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 lib/Event/UserAccountChangeEvent.php create mode 100644 lib/Event/UserAccountChangeResult.php create mode 100644 lib/Service/ProvisioningDeniedException.php create mode 100644 lib/Service/ProvisioningEventService.php create mode 100644 tests/unit/Service/EventProvisioningServiceTest.php diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 290691c0..5cc84fa1 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -36,6 +36,8 @@ use OCA\UserOIDC\Service\DiscoveryService; use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\EventProvisioningService; +use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; use OCA\UserOIDC\AppInfo\Application; @@ -118,6 +120,9 @@ class LoginController extends BaseOidcController { /** @var SessionMapper */ private $sessionMapper; + /** @var EventProvisioningService */ + private $eventProvisioningService; + /** @var ProvisioningService */ private $provisioningService; @@ -145,6 +150,7 @@ public function __construct( IConfig $config, IProvider $authTokenProvider, SessionMapper $sessionMapper, + EventProvisioningService $eventProvisioningService, ProvisioningService $provisioningService, IL10N $l10n, ILogger $logger, @@ -168,6 +174,7 @@ public function __construct( $this->ldapService = $ldapService; $this->authTokenProvider = $authTokenProvider; $this->sessionMapper = $sessionMapper; + $this->eventProvisioningService = $eventProvisioningService; $this->provisioningService = $provisioningService; $this->request = $request; $this->l10n = $l10n; @@ -471,10 +478,29 @@ public function code(string $state = '', string $code = '', string $scope = '', } $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); // Provisioning - if ($autoProvisionAllowed) { + if ($eventProvisionAllowed) { + // for the moment, make event provisioning another (prio) config option + // TODO: (proposal) refactor all provisioning strategies into event handlers + try { + $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } catch( ProvisioningDeniedException $denied ) { + $redirectUrl = $denied->getRedirectUrl(); + if ( $redirectUrl === null ) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } else { + // error response is a redirect, e.g. to a booking site + // so that you can immediately get the registration page + return new RedirectResponse($redirectUrl); + } + } catch (\Exception $e) { + $user = null; + } + } else if ($autoProvisionAllowed) { $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); } else { // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php new file mode 100644 index 00000000..5825c8e0 --- /dev/null +++ b/lib/Event/UserAccountChangeEvent.php @@ -0,0 +1,103 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +use OCP\EventDispatcher\Event; + +use OCA\UserOIDC\Event\UserAccountChangeResult; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeEvent extends Event { + + private $uid; + private $displayname; + private $mainEmail; + private $quota; + private $claims; + private $result; + + + public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { + parent::__construct(); + $this->uid = $uid; + $this->displayname = $displayname; + $this->mainEmail = $mainEmail; + $this->quota = $quota; + $this->claims = $claims; + $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + } + + /** + * @return get event username (uid) + */ + public function getUid(): string { + return $this->uid; + } + + /** + * @return get event displayname + */ + public function getDisplayName(): ?string { + return $this->displayname; + } + + /** + * @return get event main email + */ + public function getMainEmail(): ?string { + return $this->mainEmail; + } + + /** + * @return get event quota + */ + public function getQuota(): ?string { + return $this->quota; + } + + /** + * @return array the array of claim values associated with the event + */ + public function getClaims(): object { + return $this->claims; + } + + /** + * @return value for the logged in user attribute + */ + 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 00000000..e5a7c468 --- /dev/null +++ b/lib/Event/UserAccountChangeResult.php @@ -0,0 +1,87 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Event; + +/** + * Event to provide custom mapping logic based on the OIDC token data + * In order to avoid further processing the event propagation should be stopped + * in the listener after processing as the value might get overwritten afterwards + * by other listeners through $event->stopPropagation(); + */ +class UserAccountChangeResult { + + /** @var bool */ + private $accessAllowed; + /** @var string */ + private $reason; + /** @var string */ + private $redirectUrl; + + public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { + $this->accessAllowed = $accessAllowed; + $this->redirectUrl = $redirectUrl; + $this->reason = $reason; + } + + /** + * @return value for the logged in user attribute + */ + public function isAccessAllowed(): bool { + return $this->accessAllowed; + } + + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } + + /** + * @return get optional alternate redirect address + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + + /** + * @return set optional alternate redirect address + */ + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } + + /** + * @return get decision reason + */ + public function getReason(): string { + return $this->reason; + } + + /** + * @return set decision reason + */ + public function setReason(string $reason): void { + $this->reason = $reason; + } +} diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php new file mode 100644 index 00000000..425ea14f --- /dev/null +++ b/lib/Service/ProvisioningDeniedException.php @@ -0,0 +1,73 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCP; + +/** + * Exception if the precondition of the config update method isn't met + * @since 1.4.0 + */ +class ProvisioningDeniedException extends \Exception { + + private $redirectUrl; + + /** + * Exception constructor including an option redirect url. + * + * @param string $message The error message. It will be not revealed to the + * the user (unless the hint is empty) and thus + * should be not translated. + * @param string $hint A useful message that is presented to the end + * user. It should be translated, but must not + * contain sensitive data. + * @param int $code Set default to 403 (Forbidden) + * @param \Exception|null $previous + */ + public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) { + $this->redirectUrl = $redirectUrl; + parent::__construct($message, $code, $previous); + } + + /** + + * + * @return string + */ + public function getRedirectUrl(): string { + return $this->redirectUrl; + } + + /** + * Include redirect in string serialisation. + * + * @return string + */ + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } + + +} diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php new file mode 100644 index 00000000..0b2ffe8f --- /dev/null +++ b/lib/Service/ProvisioningEventService.php @@ -0,0 +1,149 @@ + + * + * @author Bernd Rederlechner + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + +namespace OCA\UserOIDC\Service; + +use OCA\UserOIDC\Service\ProvisioningDeniedException; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Db\UserMapper; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\ILogger; +use OCP\IUserManager; + +class ProvisioningEventService { + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var UserMapper */ + private $userMapper; + + /** @var IUserManager */ + private $userManager; + + /** @var ProviderService */ + private $providerService; + + public function __construct(IEventDispatcher $eventDispatcher, + ILogger $logger, + UserMapper $userMapper, + IUserManager $userManager, + ProviderService $providerService) { + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->userMapper = $userMapper; + $this->userManager = $userManager; + $this->providerService = $providerService; + } + + protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) { + $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchDisplayname(int $providerid, object $payload) { + $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (isset($mappedDisplayName)) { + $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); + } else { + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); + } + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchEmail(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function mapDispatchQuota(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + return $event->getResult(); + } + + /** + * Trigger a provisioning via event system. + * This allows to flexibly implement complex provisioning strategies - + * even in a separate app. + * + * On error, the provisioning logic can deliver failure reasons and + * even a redirect to a different endpoint. + * + * @param string $tokenUserId + * @param int $providerId + * @param object $idTokenPayload + * @return IUser|null + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { + try { + $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $eAttribute) { + $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException("Problems with user information."); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); + if ($userReaction->isAccessAllowed()) { + $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); + $user = $this->userManager->get($uid); + return $user; + } else { + $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); + } + } + +} diff --git a/tests/unit/Service/EventProvisioningServiceTest.php b/tests/unit/Service/EventProvisioningServiceTest.php new file mode 100644 index 00000000..3b2a8c6d --- /dev/null +++ b/tests/unit/Service/EventProvisioningServiceTest.php @@ -0,0 +1,87 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +use OCP\ILogger; +use OCP\ICacheFactory; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IResponse; + +use OCP\AppFramework\App; +use OCA\UserOIDC\AppInfo\Application; + +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Service\EventProvisioningService; + +use PHPUnit\Framework\TestCase; + +class DiscoveryServiceTest extends TestCase { + public function setUp(): void { + parent::setUp(); + $this->app = new App(Application::APP_ID); + + $this->provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getDiscoveryEndpoint']) + ->getMock(); + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->clientFactory = $this->getMockForAbstractClass(IClientService::class); + $this->clientFactory->expects($this->any()) + ->method('newClient') + ->willReturn($this->client); + $this->response = $this->getMockForAbstractClass(IResponse::class); + } + + public function testUidMapped() { + } + + public function testUidNotMapped() { + } + + public function testDisplaynameMapped() { + } + + public function testDisplaynameNotMapped() { + } + + public function testQuotaMapped() { + } + + public function testQuotaNotMapped() { + } + + public function testMappingProblem() { + } + + public function testSuccess() { + } + + public function testDenied() { + } + + public function testDeniedRedirect() { + } + +} From db8cf02c5681f21dfbc450d66f3d44a7b8173af7 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 10 Jul 2023 12:40:01 +0200 Subject: [PATCH 02/10] Cleanup to coding standards --- lib/Controller/LoginController.php | 1388 ++++++++++--------- lib/Event/UserAccountChangeEvent.php | 124 +- lib/Event/UserAccountChangeResult.php | 97 +- lib/Service/ProvisioningDeniedException.php | 69 +- lib/Service/ProvisioningEventService.php | 227 +-- 5 files changed, 960 insertions(+), 945 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 5cc84fa1..b6c29d4e 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -65,692 +65,704 @@ use OCP\Security\ISecureRandom; use OCP\Session\Exceptions\SessionNotAvailableException; -class LoginController extends BaseOidcController { - private const STATE = 'oidc.state'; - private const NONCE = 'oidc.nonce'; - public const PROVIDERID = 'oidc.providerid'; - private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; - private const ID_TOKEN = 'oidc.id_token'; - - /** @var ISecureRandom */ - private $random; - - /** @var ISession */ - private $session; - - /** @var IClientService */ - private $clientService; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IUserSession */ - private $userSession; - - /** @var IUserManager */ - private $userManager; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var ProviderMapper */ - private $providerMapper; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var ILogger */ - private $logger; - - /** @var ProviderService */ - private $providerService; - - /** @var DiscoveryService */ - private $discoveryService; - - /** @var IConfig */ - private $config; - - /** @var LdapService */ - private $ldapService; - - /** @var IProvider */ - private $authTokenProvider; - - /** @var SessionMapper */ - private $sessionMapper; - - /** @var EventProvisioningService */ - private $eventProvisioningService; - - /** @var ProvisioningService */ - private $provisioningService; - - /** @var IL10N */ - private $l10n; - /** - * @var ICrypto - */ - private $crypto; - - public function __construct( - IRequest $request, - ProviderMapper $providerMapper, - ProviderService $providerService, - DiscoveryService $discoveryService, - LdapService $ldapService, - ISecureRandom $random, - ISession $session, - IClientService $clientService, - IURLGenerator $urlGenerator, - IUserSession $userSession, - IUserManager $userManager, - ITimeFactory $timeFactory, - IEventDispatcher $eventDispatcher, - IConfig $config, - IProvider $authTokenProvider, - SessionMapper $sessionMapper, - EventProvisioningService $eventProvisioningService, - ProvisioningService $provisioningService, - IL10N $l10n, - ILogger $logger, - ICrypto $crypto - ) { - parent::__construct($request, $config); - - $this->random = $random; - $this->session = $session; - $this->clientService = $clientService; - $this->discoveryService = $discoveryService; - $this->urlGenerator = $urlGenerator; - $this->userSession = $userSession; - $this->userManager = $userManager; - $this->timeFactory = $timeFactory; - $this->providerMapper = $providerMapper; - $this->providerService = $providerService; - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->config = $config; - $this->ldapService = $ldapService; - $this->authTokenProvider = $authTokenProvider; - $this->sessionMapper = $sessionMapper; - $this->eventProvisioningService = $eventProvisioningService; - $this->provisioningService = $provisioningService; - $this->request = $request; - $this->l10n = $l10n; - $this->crypto = $crypto; - } - - /** - * @return bool - */ - private function isSecure(): bool { - // no restriction in debug mode - return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; - } - - /** - * @param bool|null $throttle - * @return TemplateResponse - */ - private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { - $params = [ - 'errors' => [ - ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], - ], - ]; - $throttleMetadata = ['reason' => 'insecure connection']; - return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); - } - - /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcLogin) - * - * @param int $providerId - * @param string|null $redirectUrl - * @return DataDisplayResponse|RedirectResponse|TemplateResponse - */ - public function login(int $providerId, string $redirectUrl = null) { - if ($this->userSession->isLoggedIn()) { - return new RedirectResponse($redirectUrl); - } - if (!$this->isSecure()) { - return $this->buildProtocolErrorResponse(); - } - $this->logger->debug('Initiating login for provider with id: ' . $providerId); - - try { - $provider = $this->providerMapper->getProvider($providerId); - } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); - } - - $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::STATE, $state); - $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); - - $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::NONCE, $nonce); - - $this->session->set(self::PROVIDERID, $providerId); - $this->session->close(); - - // get attribute mapping settings - $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); - $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); - $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); - $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); - - $claims = [ - // more details about requesting claims: - // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests - 'id_token' => [ - // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there - // null means we want it - $emailAttribute => null, - $displaynameAttribute => null, - $quotaAttribute => null, - $groupsAttribute => null, - ], - 'userinfo' => [ - $emailAttribute => null, - $displaynameAttribute => null, - $quotaAttribute => null, - $groupsAttribute => null, - ], - ]; - - if ($uidAttribute !== 'sub') { - $claims['id_token'][$uidAttribute] = ['essential' => true]; - $claims['userinfo'][$uidAttribute] = ['essential' => true]; - } - - $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); - if ($extraClaimsString) { - $extraClaims = explode(' ', $extraClaimsString); - foreach ($extraClaims as $extraClaim) { - $claims['id_token'][$extraClaim] = null; - $claims['userinfo'][$extraClaim] = null; - } - } - - $data = [ - 'client_id' => $provider->getClientId(), - 'response_type' => 'code', - 'scope' => trim($provider->getScope()), - 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), - 'claims' => json_encode($claims), - 'state' => $state, - 'nonce' => $nonce, - ]; - // pass discovery query parameters also on to the authentication - $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); - if (isset($discoveryUrl['query'])) { - $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); - $discoveryQuery = []; - parse_str($discoveryUrl['query'], $discoveryQuery); - $data += $discoveryQuery; - } - - try { - $discovery = $this->discoveryService->obtainDiscovery($provider); - } catch (\Exception $e) { - $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); - $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); - } - - $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); - - $this->logger->debug('Redirecting user to: ' . $authorizationUrl); - - // Workaround to avoid empty session on special conditions in Safari - // https://github.com/nextcloud/user_oidc/pull/358 - if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { - return new DataDisplayResponse(''); - } - - return new RedirectResponse($authorizationUrl); - } - - /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcCode) - * - * @param string $state - * @param string $code - * @param string $scope - * @param string $error - * @param string $error_description - * @return JSONResponse|RedirectResponse|TemplateResponse - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - * @throws SessionNotAvailableException - * @throws \JsonException - */ - public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') { - if (!$this->isSecure()) { - return $this->buildProtocolErrorResponse(); - } - $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); - - if ($error !== '') { - return new JSONResponse([ - 'error' => $error, - 'error_description' => $error_description, - ], Http::STATUS_FORBIDDEN); - } - - if ($this->session->get(self::STATE) !== $state) { - $this->logger->debug('state does not match'); - - $message = $this->l10n->t('The received state does not match the expected value.'); - if ($this->isDebugModeEnabled()) { - $responseData = [ - 'error' => 'invalid_state', - 'error_description' => $message, - 'got' => $state, - 'expected' => $this->session->get(self::STATE), - ]; - return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); - } - // we know debug mode is off, always throttle - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); - } - - $providerId = (int)$this->session->get(self::PROVIDERID); - $provider = $this->providerMapper->getProvider($providerId); - try { - $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret()); - } catch (\Exception $e) { - $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]); - $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); - } - - $discovery = $this->discoveryService->obtainDiscovery($provider); - - $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); - - $client = $this->clientService->newClient(); - try { - $result = $client->post( - $discovery['token_endpoint'], - [ - 'body' => [ - 'code' => $code, - 'client_id' => $provider->getClientId(), - 'client_secret' => $providerClientSecret, - 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), - 'grant_type' => 'authorization_code', - ], - ] - ); - } catch (ClientException | ServerException $e) { - $response = $e->getResponse(); - $body = (string) $response->getBody(); - $responseBodyArray = json_decode($body, true); - if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ - 'exception' => $e, - 'error' => $responseBodyArray['error'], - 'error_description' => $responseBodyArray['error_description'], - ]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; - } else { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); - } - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); - } catch (\Exception $e) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); - } - - $data = json_decode($result->getBody(), true); - $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); - $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); - - // TODO: proper error handling - $idTokenRaw = $data['id_token']; - $jwks = $this->discoveryService->obtainJWK($provider); - JWT::$leeway = 60; - $idTokenPayload = JWT::decode($idTokenRaw, $jwks, array_keys(JWT::$supported_algs)); - - $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); - - if ($idTokenPayload->exp < $this->timeFactory->getTime()) { - $this->logger->debug('Token expired'); - $message = $this->l10n->t('The received token is expired.'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); - } - - // Verify issuer - if ($idTokenPayload->iss !== $discovery['issuer']) { - $this->logger->debug('This token is issued by the wrong issuer'); - $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); - } - - // Verify audience - if (!($idTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $idTokenPayload->aud, true))) { - $this->logger->debug('This token is not for us'); - $message = $this->l10n->t('The audience does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); - } - - // If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. - // If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. - if (is_array($idTokenPayload->aud) && count($idTokenPayload->aud) > 1) { - if (isset($idTokenPayload->azp)) { - if ($idTokenPayload->azp !== $provider->getClientId()) { - $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); - $message = $this->l10n->t('The authorized party does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); - } - } else { - $this->logger->debug('Multiple audiences but no authorized party (azp) in the id token'); - $message = $this->l10n->t('No authorized party'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['missing_azp']); - } - } - - if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { - $this->logger->debug('Nonce does not match'); - $message = $this->l10n->t('The nonce does not match'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); - } - - // get user ID attribute - $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $userId = $idTokenPayload->{$uidAttribute} ?? null; - if ($userId === null) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); - } - - $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); - $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); - $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); - - // Provisioning - if ($eventProvisionAllowed) { - // for the moment, make event provisioning another (prio) config option - // TODO: (proposal) refactor all provisioning strategies into event handlers - try { - $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); - } catch( ProvisioningDeniedException $denied ) { - $redirectUrl = $denied->getRedirectUrl(); - if ( $redirectUrl === null ) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); - } else { - // error response is a redirect, e.g. to a booking site - // so that you can immediately get the registration page - return new RedirectResponse($redirectUrl); - } - } catch (\Exception $e) { - $user = null; - } - } else if ($autoProvisionAllowed) { - $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); - } else { - // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results - // so new users will be directly available even if they were not synced before this login attempt - $this->userManager->search($userId); - $this->ldapService->syncUser($userId); - // when auto provision is disabled, we assume the user has been created by another user backend (or manually) - $user = $this->userManager->get($userId); - if ($this->ldapService->isLdapDeletedUser($user)) { - $user = null; - } - } - - if ($user === null) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); - } - - $this->session->set(self::ID_TOKEN, $idTokenRaw); - - $this->logger->debug('Logging user in'); - - $this->userSession->setUser($user); - $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); - $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); - $this->userSession->createRememberMeToken($user); - - // for backchannel logout - try { - $authToken = $this->authTokenProvider->getToken($this->session->getId()); - $this->sessionMapper->createSession( - $idTokenPayload->sid ?? 'fallback-sid', - $idTokenPayload->sub ?? 'fallback-sub', - $idTokenPayload->iss ?? 'fallback-iss', - $authToken->getId(), - $this->session->getId() - ); - } catch (InvalidTokenException $e) { - $this->logger->debug('Auth token not found after login'); - } - - // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar - if ($user->canChangeAvatar()) { - $this->logger->debug('$user->canChangeAvatar() is true'); - } - - $this->logger->debug('Redirecting user'); - - $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); - if ($redirectUrl) { - return new RedirectResponse($redirectUrl); - } - - return new RedirectResponse(\OC_Util::getDefaultPageUrl()); - } - - /** - * Endpoint called by NC to logout in the IdP before killing the current session - * - * @NoAdminRequired - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcSingleLogout) - * - * @return RedirectResponse|TemplateResponse - * @throws Exception - * @throws SessionNotAvailableException - * @throws \JsonException - */ - public function singleLogoutService() { - // TODO throttle in all failing cases - $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); - $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); - if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { - $providerId = $this->session->get(self::PROVIDERID); - if ($providerId) { - try { - $provider = $this->providerMapper->getProvider((int)$providerId); - } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); - } - $endSessionEndpoint = $this->discoveryService->obtainDiscovery($provider)['end_session_endpoint']; - if ($endSessionEndpoint) { - $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; - $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); - $shouldSendIdToken = $this->providerService->getSetting( - $provider->getId(), - ProviderService::SETTING_SEND_ID_TOKEN_HINT, '0' - ) === '1'; - $idToken = $this->session->get(self::ID_TOKEN); - if ($shouldSendIdToken && $idToken) { - $endSessionEndpoint .= '&id_token_hint=' . $idToken; - } - $targetUrl = $endSessionEndpoint; - } - } - } - - // cleanup related oidc session - $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); - - $this->userSession->logout(); - - // make sure we clear the session to avoid messing with Backend::isSessionActive - $this->session->clear(); - return new RedirectResponse($targetUrl); - } - - /** - * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client - * The logout token contains the sid for which we know the sessionId - * which leads to the auth token that we can invalidate - * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html - * - * @PublicPage - * @NoCSRFRequired - * @BruteForceProtection(action=userOidcBackchannelLogout) - * - * @param string $providerIdentifier - * @param string $logout_token - * @return JSONResponse - * @throws Exception - * @throws \JsonException - */ - public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse { - // get the provider - $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); - if ($provider === null) { - return $this->getBackchannelLogoutErrorResponse( - 'provider not found', - 'The provider was not found in Nextcloud', - ['provider_not_found' => $providerIdentifier] - ); - } - - // decrypt the logout token - $jwks = $this->discoveryService->obtainJWK($provider); - JWT::$leeway = 60; - $logoutTokenPayload = JWT::decode($logout_token, $jwks, array_keys(JWT::$supported_algs)); - - $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); - - // check the audience - if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid audience', - 'The audience of the logout token does not match the provider', - ['invalid_audience' => $logoutTokenPayload->aud] - ); - } - - // check the event attr - if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid event', - 'The backchannel-logout event was not found in the logout token', - ['invalid_event' => true] - ); - } - - // check the nonce attr - if (isset($logoutTokenPayload->nonce)) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid nonce', - 'The logout token should not contain a nonce attribute', - ['nonce_should_not_be_set' => true] - ); - } - - // get the auth token ID associated with the logout token's sid attr - $sid = $logoutTokenPayload->sid; - try { - $oidcSession = $this->sessionMapper->findSessionBySid($sid); - } catch (DoesNotExistException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was not found', - ['session_sid_not_found' => $sid] - ); - } catch (MultipleObjectsReturnedException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was found multiple times', - ['multiple_logout_tokens_found' => $sid] - ); - } - - $sub = $logoutTokenPayload->sub; - if ($oidcSession->getSub() !== $sub) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SUB', - 'The sub does not match the one from the login ID token', - ['invalid_sub' => $sub] - ); - } - $iss = $logoutTokenPayload->iss; - if ($oidcSession->getIss() !== $iss) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid ISS', - 'The iss does not match the one from the login ID token', - ['invalid_iss' => $iss] - ); - } - - // i don't know why but the cast is necessary - $authTokenId = (int)$oidcSession->getAuthtokenId(); - try { - $authToken = $this->authTokenProvider->getTokenById($authTokenId); - // we could also get the auth token by nc session ID - // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); - $userId = $authToken->getUID(); - $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); - } catch (InvalidTokenException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'nc session not found', - 'The authentication session was not found in Nextcloud', - ['nc_auth_session_not_found' => $authTokenId] - ); - } - - // cleanup - $this->sessionMapper->delete($oidcSession); - - return new JSONResponse([], Http::STATUS_OK); - } - - /** - * Generate an error response according to the OIDC standard - * Log the error - * - * @param string $error - * @param string $description - * @param array $throttleMetadata - * @param bool|null $throttle - * @return JSONResponse - */ - private function getBackchannelLogoutErrorResponse(string $error, string $description, - array $throttleMetadata = [], ?bool $throttle = null): JSONResponse { - $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); - $response = new JSONResponse( - [ - 'error' => $error, - 'error_description' => $description, - ], - Http::STATUS_BAD_REQUEST, - ); - if (($throttle === null && !$this->isDebugModeEnabled()) || $throttle) { - $response->throttle($throttleMetadata); - } - return $response; - } +class LoginController extends BaseOidcController +{ + private const STATE = 'oidc.state'; + private const NONCE = 'oidc.nonce'; + public const PROVIDERID = 'oidc.providerid'; + private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; + private const ID_TOKEN = 'oidc.id_token'; + + /** @var ISecureRandom */ + private $random; + + /** @var ISession */ + private $session; + + /** @var IClientService */ + private $clientService; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var IUserSession */ + private $userSession; + + /** @var IUserManager */ + private $userManager; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var ProviderMapper */ + private $providerMapper; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var ProviderService */ + private $providerService; + + /** @var DiscoveryService */ + private $discoveryService; + + /** @var IConfig */ + private $config; + + /** @var LdapService */ + private $ldapService; + + /** @var IProvider */ + private $authTokenProvider; + + /** @var SessionMapper */ + private $sessionMapper; + + /** @var EventProvisioningService */ + private $eventProvisioningService; + + /** @var ProvisioningService */ + private $provisioningService; + + /** @var IL10N */ + private $l10n; + /** + * @var ICrypto + */ + private $crypto; + + public function __construct( + IRequest $request, + ProviderMapper $providerMapper, + ProviderService $providerService, + DiscoveryService $discoveryService, + LdapService $ldapService, + ISecureRandom $random, + ISession $session, + IClientService $clientService, + IURLGenerator $urlGenerator, + IUserSession $userSession, + IUserManager $userManager, + ITimeFactory $timeFactory, + IEventDispatcher $eventDispatcher, + IConfig $config, + IProvider $authTokenProvider, + SessionMapper $sessionMapper, + EventProvisioningService $eventProvisioningService, + ProvisioningService $provisioningService, + IL10N $l10n, + ILogger $logger, + ICrypto $crypto + ) { + parent::__construct($request, $config); + + $this->random = $random; + $this->session = $session; + $this->clientService = $clientService; + $this->discoveryService = $discoveryService; + $this->urlGenerator = $urlGenerator; + $this->userSession = $userSession; + $this->userManager = $userManager; + $this->timeFactory = $timeFactory; + $this->providerMapper = $providerMapper; + $this->providerService = $providerService; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->config = $config; + $this->ldapService = $ldapService; + $this->authTokenProvider = $authTokenProvider; + $this->sessionMapper = $sessionMapper; + $this->eventProvisioningService = $eventProvisioningService; + $this->provisioningService = $provisioningService; + $this->request = $request; + $this->l10n = $l10n; + $this->crypto = $crypto; + } + + /** + * @return bool + */ + private function isSecure(): bool + { + // no restriction in debug mode + return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; + } + + /** + * @param bool|null $throttle + * @return TemplateResponse + */ + private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse + { + $params = [ + 'errors' => [ + ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], + ], + ]; + $throttleMetadata = ['reason' => 'insecure connection']; + return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); + } + + /** + * @PublicPage + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcLogin) + * + * @param int $providerId + * @param string|null $redirectUrl + * @return DataDisplayResponse|RedirectResponse|TemplateResponse + */ + public function login(int $providerId, string $redirectUrl = null) + { + if ($this->userSession->isLoggedIn()) { + return new RedirectResponse($redirectUrl); + } + if (!$this->isSecure()) { + return $this->buildProtocolErrorResponse(); + } + $this->logger->debug('Initiating login for provider with id: ' . $providerId); + + try { + $provider = $this->providerMapper->getProvider($providerId); + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + $message = $this->l10n->t('There is not such OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); + } + + $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::STATE, $state); + $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); + + $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::NONCE, $nonce); + + $this->session->set(self::PROVIDERID, $providerId); + $this->session->close(); + + // get attribute mapping settings + $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); + $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); + $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); + $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); + + $claims = [ + // more details about requesting claims: + // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests + 'id_token' => [ + // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there + // null means we want it + $emailAttribute => null, + $displaynameAttribute => null, + $quotaAttribute => null, + $groupsAttribute => null, + ], + 'userinfo' => [ + $emailAttribute => null, + $displaynameAttribute => null, + $quotaAttribute => null, + $groupsAttribute => null, + ], + ]; + + if ($uidAttribute !== 'sub') { + $claims['id_token'][$uidAttribute] = ['essential' => true]; + $claims['userinfo'][$uidAttribute] = ['essential' => true]; + } + + $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); + if ($extraClaimsString) { + $extraClaims = explode(' ', $extraClaimsString); + foreach ($extraClaims as $extraClaim) { + $claims['id_token'][$extraClaim] = null; + $claims['userinfo'][$extraClaim] = null; + } + } + + $data = [ + 'client_id' => $provider->getClientId(), + 'response_type' => 'code', + 'scope' => trim($provider->getScope()), + 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), + 'claims' => json_encode($claims), + 'state' => $state, + 'nonce' => $nonce, + ]; + // pass discovery query parameters also on to the authentication + $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); + if (isset($discoveryUrl['query'])) { + $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); + $discoveryQuery = []; + parse_str($discoveryUrl['query'], $discoveryQuery); + $data += $discoveryQuery; + } + + try { + $discovery = $this->discoveryService->obtainDiscovery($provider); + } catch (\Exception $e) { + $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); + $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); + } + + $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); + + $this->logger->debug('Redirecting user to: ' . $authorizationUrl); + + // Workaround to avoid empty session on special conditions in Safari + // https://github.com/nextcloud/user_oidc/pull/358 + if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { + return new DataDisplayResponse(''); + } + + return new RedirectResponse($authorizationUrl); + } + + /** + * @PublicPage + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcCode) + * + * @param string $state + * @param string $code + * @param string $scope + * @param string $error + * @param string $error_description + * @return JSONResponse|RedirectResponse|TemplateResponse + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws SessionNotAvailableException + * @throws \JsonException + */ + public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') + { + if (!$this->isSecure()) { + return $this->buildProtocolErrorResponse(); + } + $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); + + if ($error !== '') { + return new JSONResponse([ + 'error' => $error, + 'error_description' => $error_description, + ], Http::STATUS_FORBIDDEN); + } + + if ($this->session->get(self::STATE) !== $state) { + $this->logger->debug('state does not match'); + + $message = $this->l10n->t('The received state does not match the expected value.'); + if ($this->isDebugModeEnabled()) { + $responseData = [ + 'error' => 'invalid_state', + 'error_description' => $message, + 'got' => $state, + 'expected' => $this->session->get(self::STATE), + ]; + return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); + } + // we know debug mode is off, always throttle + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); + } + + $providerId = (int)$this->session->get(self::PROVIDERID); + $provider = $this->providerMapper->getProvider($providerId); + try { + $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret()); + } catch (\Exception $e) { + $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]); + $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); + } + + $discovery = $this->discoveryService->obtainDiscovery($provider); + + $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); + + $client = $this->clientService->newClient(); + try { + $result = $client->post( + $discovery['token_endpoint'], + [ + 'body' => [ + 'code' => $code, + 'client_id' => $provider->getClientId(), + 'client_secret' => $providerClientSecret, + 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), + 'grant_type' => 'authorization_code', + ], + ] + ); + } catch (ClientException | ServerException $e) { + $response = $e->getResponse(); + $body = (string) $response->getBody(); + $responseBodyArray = json_decode($body, true); + if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ + 'exception' => $e, + 'error' => $responseBodyArray['error'], + 'error_description' => $responseBodyArray['error_description'], + ]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; + } else { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); + } + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); + } catch (\Exception $e) { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); + } + + $data = json_decode($result->getBody(), true); + $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); + $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); + + // TODO: proper error handling + $idTokenRaw = $data['id_token']; + $jwks = $this->discoveryService->obtainJWK($provider); + JWT::$leeway = 60; + $idTokenPayload = JWT::decode($idTokenRaw, $jwks, array_keys(JWT::$supported_algs)); + + $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); + + if ($idTokenPayload->exp < $this->timeFactory->getTime()) { + $this->logger->debug('Token expired'); + $message = $this->l10n->t('The received token is expired.'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); + } + + // Verify issuer + if ($idTokenPayload->iss !== $discovery['issuer']) { + $this->logger->debug('This token is issued by the wrong issuer'); + $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); + } + + // Verify audience + if (!($idTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $idTokenPayload->aud, true))) { + $this->logger->debug('This token is not for us'); + $message = $this->l10n->t('The audience does not match ours'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); + } + + // If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. + // If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. + if (is_array($idTokenPayload->aud) && count($idTokenPayload->aud) > 1) { + if (isset($idTokenPayload->azp)) { + if ($idTokenPayload->azp !== $provider->getClientId()) { + $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); + $message = $this->l10n->t('The authorized party does not match ours'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); + } + } else { + $this->logger->debug('Multiple audiences but no authorized party (azp) in the id token'); + $message = $this->l10n->t('No authorized party'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['missing_azp']); + } + } + + if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { + $this->logger->debug('Nonce does not match'); + $message = $this->l10n->t('The nonce does not match'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); + } + + // get user ID attribute + $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $userId = $idTokenPayload->{$uidAttribute} ?? null; + if ($userId === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); + } + + $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); + $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); + + // Provisioning + if ($eventProvisionAllowed) { + // for the moment, make event provisioning another (prio) config option + // TODO: (proposal) refactor all provisioning strategies into event handlers + try { + $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } catch(ProvisioningDeniedException $denied) { + $redirectUrl = $denied->getRedirectUrl(); + if ($redirectUrl === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } else { + // error response is a redirect, e.g. to a booking site + // so that you can immediately get the registration page + return new RedirectResponse($redirectUrl); + } + } catch (\Exception $e) { + $user = null; + } + } elseif ($autoProvisionAllowed) { + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } else { + // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results + // so new users will be directly available even if they were not synced before this login attempt + $this->userManager->search($userId); + $this->ldapService->syncUser($userId); + // when auto provision is disabled, we assume the user has been created by another user backend (or manually) + $user = $this->userManager->get($userId); + if ($this->ldapService->isLdapDeletedUser($user)) { + $user = null; + } + } + + if ($user === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); + } + + $this->session->set(self::ID_TOKEN, $idTokenRaw); + + $this->logger->debug('Logging user in'); + + $this->userSession->setUser($user); + $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); + $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); + $this->userSession->createRememberMeToken($user); + + // for backchannel logout + try { + $authToken = $this->authTokenProvider->getToken($this->session->getId()); + $this->sessionMapper->createSession( + $idTokenPayload->sid ?? 'fallback-sid', + $idTokenPayload->sub ?? 'fallback-sub', + $idTokenPayload->iss ?? 'fallback-iss', + $authToken->getId(), + $this->session->getId() + ); + } catch (InvalidTokenException $e) { + $this->logger->debug('Auth token not found after login'); + } + + // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar + if ($user->canChangeAvatar()) { + $this->logger->debug('$user->canChangeAvatar() is true'); + } + + $this->logger->debug('Redirecting user'); + + $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); + if ($redirectUrl) { + return new RedirectResponse($redirectUrl); + } + + return new RedirectResponse(\OC_Util::getDefaultPageUrl()); + } + + /** + * Endpoint called by NC to logout in the IdP before killing the current session + * + * @NoAdminRequired + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcSingleLogout) + * + * @return RedirectResponse|TemplateResponse + * @throws Exception + * @throws SessionNotAvailableException + * @throws \JsonException + */ + public function singleLogoutService() + { + // TODO throttle in all failing cases + $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); + if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { + $providerId = $this->session->get(self::PROVIDERID); + if ($providerId) { + try { + $provider = $this->providerMapper->getProvider((int)$providerId); + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + $message = $this->l10n->t('There is not such OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); + } + $endSessionEndpoint = $this->discoveryService->obtainDiscovery($provider)['end_session_endpoint']; + if ($endSessionEndpoint) { + $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; + $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); + $shouldSendIdToken = $this->providerService->getSetting( + $provider->getId(), + ProviderService::SETTING_SEND_ID_TOKEN_HINT, + '0' + ) === '1'; + $idToken = $this->session->get(self::ID_TOKEN); + if ($shouldSendIdToken && $idToken) { + $endSessionEndpoint .= '&id_token_hint=' . $idToken; + } + $targetUrl = $endSessionEndpoint; + } + } + } + + // cleanup related oidc session + $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); + + $this->userSession->logout(); + + // make sure we clear the session to avoid messing with Backend::isSessionActive + $this->session->clear(); + return new RedirectResponse($targetUrl); + } + + /** + * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client + * The logout token contains the sid for which we know the sessionId + * which leads to the auth token that we can invalidate + * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html + * + * @PublicPage + * @NoCSRFRequired + * @BruteForceProtection(action=userOidcBackchannelLogout) + * + * @param string $providerIdentifier + * @param string $logout_token + * @return JSONResponse + * @throws Exception + * @throws \JsonException + */ + public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse + { + // get the provider + $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); + if ($provider === null) { + return $this->getBackchannelLogoutErrorResponse( + 'provider not found', + 'The provider was not found in Nextcloud', + ['provider_not_found' => $providerIdentifier] + ); + } + + // decrypt the logout token + $jwks = $this->discoveryService->obtainJWK($provider); + JWT::$leeway = 60; + $logoutTokenPayload = JWT::decode($logout_token, $jwks, array_keys(JWT::$supported_algs)); + + $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); + + // check the audience + if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid audience', + 'The audience of the logout token does not match the provider', + ['invalid_audience' => $logoutTokenPayload->aud] + ); + } + + // check the event attr + if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid event', + 'The backchannel-logout event was not found in the logout token', + ['invalid_event' => true] + ); + } + + // check the nonce attr + if (isset($logoutTokenPayload->nonce)) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid nonce', + 'The logout token should not contain a nonce attribute', + ['nonce_should_not_be_set' => true] + ); + } + + // get the auth token ID associated with the logout token's sid attr + $sid = $logoutTokenPayload->sid; + try { + $oidcSession = $this->sessionMapper->findSessionBySid($sid); + } catch (DoesNotExistException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was not found', + ['session_sid_not_found' => $sid] + ); + } catch (MultipleObjectsReturnedException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was found multiple times', + ['multiple_logout_tokens_found' => $sid] + ); + } + + $sub = $logoutTokenPayload->sub; + if ($oidcSession->getSub() !== $sub) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SUB', + 'The sub does not match the one from the login ID token', + ['invalid_sub' => $sub] + ); + } + $iss = $logoutTokenPayload->iss; + if ($oidcSession->getIss() !== $iss) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid ISS', + 'The iss does not match the one from the login ID token', + ['invalid_iss' => $iss] + ); + } + + // i don't know why but the cast is necessary + $authTokenId = (int)$oidcSession->getAuthtokenId(); + try { + $authToken = $this->authTokenProvider->getTokenById($authTokenId); + // we could also get the auth token by nc session ID + // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); + $userId = $authToken->getUID(); + $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); + } catch (InvalidTokenException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'nc session not found', + 'The authentication session was not found in Nextcloud', + ['nc_auth_session_not_found' => $authTokenId] + ); + } + + // cleanup + $this->sessionMapper->delete($oidcSession); + + return new JSONResponse([], Http::STATUS_OK); + } + + /** + * Generate an error response according to the OIDC standard + * Log the error + * + * @param string $error + * @param string $description + * @param array $throttleMetadata + * @param bool|null $throttle + * @return JSONResponse + */ + private function getBackchannelLogoutErrorResponse( + string $error, + string $description, + array $throttleMetadata = [], + ?bool $throttle = null + ): JSONResponse { + $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); + $response = new JSONResponse( + [ + 'error' => $error, + 'error_description' => $description, + ], + Http::STATUS_BAD_REQUEST, + ); + if (($throttle === null && !$this->isDebugModeEnabled()) || $throttle) { + $response->throttle($throttleMetadata); + } + return $response; + } } diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php index 5825c8e0..c2e1f6f0 100644 --- a/lib/Event/UserAccountChangeEvent.php +++ b/lib/Event/UserAccountChangeEvent.php @@ -1,24 +1,11 @@ + * @copyright Copyright (c) 2023 T-Systems International * - * @author Julius Härtl + * @author B. Rederlechner * * @license GNU AGPL version 3 or any later version * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * */ declare(strict_types=1); @@ -35,7 +22,8 @@ * in the listener after processing as the value might get overwritten afterwards * by other listeners through $event->stopPropagation(); */ -class UserAccountChangeEvent extends Event { +class UserAccountChangeEvent extends Event +{ private $uid; private $displayname; @@ -45,59 +33,67 @@ class UserAccountChangeEvent extends Event { private $result; - public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { - parent::__construct(); - $this->uid = $uid; - $this->displayname = $displayname; - $this->mainEmail = $mainEmail; - $this->quota = $quota; - $this->claims = $claims; - $this->result = new UserAccountChangeResult($accessAllowed, 'default'); - } + public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) + { + parent::__construct(); + $this->uid = $uid; + $this->displayname = $displayname; + $this->mainEmail = $mainEmail; + $this->quota = $quota; + $this->claims = $claims; + $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + } - /** - * @return get event username (uid) - */ - public function getUid(): string { - return $this->uid; - } + /** + * @return get event username (uid) + */ + public function getUid(): string + { + return $this->uid; + } - /** - * @return get event displayname - */ - public function getDisplayName(): ?string { - return $this->displayname; - } + /** + * @return get event displayname + */ + public function getDisplayName(): ?string + { + return $this->displayname; + } - /** - * @return get event main email - */ - public function getMainEmail(): ?string { - return $this->mainEmail; - } + /** + * @return get event main email + */ + public function getMainEmail(): ?string + { + return $this->mainEmail; + } - /** - * @return get event quota - */ - public function getQuota(): ?string { - return $this->quota; - } + /** + * @return get event quota + */ + public function getQuota(): ?string + { + return $this->quota; + } - /** - * @return array the array of claim values associated with the event - */ - public function getClaims(): object { - return $this->claims; - } + /** + * @return array the array of claim values associated with the event + */ + public function getClaims(): object + { + return $this->claims; + } - /** - * @return value for the logged in user attribute - */ - public function getResult(): UserAccountChangeResult { - return $this->result; - } + /** + * @return value for the logged in user attribute + */ + 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); - } + 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 index e5a7c468..8a2c961f 100644 --- a/lib/Event/UserAccountChangeResult.php +++ b/lib/Event/UserAccountChangeResult.php @@ -1,24 +1,11 @@ + * @copyright Copyright (c) 2023 T-Systems International * - * @author Julius Härtl + * @author B. Rederlechner * * @license GNU AGPL version 3 or any later version * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * */ declare(strict_types=1); @@ -31,7 +18,8 @@ * in the listener after processing as the value might get overwritten afterwards * by other listeners through $event->stopPropagation(); */ -class UserAccountChangeResult { +class UserAccountChangeResult +{ /** @var bool */ private $accessAllowed; @@ -40,48 +28,55 @@ class UserAccountChangeResult { /** @var string */ private $redirectUrl; - public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { - $this->accessAllowed = $accessAllowed; + public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) + { + $this->accessAllowed = $accessAllowed; $this->redirectUrl = $redirectUrl; $this->reason = $reason; - } + } - /** - * @return value for the logged in user attribute - */ - public function isAccessAllowed(): bool { - return $this->accessAllowed; - } + /** + * @return value for the logged in user attribute + */ + public function isAccessAllowed(): bool + { + return $this->accessAllowed; + } - public function setAccessAllowed(bool $accessAllowed): void { - $this->accessAllowed = $accessAllowed; - } + public function setAccessAllowed(bool $accessAllowed): void + { + $this->accessAllowed = $accessAllowed; + } - /** - * @return get optional alternate redirect address - */ - public function getRedirectUrl(): ?string { - return $this->redirectUrl; - } + /** + * @return get optional alternate redirect address + */ + public function getRedirectUrl(): ?string + { + return $this->redirectUrl; + } - /** - * @return set optional alternate redirect address - */ - public function setRedirectUrl(?string $redirectUrl): void { - $this->redirectUrl = $redirectUrl; - } + /** + * @return set optional alternate redirect address + */ + public function setRedirectUrl(?string $redirectUrl): void + { + $this->redirectUrl = $redirectUrl; + } - /** - * @return get decision reason - */ - public function getReason(): string { - return $this->reason; - } + /** + * @return get decision reason + */ + public function getReason(): string + { + return $this->reason; + } - /** - * @return set decision reason - */ - public function setReason(string $reason): void { - $this->reason = $reason; + /** + * @return set decision reason + */ + public function setReason(string $reason): void + { + $this->reason = $reason; } } diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php index 425ea14f..6d689e67 100644 --- a/lib/Service/ProvisioningDeniedException.php +++ b/lib/Service/ProvisioningDeniedException.php @@ -29,45 +29,48 @@ * Exception if the precondition of the config update method isn't met * @since 1.4.0 */ -class ProvisioningDeniedException extends \Exception { +class ProvisioningDeniedException extends \Exception +{ - private $redirectUrl; - - /** - * Exception constructor including an option redirect url. - * - * @param string $message The error message. It will be not revealed to the - * the user (unless the hint is empty) and thus - * should be not translated. - * @param string $hint A useful message that is presented to the end - * user. It should be translated, but must not - * contain sensitive data. - * @param int $code Set default to 403 (Forbidden) - * @param \Exception|null $previous - */ - public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) { - $this->redirectUrl = $redirectUrl; - parent::__construct($message, $code, $previous); - } - - /** + private $redirectUrl; + /** + * Exception constructor including an option redirect url. * - * @return string - */ - public function getRedirectUrl(): string { - return $this->redirectUrl; - } + * @param string $message The error message. It will be not revealed to the + * the user (unless the hint is empty) and thus + * should be not translated. + * @param string $hint A useful message that is presented to the end + * user. It should be translated, but must not + * contain sensitive data. + * @param int $code Set default to 403 (Forbidden) + * @param \Exception|null $previous + */ + public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) + { + parent::__construct($message, $code, $previous); + $this->redirectUrl = $redirectUrl; + } /** - * Include redirect in string serialisation. - * - * @return string - */ - public function __toString(): string { + * Read optional failure redirect if available + * @return string|null + */ + public function getRedirectUrl(): ?string + { + return $this->redirectUrl; + } + + /** + * Include redirect in string serialisation. + * + * @return string + */ + public function __toString(): string + { $redirect = $this->redirectUrl ?? ''; - return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; - } + return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } } diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index 0b2ffe8f..17e91692 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -34,116 +34,125 @@ use OCP\ILogger; use OCP\IUserManager; -class ProvisioningEventService { - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var ILogger */ - private $logger; - - /** @var UserMapper */ - private $userMapper; - - /** @var IUserManager */ - private $userManager; - - /** @var ProviderService */ - private $providerService; - - public function __construct(IEventDispatcher $eventDispatcher, - ILogger $logger, - UserMapper $userMapper, - IUserManager $userManager, - ProviderService $providerService) { - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->userMapper = $userMapper; - $this->userManager = $userManager; - $this->providerService = $providerService; - } - - protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) { - $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); - $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); - $this->eventDispatcher->dispatchTyped($event); - return $event->getValue(); - } - - protected function mapDispatchDisplayname(int $providerid, object $payload) { - $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); - $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; - - if (isset($mappedDisplayName)) { - $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); - } else { - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); - } - $this->eventDispatcher->dispatchTyped($event); - return $event->getValue(); - } - - protected function mapDispatchEmail(int $providerid, object $payload) { - $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); - return $event->getValue(); - } - - protected function mapDispatchQuota(int $providerid, object $payload) { - $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); - return $event->getValue(); - } - - protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) { - $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); - $this->eventDispatcher->dispatchTyped($event); +class ProvisioningEventService +{ + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var UserMapper */ + private $userMapper; + + /** @var IUserManager */ + private $userManager; + + /** @var ProviderService */ + private $providerService; + + public function __construct( + IEventDispatcher $eventDispatcher, + ILogger $logger, + UserMapper $userMapper, + IUserManager $userManager, + ProviderService $providerService + ) { + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->userMapper = $userMapper; + $this->userManager = $userManager; + $this->providerService = $providerService; + } + + protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) + { + $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchDisplayname(int $providerid, object $payload) + { + $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (isset($mappedDisplayName)) { + $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); + } else { + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); + } + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchEmail(int $providerid, object $payload) + { + $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); + return $event->getValue(); + } + + protected function mapDispatchQuota(int $providerid, object $payload) + { + $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); + return $event->getValue(); + } + + protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) + { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); return $event->getResult(); } - /** - * Trigger a provisioning via event system. - * This allows to flexibly implement complex provisioning strategies - - * even in a separate app. - * - * On error, the provisioning logic can deliver failure reasons and - * even a redirect to a different endpoint. - * - * @param string $tokenUserId - * @param int $providerId - * @param object $idTokenPayload - * @return IUser|null - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws ProvisioningDeniedException - */ - public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { - try { - $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); - $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); - $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); - $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); - } catch (AttributeValueException $eAttribute) { - $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); - throw new ProvisioningDeniedException("Problems with user information."); - } - - $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); - if ($userReaction->isAccessAllowed()) { - $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); - $user = $this->userManager->get($uid); - return $user; - } else { - $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); - throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); - } - } - + /** + * Trigger a provisioning via event system. + * This allows to flexibly implement complex provisioning strategies - + * even in a separate app. + * + * On error, the provisioning logic can deliver failure reasons and + * even a redirect to a different endpoint. + * + * @param string $tokenUserId + * @param int $providerId + * @param object $idTokenPayload + * @return IUser|null + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser + { + try { + $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $eAttribute) { + $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException("Problems with user information."); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); + if ($userReaction->isAccessAllowed()) { + $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); + $user = $this->userManager->get($uid); + return $user; + } else { + $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); + } + } + } From d993f2b3cef4a77f874a7c8083bc42094ba9f658 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 10 Jul 2023 16:10:36 +0200 Subject: [PATCH 03/10] Fix coding standards calling locally installed php-cs-fix fix --- lib/Controller/LoginController.php | 1393 ++++++++--------- lib/Event/UserAccountChangeEvent.php | 122 +- lib/Event/UserAccountChangeResult.php | 98 +- lib/Service/ProvisioningDeniedException.php | 75 +- lib/Service/ProvisioningEventService.php | 236 ++- .../Service/EventProvisioningServiceTest.php | 36 +- 6 files changed, 956 insertions(+), 1004 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index b6c29d4e..3b30e70c 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -65,704 +65,697 @@ use OCP\Security\ISecureRandom; use OCP\Session\Exceptions\SessionNotAvailableException; -class LoginController extends BaseOidcController -{ - private const STATE = 'oidc.state'; - private const NONCE = 'oidc.nonce'; - public const PROVIDERID = 'oidc.providerid'; - private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; - private const ID_TOKEN = 'oidc.id_token'; - - /** @var ISecureRandom */ - private $random; - - /** @var ISession */ - private $session; - - /** @var IClientService */ - private $clientService; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var IUserSession */ - private $userSession; - - /** @var IUserManager */ - private $userManager; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var ProviderMapper */ - private $providerMapper; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var ILogger */ - private $logger; - - /** @var ProviderService */ - private $providerService; - - /** @var DiscoveryService */ - private $discoveryService; - - /** @var IConfig */ - private $config; - - /** @var LdapService */ - private $ldapService; - - /** @var IProvider */ - private $authTokenProvider; - - /** @var SessionMapper */ - private $sessionMapper; - - /** @var EventProvisioningService */ - private $eventProvisioningService; - - /** @var ProvisioningService */ - private $provisioningService; - - /** @var IL10N */ - private $l10n; - /** - * @var ICrypto - */ - private $crypto; - - public function __construct( - IRequest $request, - ProviderMapper $providerMapper, - ProviderService $providerService, - DiscoveryService $discoveryService, - LdapService $ldapService, - ISecureRandom $random, - ISession $session, - IClientService $clientService, - IURLGenerator $urlGenerator, - IUserSession $userSession, - IUserManager $userManager, - ITimeFactory $timeFactory, - IEventDispatcher $eventDispatcher, - IConfig $config, - IProvider $authTokenProvider, - SessionMapper $sessionMapper, - EventProvisioningService $eventProvisioningService, - ProvisioningService $provisioningService, - IL10N $l10n, - ILogger $logger, - ICrypto $crypto - ) { - parent::__construct($request, $config); - - $this->random = $random; - $this->session = $session; - $this->clientService = $clientService; - $this->discoveryService = $discoveryService; - $this->urlGenerator = $urlGenerator; - $this->userSession = $userSession; - $this->userManager = $userManager; - $this->timeFactory = $timeFactory; - $this->providerMapper = $providerMapper; - $this->providerService = $providerService; - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->config = $config; - $this->ldapService = $ldapService; - $this->authTokenProvider = $authTokenProvider; - $this->sessionMapper = $sessionMapper; - $this->eventProvisioningService = $eventProvisioningService; - $this->provisioningService = $provisioningService; - $this->request = $request; - $this->l10n = $l10n; - $this->crypto = $crypto; - } - - /** - * @return bool - */ - private function isSecure(): bool - { - // no restriction in debug mode - return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; - } - - /** - * @param bool|null $throttle - * @return TemplateResponse - */ - private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse - { - $params = [ - 'errors' => [ - ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], - ], - ]; - $throttleMetadata = ['reason' => 'insecure connection']; - return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); - } - - /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcLogin) - * - * @param int $providerId - * @param string|null $redirectUrl - * @return DataDisplayResponse|RedirectResponse|TemplateResponse - */ - public function login(int $providerId, string $redirectUrl = null) - { - if ($this->userSession->isLoggedIn()) { - return new RedirectResponse($redirectUrl); - } - if (!$this->isSecure()) { - return $this->buildProtocolErrorResponse(); - } - $this->logger->debug('Initiating login for provider with id: ' . $providerId); - - try { - $provider = $this->providerMapper->getProvider($providerId); - } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); - } - - $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::STATE, $state); - $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); - - $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); - $this->session->set(self::NONCE, $nonce); - - $this->session->set(self::PROVIDERID, $providerId); - $this->session->close(); - - // get attribute mapping settings - $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); - $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); - $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); - $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); - - $claims = [ - // more details about requesting claims: - // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests - 'id_token' => [ - // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there - // null means we want it - $emailAttribute => null, - $displaynameAttribute => null, - $quotaAttribute => null, - $groupsAttribute => null, - ], - 'userinfo' => [ - $emailAttribute => null, - $displaynameAttribute => null, - $quotaAttribute => null, - $groupsAttribute => null, - ], - ]; - - if ($uidAttribute !== 'sub') { - $claims['id_token'][$uidAttribute] = ['essential' => true]; - $claims['userinfo'][$uidAttribute] = ['essential' => true]; - } - - $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); - if ($extraClaimsString) { - $extraClaims = explode(' ', $extraClaimsString); - foreach ($extraClaims as $extraClaim) { - $claims['id_token'][$extraClaim] = null; - $claims['userinfo'][$extraClaim] = null; - } - } - - $data = [ - 'client_id' => $provider->getClientId(), - 'response_type' => 'code', - 'scope' => trim($provider->getScope()), - 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), - 'claims' => json_encode($claims), - 'state' => $state, - 'nonce' => $nonce, - ]; - // pass discovery query parameters also on to the authentication - $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); - if (isset($discoveryUrl['query'])) { - $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); - $discoveryQuery = []; - parse_str($discoveryUrl['query'], $discoveryQuery); - $data += $discoveryQuery; - } - - try { - $discovery = $this->discoveryService->obtainDiscovery($provider); - } catch (\Exception $e) { - $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); - $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); - } - - $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); - - $this->logger->debug('Redirecting user to: ' . $authorizationUrl); - - // Workaround to avoid empty session on special conditions in Safari - // https://github.com/nextcloud/user_oidc/pull/358 - if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { - return new DataDisplayResponse(''); - } - - return new RedirectResponse($authorizationUrl); - } - - /** - * @PublicPage - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcCode) - * - * @param string $state - * @param string $code - * @param string $scope - * @param string $error - * @param string $error_description - * @return JSONResponse|RedirectResponse|TemplateResponse - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - * @throws SessionNotAvailableException - * @throws \JsonException - */ - public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') - { - if (!$this->isSecure()) { - return $this->buildProtocolErrorResponse(); - } - $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); - - if ($error !== '') { - return new JSONResponse([ - 'error' => $error, - 'error_description' => $error_description, - ], Http::STATUS_FORBIDDEN); - } - - if ($this->session->get(self::STATE) !== $state) { - $this->logger->debug('state does not match'); - - $message = $this->l10n->t('The received state does not match the expected value.'); - if ($this->isDebugModeEnabled()) { - $responseData = [ - 'error' => 'invalid_state', - 'error_description' => $message, - 'got' => $state, - 'expected' => $this->session->get(self::STATE), - ]; - return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); - } - // we know debug mode is off, always throttle - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); - } - - $providerId = (int)$this->session->get(self::PROVIDERID); - $provider = $this->providerMapper->getProvider($providerId); - try { - $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret()); - } catch (\Exception $e) { - $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]); - $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); - } - - $discovery = $this->discoveryService->obtainDiscovery($provider); - - $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); - - $client = $this->clientService->newClient(); - try { - $result = $client->post( - $discovery['token_endpoint'], - [ - 'body' => [ - 'code' => $code, - 'client_id' => $provider->getClientId(), - 'client_secret' => $providerClientSecret, - 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), - 'grant_type' => 'authorization_code', - ], - ] - ); - } catch (ClientException | ServerException $e) { - $response = $e->getResponse(); - $body = (string) $response->getBody(); - $responseBodyArray = json_decode($body, true); - if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ - 'exception' => $e, - 'error' => $responseBodyArray['error'], - 'error_description' => $responseBodyArray['error_description'], - ]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; - } else { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); - } - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); - } catch (\Exception $e) { - $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); - $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); - } - - $data = json_decode($result->getBody(), true); - $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); - $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); - - // TODO: proper error handling - $idTokenRaw = $data['id_token']; - $jwks = $this->discoveryService->obtainJWK($provider); - JWT::$leeway = 60; - $idTokenPayload = JWT::decode($idTokenRaw, $jwks, array_keys(JWT::$supported_algs)); - - $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); - - if ($idTokenPayload->exp < $this->timeFactory->getTime()) { - $this->logger->debug('Token expired'); - $message = $this->l10n->t('The received token is expired.'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); - } - - // Verify issuer - if ($idTokenPayload->iss !== $discovery['issuer']) { - $this->logger->debug('This token is issued by the wrong issuer'); - $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); - } - - // Verify audience - if (!($idTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $idTokenPayload->aud, true))) { - $this->logger->debug('This token is not for us'); - $message = $this->l10n->t('The audience does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); - } - - // If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. - // If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. - if (is_array($idTokenPayload->aud) && count($idTokenPayload->aud) > 1) { - if (isset($idTokenPayload->azp)) { - if ($idTokenPayload->azp !== $provider->getClientId()) { - $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); - $message = $this->l10n->t('The authorized party does not match ours'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); - } - } else { - $this->logger->debug('Multiple audiences but no authorized party (azp) in the id token'); - $message = $this->l10n->t('No authorized party'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['missing_azp']); - } - } - - if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { - $this->logger->debug('Nonce does not match'); - $message = $this->l10n->t('The nonce does not match'); - return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); - } - - // get user ID attribute - $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); - $userId = $idTokenPayload->{$uidAttribute} ?? null; - if ($userId === null) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); - } - - $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); - $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); - $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); - - // Provisioning - if ($eventProvisionAllowed) { - // for the moment, make event provisioning another (prio) config option - // TODO: (proposal) refactor all provisioning strategies into event handlers - try { - $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); - } catch(ProvisioningDeniedException $denied) { - $redirectUrl = $denied->getRedirectUrl(); - if ($redirectUrl === null) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); - } else { - // error response is a redirect, e.g. to a booking site - // so that you can immediately get the registration page - return new RedirectResponse($redirectUrl); - } - } catch (\Exception $e) { - $user = null; - } - } elseif ($autoProvisionAllowed) { - $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); - } else { - // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results - // so new users will be directly available even if they were not synced before this login attempt - $this->userManager->search($userId); - $this->ldapService->syncUser($userId); - // when auto provision is disabled, we assume the user has been created by another user backend (or manually) - $user = $this->userManager->get($userId); - if ($this->ldapService->isLdapDeletedUser($user)) { - $user = null; - } - } - - if ($user === null) { - $message = $this->l10n->t('Failed to provision the user'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); - } - - $this->session->set(self::ID_TOKEN, $idTokenRaw); - - $this->logger->debug('Logging user in'); - - $this->userSession->setUser($user); - $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); - $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); - $this->userSession->createRememberMeToken($user); - - // for backchannel logout - try { - $authToken = $this->authTokenProvider->getToken($this->session->getId()); - $this->sessionMapper->createSession( - $idTokenPayload->sid ?? 'fallback-sid', - $idTokenPayload->sub ?? 'fallback-sub', - $idTokenPayload->iss ?? 'fallback-iss', - $authToken->getId(), - $this->session->getId() - ); - } catch (InvalidTokenException $e) { - $this->logger->debug('Auth token not found after login'); - } - - // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar - if ($user->canChangeAvatar()) { - $this->logger->debug('$user->canChangeAvatar() is true'); - } - - $this->logger->debug('Redirecting user'); - - $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); - if ($redirectUrl) { - return new RedirectResponse($redirectUrl); - } - - return new RedirectResponse(\OC_Util::getDefaultPageUrl()); - } - - /** - * Endpoint called by NC to logout in the IdP before killing the current session - * - * @NoAdminRequired - * @NoCSRFRequired - * @UseSession - * @BruteForceProtection(action=userOidcSingleLogout) - * - * @return RedirectResponse|TemplateResponse - * @throws Exception - * @throws SessionNotAvailableException - * @throws \JsonException - */ - public function singleLogoutService() - { - // TODO throttle in all failing cases - $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); - $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); - if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { - $providerId = $this->session->get(self::PROVIDERID); - if ($providerId) { - try { - $provider = $this->providerMapper->getProvider((int)$providerId); - } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { - $message = $this->l10n->t('There is not such OpenID Connect provider.'); - return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); - } - $endSessionEndpoint = $this->discoveryService->obtainDiscovery($provider)['end_session_endpoint']; - if ($endSessionEndpoint) { - $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; - $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); - $shouldSendIdToken = $this->providerService->getSetting( - $provider->getId(), - ProviderService::SETTING_SEND_ID_TOKEN_HINT, - '0' - ) === '1'; - $idToken = $this->session->get(self::ID_TOKEN); - if ($shouldSendIdToken && $idToken) { - $endSessionEndpoint .= '&id_token_hint=' . $idToken; - } - $targetUrl = $endSessionEndpoint; - } - } - } - - // cleanup related oidc session - $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); - - $this->userSession->logout(); - - // make sure we clear the session to avoid messing with Backend::isSessionActive - $this->session->clear(); - return new RedirectResponse($targetUrl); - } - - /** - * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client - * The logout token contains the sid for which we know the sessionId - * which leads to the auth token that we can invalidate - * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html - * - * @PublicPage - * @NoCSRFRequired - * @BruteForceProtection(action=userOidcBackchannelLogout) - * - * @param string $providerIdentifier - * @param string $logout_token - * @return JSONResponse - * @throws Exception - * @throws \JsonException - */ - public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse - { - // get the provider - $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); - if ($provider === null) { - return $this->getBackchannelLogoutErrorResponse( - 'provider not found', - 'The provider was not found in Nextcloud', - ['provider_not_found' => $providerIdentifier] - ); - } - - // decrypt the logout token - $jwks = $this->discoveryService->obtainJWK($provider); - JWT::$leeway = 60; - $logoutTokenPayload = JWT::decode($logout_token, $jwks, array_keys(JWT::$supported_algs)); - - $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); - - // check the audience - if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid audience', - 'The audience of the logout token does not match the provider', - ['invalid_audience' => $logoutTokenPayload->aud] - ); - } - - // check the event attr - if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid event', - 'The backchannel-logout event was not found in the logout token', - ['invalid_event' => true] - ); - } - - // check the nonce attr - if (isset($logoutTokenPayload->nonce)) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid nonce', - 'The logout token should not contain a nonce attribute', - ['nonce_should_not_be_set' => true] - ); - } - - // get the auth token ID associated with the logout token's sid attr - $sid = $logoutTokenPayload->sid; - try { - $oidcSession = $this->sessionMapper->findSessionBySid($sid); - } catch (DoesNotExistException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was not found', - ['session_sid_not_found' => $sid] - ); - } catch (MultipleObjectsReturnedException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SID', - 'The sid of the logout token was found multiple times', - ['multiple_logout_tokens_found' => $sid] - ); - } - - $sub = $logoutTokenPayload->sub; - if ($oidcSession->getSub() !== $sub) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid SUB', - 'The sub does not match the one from the login ID token', - ['invalid_sub' => $sub] - ); - } - $iss = $logoutTokenPayload->iss; - if ($oidcSession->getIss() !== $iss) { - return $this->getBackchannelLogoutErrorResponse( - 'invalid ISS', - 'The iss does not match the one from the login ID token', - ['invalid_iss' => $iss] - ); - } - - // i don't know why but the cast is necessary - $authTokenId = (int)$oidcSession->getAuthtokenId(); - try { - $authToken = $this->authTokenProvider->getTokenById($authTokenId); - // we could also get the auth token by nc session ID - // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); - $userId = $authToken->getUID(); - $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); - } catch (InvalidTokenException $e) { - return $this->getBackchannelLogoutErrorResponse( - 'nc session not found', - 'The authentication session was not found in Nextcloud', - ['nc_auth_session_not_found' => $authTokenId] - ); - } - - // cleanup - $this->sessionMapper->delete($oidcSession); - - return new JSONResponse([], Http::STATUS_OK); - } - - /** - * Generate an error response according to the OIDC standard - * Log the error - * - * @param string $error - * @param string $description - * @param array $throttleMetadata - * @param bool|null $throttle - * @return JSONResponse - */ - private function getBackchannelLogoutErrorResponse( - string $error, - string $description, - array $throttleMetadata = [], - ?bool $throttle = null - ): JSONResponse { - $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); - $response = new JSONResponse( - [ - 'error' => $error, - 'error_description' => $description, - ], - Http::STATUS_BAD_REQUEST, - ); - if (($throttle === null && !$this->isDebugModeEnabled()) || $throttle) { - $response->throttle($throttleMetadata); - } - return $response; - } +class LoginController extends BaseOidcController { + private const STATE = 'oidc.state'; + private const NONCE = 'oidc.nonce'; + public const PROVIDERID = 'oidc.providerid'; + private const REDIRECT_AFTER_LOGIN = 'oidc.redirect'; + private const ID_TOKEN = 'oidc.id_token'; + + /** @var ISecureRandom */ + private $random; + + /** @var ISession */ + private $session; + + /** @var IClientService */ + private $clientService; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var IUserSession */ + private $userSession; + + /** @var IUserManager */ + private $userManager; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var ProviderMapper */ + private $providerMapper; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var ProviderService */ + private $providerService; + + /** @var DiscoveryService */ + private $discoveryService; + + /** @var IConfig */ + private $config; + + /** @var LdapService */ + private $ldapService; + + /** @var IProvider */ + private $authTokenProvider; + + /** @var SessionMapper */ + private $sessionMapper; + + /** @var EventProvisioningService */ + private $eventProvisioningService; + + /** @var ProvisioningService */ + private $provisioningService; + + /** @var IL10N */ + private $l10n; + /** + * @var ICrypto + */ + private $crypto; + + public function __construct( + IRequest $request, + ProviderMapper $providerMapper, + ProviderService $providerService, + DiscoveryService $discoveryService, + LdapService $ldapService, + ISecureRandom $random, + ISession $session, + IClientService $clientService, + IURLGenerator $urlGenerator, + IUserSession $userSession, + IUserManager $userManager, + ITimeFactory $timeFactory, + IEventDispatcher $eventDispatcher, + IConfig $config, + IProvider $authTokenProvider, + SessionMapper $sessionMapper, + EventProvisioningService $eventProvisioningService, + ProvisioningService $provisioningService, + IL10N $l10n, + ILogger $logger, + ICrypto $crypto + ) { + parent::__construct($request, $config); + + $this->random = $random; + $this->session = $session; + $this->clientService = $clientService; + $this->discoveryService = $discoveryService; + $this->urlGenerator = $urlGenerator; + $this->userSession = $userSession; + $this->userManager = $userManager; + $this->timeFactory = $timeFactory; + $this->providerMapper = $providerMapper; + $this->providerService = $providerService; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->config = $config; + $this->ldapService = $ldapService; + $this->authTokenProvider = $authTokenProvider; + $this->sessionMapper = $sessionMapper; + $this->eventProvisioningService = $eventProvisioningService; + $this->provisioningService = $provisioningService; + $this->request = $request; + $this->l10n = $l10n; + $this->crypto = $crypto; + } + + /** + * @return bool + */ + private function isSecure(): bool { + // no restriction in debug mode + return $this->isDebugModeEnabled() || $this->request->getServerProtocol() === 'https'; + } + + /** + * @param bool|null $throttle + * @return TemplateResponse + */ + private function buildProtocolErrorResponse(?bool $throttle = null): TemplateResponse { + $params = [ + 'errors' => [ + ['error' => $this->l10n->t('You must access Nextcloud with HTTPS to use OpenID Connect.')], + ], + ]; + $throttleMetadata = ['reason' => 'insecure connection']; + return $this->buildFailureTemplateResponse('', 'error', $params, Http::STATUS_NOT_FOUND, $throttleMetadata, $throttle); + } + + /** + * @PublicPage + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcLogin) + * + * @param int $providerId + * @param string|null $redirectUrl + * @return DataDisplayResponse|RedirectResponse|TemplateResponse + */ + public function login(int $providerId, string $redirectUrl = null) { + if ($this->userSession->isLoggedIn()) { + return new RedirectResponse($redirectUrl); + } + if (!$this->isSecure()) { + return $this->buildProtocolErrorResponse(); + } + $this->logger->debug('Initiating login for provider with id: ' . $providerId); + + try { + $provider = $this->providerMapper->getProvider($providerId); + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + $message = $this->l10n->t('There is not such OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_not_found' => $providerId]); + } + + $state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::STATE, $state); + $this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl); + + $nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER); + $this->session->set(self::NONCE, $nonce); + + $this->session->set(self::PROVIDERID, $providerId); + $this->session->close(); + + // get attribute mapping settings + $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); + $displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name'); + $quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota'); + $groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups'); + + $claims = [ + // more details about requesting claims: + // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests + 'id_token' => [ + // ['essential' => true] means it's mandatory but it won't trigger an error if it's not there + // null means we want it + $emailAttribute => null, + $displaynameAttribute => null, + $quotaAttribute => null, + $groupsAttribute => null, + ], + 'userinfo' => [ + $emailAttribute => null, + $displaynameAttribute => null, + $quotaAttribute => null, + $groupsAttribute => null, + ], + ]; + + if ($uidAttribute !== 'sub') { + $claims['id_token'][$uidAttribute] = ['essential' => true]; + $claims['userinfo'][$uidAttribute] = ['essential' => true]; + } + + $extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, ''); + if ($extraClaimsString) { + $extraClaims = explode(' ', $extraClaimsString); + foreach ($extraClaims as $extraClaim) { + $claims['id_token'][$extraClaim] = null; + $claims['userinfo'][$extraClaim] = null; + } + } + + $data = [ + 'client_id' => $provider->getClientId(), + 'response_type' => 'code', + 'scope' => trim($provider->getScope()), + 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), + 'claims' => json_encode($claims), + 'state' => $state, + 'nonce' => $nonce, + ]; + // pass discovery query parameters also on to the authentication + $discoveryUrl = parse_url($provider->getDiscoveryEndpoint()); + if (isset($discoveryUrl['query'])) { + $this->logger->debug('Add custom discovery query: ' . $discoveryUrl['query']); + $discoveryQuery = []; + parse_str($discoveryUrl['query'], $discoveryQuery); + $data += $discoveryQuery; + } + + try { + $discovery = $this->discoveryService->obtainDiscovery($provider); + } catch (\Exception $e) { + $this->logger->error('Could not reach the provider at URL ' . $provider->getDiscoveryEndpoint(), ['exception' => $e]); + $message = $this->l10n->t('Could not reach the OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['reason' => 'provider unreachable']); + } + + $authorizationUrl = $this->discoveryService->buildAuthorizationUrl($discovery['authorization_endpoint'], $data); + + $this->logger->debug('Redirecting user to: ' . $authorizationUrl); + + // Workaround to avoid empty session on special conditions in Safari + // https://github.com/nextcloud/user_oidc/pull/358 + if ($this->request->isUserAgent(['/Safari/']) && !$this->request->isUserAgent(['/Chrome/'])) { + return new DataDisplayResponse(''); + } + + return new RedirectResponse($authorizationUrl); + } + + /** + * @PublicPage + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcCode) + * + * @param string $state + * @param string $code + * @param string $scope + * @param string $error + * @param string $error_description + * @return JSONResponse|RedirectResponse|TemplateResponse + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws SessionNotAvailableException + * @throws \JsonException + */ + public function code(string $state = '', string $code = '', string $scope = '', string $error = '', string $error_description = '') { + if (!$this->isSecure()) { + return $this->buildProtocolErrorResponse(); + } + $this->logger->debug('Code login with core: ' . $code . ' and state: ' . $state); + + if ($error !== '') { + return new JSONResponse([ + 'error' => $error, + 'error_description' => $error_description, + ], Http::STATUS_FORBIDDEN); + } + + if ($this->session->get(self::STATE) !== $state) { + $this->logger->debug('state does not match'); + + $message = $this->l10n->t('The received state does not match the expected value.'); + if ($this->isDebugModeEnabled()) { + $responseData = [ + 'error' => 'invalid_state', + 'error_description' => $message, + 'got' => $state, + 'expected' => $this->session->get(self::STATE), + ]; + return new JSONResponse($responseData, Http::STATUS_FORBIDDEN); + } + // we know debug mode is off, always throttle + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true); + } + + $providerId = (int)$this->session->get(self::PROVIDERID); + $provider = $this->providerMapper->getProvider($providerId); + try { + $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret()); + } catch (\Exception $e) { + $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]); + $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false); + } + + $discovery = $this->discoveryService->obtainDiscovery($provider); + + $this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']); + + $client = $this->clientService->newClient(); + try { + $result = $client->post( + $discovery['token_endpoint'], + [ + 'body' => [ + 'code' => $code, + 'client_id' => $provider->getClientId(), + 'client_secret' => $providerClientSecret, + 'redirect_uri' => $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.login.code'), + 'grant_type' => 'authorization_code', + ], + ] + ); + } catch (ClientException | ServerException $e) { + $response = $e->getResponse(); + $body = (string) $response->getBody(); + $responseBodyArray = json_decode($body, true); + if ($responseBodyArray !== null && isset($responseBodyArray['error'], $responseBodyArray['error_description'])) { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', [ + 'exception' => $e, + 'error' => $responseBodyArray['error'], + 'error_description' => $responseBodyArray['error_description'], + ]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint') . ': ' . $responseBodyArray['error_description']; + } else { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); + } + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); + } catch (\Exception $e) { + $this->logger->debug('Failed to contact the OIDC provider token endpoint', ['exception' => $e]); + $message = $this->l10n->t('Failed to contact the OIDC provider token endpoint'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false); + } + + $data = json_decode($result->getBody(), true); + $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); + $this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery)); + + // TODO: proper error handling + $idTokenRaw = $data['id_token']; + $jwks = $this->discoveryService->obtainJWK($provider); + JWT::$leeway = 60; + $idTokenPayload = JWT::decode($idTokenRaw, $jwks, array_keys(JWT::$supported_algs)); + + $this->logger->debug('Parsed the JWT payload: ' . json_encode($idTokenPayload, JSON_THROW_ON_ERROR)); + + if ($idTokenPayload->exp < $this->timeFactory->getTime()) { + $this->logger->debug('Token expired'); + $message = $this->l10n->t('The received token is expired.'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'token expired']); + } + + // Verify issuer + if ($idTokenPayload->iss !== $discovery['issuer']) { + $this->logger->debug('This token is issued by the wrong issuer'); + $message = $this->l10n->t('The issuer does not match the one from the discovery endpoint'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_issuer' => $idTokenPayload->iss]); + } + + // Verify audience + if (!($idTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $idTokenPayload->aud, true))) { + $this->logger->debug('This token is not for us'); + $message = $this->l10n->t('The audience does not match ours'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_audience' => $idTokenPayload->aud]); + } + + // If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. + // If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. + if (is_array($idTokenPayload->aud) && count($idTokenPayload->aud) > 1) { + if (isset($idTokenPayload->azp)) { + if ($idTokenPayload->azp !== $provider->getClientId()) { + $this->logger->debug('This token is not for us, authorized party (azp) is different than the client ID'); + $message = $this->l10n->t('The authorized party does not match ours'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['invalid_azp' => $idTokenPayload->azp]); + } + } else { + $this->logger->debug('Multiple audiences but no authorized party (azp) in the id token'); + $message = $this->l10n->t('No authorized party'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['missing_azp']); + } + } + + if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) { + $this->logger->debug('Nonce does not match'); + $message = $this->l10n->t('The nonce does not match'); + return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']); + } + + // get user ID attribute + $uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub'); + $userId = $idTokenPayload->{$uidAttribute} ?? null; + if ($userId === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); + } + + $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); + $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); + + // Provisioning + if ($eventProvisionAllowed) { + // for the moment, make event provisioning another (prio) config option + // TODO: (proposal) refactor all provisioning strategies into event handlers + try { + $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } catch (ProvisioningDeniedException $denied) { + $redirectUrl = $denied->getRedirectUrl(); + if ($redirectUrl === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); + } else { + // error response is a redirect, e.g. to a booking site + // so that you can immediately get the registration page + return new RedirectResponse($redirectUrl); + } + } catch (\Exception $e) { + $user = null; + } + } elseif ($autoProvisionAllowed) { + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); + } else { + // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results + // so new users will be directly available even if they were not synced before this login attempt + $this->userManager->search($userId); + $this->ldapService->syncUser($userId); + // when auto provision is disabled, we assume the user has been created by another user backend (or manually) + $user = $this->userManager->get($userId); + if ($this->ldapService->isLdapDeletedUser($user)) { + $user = null; + } + } + + if ($user === null) { + $message = $this->l10n->t('Failed to provision the user'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']); + } + + $this->session->set(self::ID_TOKEN, $idTokenRaw); + + $this->logger->debug('Logging user in'); + + $this->userSession->setUser($user); + $this->userSession->completeLogin($user, ['loginName' => $user->getUID(), 'password' => '']); + $this->userSession->createSessionToken($this->request, $user->getUID(), $user->getUID()); + $this->userSession->createRememberMeToken($user); + + // for backchannel logout + try { + $authToken = $this->authTokenProvider->getToken($this->session->getId()); + $this->sessionMapper->createSession( + $idTokenPayload->sid ?? 'fallback-sid', + $idTokenPayload->sub ?? 'fallback-sub', + $idTokenPayload->iss ?? 'fallback-iss', + $authToken->getId(), + $this->session->getId() + ); + } catch (InvalidTokenException $e) { + $this->logger->debug('Auth token not found after login'); + } + + // if the user was provisioned by user_ldap, this is required to update and/or generate the avatar + if ($user->canChangeAvatar()) { + $this->logger->debug('$user->canChangeAvatar() is true'); + } + + $this->logger->debug('Redirecting user'); + + $redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN); + if ($redirectUrl) { + return new RedirectResponse($redirectUrl); + } + + return new RedirectResponse(\OC_Util::getDefaultPageUrl()); + } + + /** + * Endpoint called by NC to logout in the IdP before killing the current session + * + * @NoAdminRequired + * @NoCSRFRequired + * @UseSession + * @BruteForceProtection(action=userOidcSingleLogout) + * + * @return RedirectResponse|TemplateResponse + * @throws Exception + * @throws SessionNotAvailableException + * @throws \JsonException + */ + public function singleLogoutService() { + // TODO throttle in all failing cases + $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + $targetUrl = $this->urlGenerator->getAbsoluteURL('/'); + if (!isset($oidcSystemConfig['single_logout']) || $oidcSystemConfig['single_logout']) { + $providerId = $this->session->get(self::PROVIDERID); + if ($providerId) { + try { + $provider = $this->providerMapper->getProvider((int)$providerId); + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + $message = $this->l10n->t('There is not such OpenID Connect provider.'); + return $this->buildErrorTemplateResponse($message, Http::STATUS_NOT_FOUND, ['provider_id' => $providerId]); + } + $endSessionEndpoint = $this->discoveryService->obtainDiscovery($provider)['end_session_endpoint']; + if ($endSessionEndpoint) { + $endSessionEndpoint .= '?post_logout_redirect_uri=' . $targetUrl; + $endSessionEndpoint .= '&client_id=' . $provider->getClientId(); + $shouldSendIdToken = $this->providerService->getSetting( + $provider->getId(), + ProviderService::SETTING_SEND_ID_TOKEN_HINT, + '0' + ) === '1'; + $idToken = $this->session->get(self::ID_TOKEN); + if ($shouldSendIdToken && $idToken) { + $endSessionEndpoint .= '&id_token_hint=' . $idToken; + } + $targetUrl = $endSessionEndpoint; + } + } + } + + // cleanup related oidc session + $this->sessionMapper->deleteFromNcSessionId($this->session->getId()); + + $this->userSession->logout(); + + // make sure we clear the session to avoid messing with Backend::isSessionActive + $this->session->clear(); + return new RedirectResponse($targetUrl); + } + + /** + * Endpoint called by the IdP (OP) when end_session_endpoint is called by another client + * The logout token contains the sid for which we know the sessionId + * which leads to the auth token that we can invalidate + * Implemented according to https://openid.net/specs/openid-connect-backchannel-1_0.html + * + * @PublicPage + * @NoCSRFRequired + * @BruteForceProtection(action=userOidcBackchannelLogout) + * + * @param string $providerIdentifier + * @param string $logout_token + * @return JSONResponse + * @throws Exception + * @throws \JsonException + */ + public function backChannelLogout(string $providerIdentifier, string $logout_token = ''): JSONResponse { + // get the provider + $provider = $this->providerService->getProviderByIdentifier($providerIdentifier); + if ($provider === null) { + return $this->getBackchannelLogoutErrorResponse( + 'provider not found', + 'The provider was not found in Nextcloud', + ['provider_not_found' => $providerIdentifier] + ); + } + + // decrypt the logout token + $jwks = $this->discoveryService->obtainJWK($provider); + JWT::$leeway = 60; + $logoutTokenPayload = JWT::decode($logout_token, $jwks, array_keys(JWT::$supported_algs)); + + $this->logger->debug('Parsed the logout JWT payload: ' . json_encode($logoutTokenPayload, JSON_THROW_ON_ERROR)); + + // check the audience + if (!(($logoutTokenPayload->aud === $provider->getClientId() || in_array($provider->getClientId(), $logoutTokenPayload->aud, true)))) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid audience', + 'The audience of the logout token does not match the provider', + ['invalid_audience' => $logoutTokenPayload->aud] + ); + } + + // check the event attr + if (!isset($logoutTokenPayload->events->{'http://schemas.openid.net/event/backchannel-logout'})) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid event', + 'The backchannel-logout event was not found in the logout token', + ['invalid_event' => true] + ); + } + + // check the nonce attr + if (isset($logoutTokenPayload->nonce)) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid nonce', + 'The logout token should not contain a nonce attribute', + ['nonce_should_not_be_set' => true] + ); + } + + // get the auth token ID associated with the logout token's sid attr + $sid = $logoutTokenPayload->sid; + try { + $oidcSession = $this->sessionMapper->findSessionBySid($sid); + } catch (DoesNotExistException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was not found', + ['session_sid_not_found' => $sid] + ); + } catch (MultipleObjectsReturnedException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SID', + 'The sid of the logout token was found multiple times', + ['multiple_logout_tokens_found' => $sid] + ); + } + + $sub = $logoutTokenPayload->sub; + if ($oidcSession->getSub() !== $sub) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid SUB', + 'The sub does not match the one from the login ID token', + ['invalid_sub' => $sub] + ); + } + $iss = $logoutTokenPayload->iss; + if ($oidcSession->getIss() !== $iss) { + return $this->getBackchannelLogoutErrorResponse( + 'invalid ISS', + 'The iss does not match the one from the login ID token', + ['invalid_iss' => $iss] + ); + } + + // i don't know why but the cast is necessary + $authTokenId = (int)$oidcSession->getAuthtokenId(); + try { + $authToken = $this->authTokenProvider->getTokenById($authTokenId); + // we could also get the auth token by nc session ID + // $authToken = $this->authTokenProvider->getToken($oidcSession->getNcSessionId()); + $userId = $authToken->getUID(); + $this->authTokenProvider->invalidateTokenById($userId, $authToken->getId()); + } catch (InvalidTokenException $e) { + return $this->getBackchannelLogoutErrorResponse( + 'nc session not found', + 'The authentication session was not found in Nextcloud', + ['nc_auth_session_not_found' => $authTokenId] + ); + } + + // cleanup + $this->sessionMapper->delete($oidcSession); + + return new JSONResponse([], Http::STATUS_OK); + } + + /** + * Generate an error response according to the OIDC standard + * Log the error + * + * @param string $error + * @param string $description + * @param array $throttleMetadata + * @param bool|null $throttle + * @return JSONResponse + */ + private function getBackchannelLogoutErrorResponse( + string $error, + string $description, + array $throttleMetadata = [], + ?bool $throttle = null + ): JSONResponse { + $this->logger->debug('Backchannel logout error. ' . $error . ' ; ' . $description); + $response = new JSONResponse( + [ + 'error' => $error, + 'error_description' => $description, + ], + Http::STATUS_BAD_REQUEST, + ); + if (($throttle === null && !$this->isDebugModeEnabled()) || $throttle) { + $response->throttle($throttleMetadata); + } + return $response; + } } diff --git a/lib/Event/UserAccountChangeEvent.php b/lib/Event/UserAccountChangeEvent.php index c2e1f6f0..c6b698aa 100644 --- a/lib/Event/UserAccountChangeEvent.php +++ b/lib/Event/UserAccountChangeEvent.php @@ -14,86 +14,74 @@ use OCP\EventDispatcher\Event; -use OCA\UserOIDC\Event\UserAccountChangeResult; - /** * Event to provide custom mapping logic based on the OIDC token data * In order to avoid further processing the event propagation should be stopped * in the listener after processing as the value might get overwritten afterwards * by other listeners through $event->stopPropagation(); */ -class UserAccountChangeEvent extends Event -{ - - private $uid; - private $displayname; - private $mainEmail; - private $quota; - private $claims; - private $result; +class UserAccountChangeEvent extends Event { + private $uid; + private $displayname; + private $mainEmail; + private $quota; + private $claims; + private $result; - public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) - { - parent::__construct(); - $this->uid = $uid; - $this->displayname = $displayname; - $this->mainEmail = $mainEmail; - $this->quota = $quota; - $this->claims = $claims; - $this->result = new UserAccountChangeResult($accessAllowed, 'default'); - } + public function __construct(string $uid, ?string $displayname, ?string $mainEmail, ?string $quota, object $claims, bool $accessAllowed = false) { + parent::__construct(); + $this->uid = $uid; + $this->displayname = $displayname; + $this->mainEmail = $mainEmail; + $this->quota = $quota; + $this->claims = $claims; + $this->result = new UserAccountChangeResult($accessAllowed, 'default'); + } - /** - * @return get event username (uid) - */ - public function getUid(): string - { - return $this->uid; - } + /** + * @return get event username (uid) + */ + public function getUid(): string { + return $this->uid; + } - /** - * @return get event displayname - */ - public function getDisplayName(): ?string - { - return $this->displayname; - } + /** + * @return get event displayname + */ + public function getDisplayName(): ?string { + return $this->displayname; + } - /** - * @return get event main email - */ - public function getMainEmail(): ?string - { - return $this->mainEmail; - } + /** + * @return get event main email + */ + public function getMainEmail(): ?string { + return $this->mainEmail; + } - /** - * @return get event quota - */ - public function getQuota(): ?string - { - return $this->quota; - } + /** + * @return get event quota + */ + public function getQuota(): ?string { + return $this->quota; + } - /** - * @return array the array of claim values associated with the event - */ - public function getClaims(): object - { - return $this->claims; - } + /** + * @return array the array of claim values associated with the event + */ + public function getClaims(): object { + return $this->claims; + } - /** - * @return value for the logged in user attribute - */ - public function getResult(): UserAccountChangeResult - { - return $this->result; - } + /** + * @return value for the logged in user attribute + */ + 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); - } + 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 index 8a2c961f..660e78f9 100644 --- a/lib/Event/UserAccountChangeResult.php +++ b/lib/Event/UserAccountChangeResult.php @@ -18,65 +18,57 @@ * in the listener after processing as the value might get overwritten afterwards * by other listeners through $event->stopPropagation(); */ -class UserAccountChangeResult -{ +class UserAccountChangeResult { - /** @var bool */ - private $accessAllowed; - /** @var string */ - private $reason; - /** @var string */ - private $redirectUrl; + /** @var bool */ + private $accessAllowed; + /** @var string */ + private $reason; + /** @var string */ + private $redirectUrl; - public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) - { - $this->accessAllowed = $accessAllowed; - $this->redirectUrl = $redirectUrl; - $this->reason = $reason; - } + public function __construct(bool $accessAllowed, string $reason = '', ?string $redirectUrl = null) { + $this->accessAllowed = $accessAllowed; + $this->redirectUrl = $redirectUrl; + $this->reason = $reason; + } - /** - * @return value for the logged in user attribute - */ - public function isAccessAllowed(): bool - { - return $this->accessAllowed; - } + /** + * @return value for the logged in user attribute + */ + public function isAccessAllowed(): bool { + return $this->accessAllowed; + } - public function setAccessAllowed(bool $accessAllowed): void - { - $this->accessAllowed = $accessAllowed; - } + public function setAccessAllowed(bool $accessAllowed): void { + $this->accessAllowed = $accessAllowed; + } - /** - * @return get optional alternate redirect address - */ - public function getRedirectUrl(): ?string - { - return $this->redirectUrl; - } + /** + * @return get optional alternate redirect address + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } - /** - * @return set optional alternate redirect address - */ - public function setRedirectUrl(?string $redirectUrl): void - { - $this->redirectUrl = $redirectUrl; - } + /** + * @return set optional alternate redirect address + */ + public function setRedirectUrl(?string $redirectUrl): void { + $this->redirectUrl = $redirectUrl; + } - /** - * @return get decision reason - */ - public function getReason(): string - { - return $this->reason; - } + /** + * @return get decision reason + */ + public function getReason(): string { + return $this->reason; + } - /** - * @return set decision reason - */ - public function setReason(string $reason): void - { - $this->reason = $reason; - } + /** + * @return set decision reason + */ + public function setReason(string $reason): void { + $this->reason = $reason; + } } diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php index 6d689e67..4ebf8fd9 100644 --- a/lib/Service/ProvisioningDeniedException.php +++ b/lib/Service/ProvisioningDeniedException.php @@ -29,48 +29,41 @@ * Exception if the precondition of the config update method isn't met * @since 1.4.0 */ -class ProvisioningDeniedException extends \Exception -{ +class ProvisioningDeniedException extends \Exception { + private $redirectUrl; - private $redirectUrl; - - /** - * Exception constructor including an option redirect url. - * - * @param string $message The error message. It will be not revealed to the - * the user (unless the hint is empty) and thus - * should be not translated. - * @param string $hint A useful message that is presented to the end - * user. It should be translated, but must not - * contain sensitive data. - * @param int $code Set default to 403 (Forbidden) - * @param \Exception|null $previous - */ - public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) - { - parent::__construct($message, $code, $previous); - $this->redirectUrl = $redirectUrl; - } - - /** - * Read optional failure redirect if available - * @return string|null - */ - public function getRedirectUrl(): ?string - { - return $this->redirectUrl; - } - - /** - * Include redirect in string serialisation. - * - * @return string - */ - public function __toString(): string - { - $redirect = $this->redirectUrl ?? ''; - return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; - } + /** + * Exception constructor including an option redirect url. + * + * @param string $message The error message. It will be not revealed to the + * the user (unless the hint is empty) and thus + * should be not translated. + * @param string $hint A useful message that is presented to the end + * user. It should be translated, but must not + * contain sensitive data. + * @param int $code Set default to 403 (Forbidden) + * @param \Exception|null $previous + */ + public function __construct(string $message, ?string $redirectUrl = null, int $code = 403, \Exception $previous = null) { + parent::__construct($message, $code, $previous); + $this->redirectUrl = $redirectUrl; + } + /** + * Read optional failure redirect if available + * @return string|null + */ + public function getRedirectUrl(): ?string { + return $this->redirectUrl; + } + /** + * Include redirect in string serialisation. + * + * @return string + */ + public function __toString(): string { + $redirect = $this->redirectUrl ?? ''; + return __CLASS__ . ": [{$this->code}]: {$this->message} ({$redirect})\n"; + } } diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index 17e91692..4ba15e8b 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -25,134 +25,124 @@ namespace OCA\UserOIDC\Service; -use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCA\UserOIDC\Event\UserAccountChangeEvent; -use OCA\UserOIDC\Db\Provider; use OCA\UserOIDC\Db\UserMapper; use OCP\EventDispatcher\IEventDispatcher; use OCP\ILogger; use OCP\IUserManager; -class ProvisioningEventService -{ - - /** @var IEventDispatcher */ - private $eventDispatcher; - - /** @var ILogger */ - private $logger; - - /** @var UserMapper */ - private $userMapper; - - /** @var IUserManager */ - private $userManager; - - /** @var ProviderService */ - private $providerService; - - public function __construct( - IEventDispatcher $eventDispatcher, - ILogger $logger, - UserMapper $userMapper, - IUserManager $userManager, - ProviderService $providerService - ) { - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->userMapper = $userMapper; - $this->userManager = $userManager; - $this->providerService = $providerService; - } - - protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) - { - $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); - $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); - $this->eventDispatcher->dispatchTyped($event); - return $event->getValue(); - } - - protected function mapDispatchDisplayname(int $providerid, object $payload) - { - $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); - $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; - - if (isset($mappedDisplayName)) { - $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); - } else { - $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); - } - $this->eventDispatcher->dispatchTyped($event); - return $event->getValue(); - } - - protected function mapDispatchEmail(int $providerid, object $payload) - { - $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); - return $event->getValue(); - } - - protected function mapDispatchQuota(int $providerid, object $payload) - { - $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); - return $event->getValue(); - } - - protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) - { - $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); - $this->eventDispatcher->dispatchTyped($event); - return $event->getResult(); - } - - /** - * Trigger a provisioning via event system. - * This allows to flexibly implement complex provisioning strategies - - * even in a separate app. - * - * On error, the provisioning logic can deliver failure reasons and - * even a redirect to a different endpoint. - * - * @param string $tokenUserId - * @param int $providerId - * @param object $idTokenPayload - * @return IUser|null - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws ProvisioningDeniedException - */ - public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser - { - try { - $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); - $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); - $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); - $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); - } catch (AttributeValueException $eAttribute) { - $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); - throw new ProvisioningDeniedException("Problems with user information."); - } - - $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); - if ($userReaction->isAccessAllowed()) { - $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); - $user = $this->userManager->get($uid); - return $user; - } else { - $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); - throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); - } - } - +class ProvisioningEventService { + + /** @var IEventDispatcher */ + private $eventDispatcher; + + /** @var ILogger */ + private $logger; + + /** @var UserMapper */ + private $userMapper; + + /** @var IUserManager */ + private $userManager; + + /** @var ProviderService */ + private $providerService; + + public function __construct( + IEventDispatcher $eventDispatcher, + ILogger $logger, + UserMapper $userMapper, + IUserManager $userManager, + ProviderService $providerService + ) { + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + $this->userMapper = $userMapper; + $this->userManager = $userManager; + $this->providerService = $providerService; + } + + protected function mapDispatchUID(int $providerid, object $payload, string $tokenUserId) { + $uidAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_UID, 'sub'); + $mappedUserId = $payload->{$uidAttribute} ?? $tokenUserId; + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $payload, $mappedUserId); + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchDisplayname(int $providerid, object $payload) { + $displaynameAttribute = $this->providerService->getSetting($providerid, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'displayname'); + $mappedDisplayName = $payload->{$displaynameAttribute} ?? null; + + if (isset($mappedDisplayName)) { + $limitedDisplayName = mb_substr($mappedDisplayName, 0, 255); + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload, $limitedDisplayName); + } else { + $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_DISPLAYNAME, $payload); + } + $this->eventDispatcher->dispatchTyped($event); + return $event->getValue(); + } + + protected function mapDispatchEmail(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function mapDispatchQuota(int $providerid, object $payload) { + $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); + return $event->getValue(); + } + + protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, ?string $email, ?string $quota, object $payload) { + $event = new UserAccountChangeEvent($uid, $displayName, $email, $quota, $payload); + $this->eventDispatcher->dispatchTyped($event); + return $event->getResult(); + } + + /** + * Trigger a provisioning via event system. + * This allows to flexibly implement complex provisioning strategies - + * even in a separate app. + * + * On error, the provisioning logic can deliver failure reasons and + * even a redirect to a different endpoint. + * + * @param string $tokenUserId + * @param int $providerId + * @param object $idTokenPayload + * @return IUser|null + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws ProvisioningDeniedException + */ + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { + try { + $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); + } catch (AttributeValueException $eAttribute) { + $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException("Problems with user information."); + } + + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); + if ($userReaction->isAccessAllowed()) { + $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); + $user = $this->userManager->get($uid); + return $user; + } else { + $this->logger->info("{$uid}: account rejected, reason: " . $userReaction->getReason()); + throw new ProvisioningDeniedException($userReaction->getReason(), $userReaction->getRedirectUrl()); + } + } } diff --git a/tests/unit/Service/EventProvisioningServiceTest.php b/tests/unit/Service/EventProvisioningServiceTest.php index 3b2a8c6d..a470decd 100644 --- a/tests/unit/Service/EventProvisioningServiceTest.php +++ b/tests/unit/Service/EventProvisioningServiceTest.php @@ -24,8 +24,6 @@ declare(strict_types=1); -use OCP\ILogger; -use OCP\ICacheFactory; use OCP\Http\Client\IClientService; use OCP\Http\Client\IClient; use OCP\Http\Client\IResponse; @@ -34,15 +32,14 @@ use OCA\UserOIDC\AppInfo\Application; use OCA\UserOIDC\Db\Provider; -use OCA\UserOIDC\Service\EventProvisioningService; use PHPUnit\Framework\TestCase; -class DiscoveryServiceTest extends TestCase { +class EventProvisioningServiceTest extends TestCase { public function setUp(): void { parent::setUp(); $this->app = new App(Application::APP_ID); - + $this->provider = $this->getMockBuilder(Provider::class) ->addMethods(['getDiscoveryEndpoint']) ->getMock(); @@ -55,33 +52,32 @@ public function setUp(): void { } public function testUidMapped() { - } + } public function testUidNotMapped() { - } + } - public function testDisplaynameMapped() { - } + public function testDisplaynameMapped() { + } public function testDisplaynameNotMapped() { - } + } - public function testQuotaMapped() { - } + public function testQuotaMapped() { + } public function testQuotaNotMapped() { - } + } public function testMappingProblem() { - } + } public function testSuccess() { - } - - public function testDenied() { - } + } - public function testDeniedRedirect() { - } + public function testDenied() { + } + public function testDeniedRedirect() { + } } From b74d53d85e5b37cace5a01c3d14bb0ce6631e80b Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 13:30:13 +0200 Subject: [PATCH 04/10] Clean up final redesign version including tests --- lib/Controller/LoginController.php | 17 +- lib/Service/ProvisioningEventService.php | 47 +- .../unit/MagentaCloud/OpenidTokenTestCase.php | 141 ++++++ .../ProvisioningEventServiceTest.php | 460 ++++++++++++++++++ tests/unit/MagentaCloud/RegistrationsTest.php | 33 ++ 5 files changed, 670 insertions(+), 28 deletions(-) create mode 100644 tests/unit/MagentaCloud/OpenidTokenTestCase.php create mode 100644 tests/unit/MagentaCloud/ProvisioningEventServiceTest.php create mode 100644 tests/unit/MagentaCloud/RegistrationsTest.php diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 3b30e70c..2792dbae 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -150,7 +150,6 @@ public function __construct( IConfig $config, IProvider $authTokenProvider, SessionMapper $sessionMapper, - EventProvisioningService $eventProvisioningService, ProvisioningService $provisioningService, IL10N $l10n, ILogger $logger, @@ -174,7 +173,6 @@ public function __construct( $this->ldapService = $ldapService; $this->authTokenProvider = $authTokenProvider; $this->sessionMapper = $sessionMapper; - $this->eventProvisioningService = $eventProvisioningService; $this->provisioningService = $provisioningService; $this->request = $request; $this->l10n = $l10n; @@ -478,30 +476,27 @@ public function code(string $state = '', string $code = '', string $scope = '', } $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); - $eventProvisionAllowed = (!isset($oidcSystemConfig['event_provision']) || $oidcSystemConfig['event_provision']); $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); // Provisioning - if ($eventProvisionAllowed) { - // for the moment, make event provisioning another (prio) config option + if ($autoProvisionAllowed) { // TODO: (proposal) refactor all provisioning strategies into event handlers + $user = null; try { - $user = $this->eventProvisioningService->provisionUser($userId, $providerId, $idTokenPayload); + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); } catch (ProvisioningDeniedException $denied) { + // TODO MagentaCLOUD should upstream the exception handling $redirectUrl = $denied->getRedirectUrl(); if ($redirectUrl === null) { - $message = $this->l10n->t('Failed to provision the user'); + $message = $this->l10n->t('Failed to provision user'); return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => $denied->getMessage()]); } else { // error response is a redirect, e.g. to a booking site // so that you can immediately get the registration page return new RedirectResponse($redirectUrl); } - } catch (\Exception $e) { - $user = null; } - } elseif ($autoProvisionAllowed) { - $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload); + // no default exception handling to pass on unittest assertion failures } else { // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results // so new users will be directly available even if they were not synced before this login attempt diff --git a/lib/Service/ProvisioningEventService.php b/lib/Service/ProvisioningEventService.php index 4ba15e8b..9074f1e1 100644 --- a/lib/Service/ProvisioningEventService.php +++ b/lib/Service/ProvisioningEventService.php @@ -25,14 +25,20 @@ namespace OCA\UserOIDC\Service; +use OCA\UserOIDC\Db\UserMapper; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCA\UserOIDC\Event\UserAccountChangeEvent; -use OCA\UserOIDC\Db\UserMapper; +use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IGroupManager; use OCP\ILogger; +use OCP\IUser; use OCP\IUserManager; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; -class ProvisioningEventService { +// FIXME there should be an interface for both variations +class ProvisioningEventService extends ProvisioningService { /** @var IEventDispatcher */ private $eventDispatcher; @@ -40,9 +46,6 @@ class ProvisioningEventService { /** @var ILogger */ private $logger; - /** @var UserMapper */ - private $userMapper; - /** @var IUserManager */ private $userManager; @@ -50,15 +53,23 @@ class ProvisioningEventService { private $providerService; public function __construct( + LocalIdService $idService, + ProviderService $providerService, + UserMapper $userMapper, + IUserManager $userManager, + IGroupManager $groupManager, IEventDispatcher $eventDispatcher, - ILogger $logger, - UserMapper $userMapper, - IUserManager $userManager, - ProviderService $providerService + ILogger $logger ) { + parent::__construct($idService, + $providerService, + $userMapper, + $userManager, + $groupManager, + $eventDispatcher, + $logger); $this->eventDispatcher = $eventDispatcher; $this->logger = $logger; - $this->userMapper = $userMapper; $this->userManager = $userManager; $this->providerService = $providerService; } @@ -126,16 +137,18 @@ protected function dispatchUserAccountUpdate(string $uid, ?string $displayName, */ public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { try { - $uid = $this->userService->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); - $displayname = $this->userService->mapDispatchDisplayname($providerId, $idTokenPayload); - $email = $this->userService->mapDispatchEmail($providerId, $idTokenPayload); - $quota = $this->userService->mapDispatchQuota($providerId, $idTokenPayload); + // for multiple reasons, it is better to take the uid directly from a token field + //$uid = $this->mapDispatchUID($providerId, $idTokenPayload, $tokenUserId); + $uid = $tokenUserId; + $displayname = $this->mapDispatchDisplayname($providerId, $idTokenPayload); + $email = $this->mapDispatchEmail($providerId, $idTokenPayload); + $quota = $this->mapDispatchQuota($providerId, $idTokenPayload); } catch (AttributeValueException $eAttribute) { - $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $userReaction->getReason()); - throw new ProvisioningDeniedException("Problems with user information."); + $this->logger->info("{$uid}: user rejected by OpenId web authorization, reason: " . $eAttribute->getMessage()); + throw new ProvisioningDeniedException($eAttribute->getMessage()); } - $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $payload); + $userReaction = $this->dispatchUserAccountUpdate($uid, $displayname, $email, $quota, $idTokenPayload); if ($userReaction->isAccessAllowed()) { $this->logger->info("{$uid}: account accepted, reason: " . $userReaction->getReason()); $user = $this->userManager->get($uid); diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php new file mode 100644 index 00000000..cbaa9847 --- /dev/null +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -0,0 +1,141 @@ +realOidClaims; + } + + public function getOidClientId() { + return "USER_NC_OPENID_TEST"; + } + + public function getOidNonce() { + return "CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K"; + } + + public function getOidClientSecret() { + return \Base64Url\Base64Url::encode('JQ17C99A-DAF8-4E27-FBW4-GV23B043C993'); + } + + public function getOidServerKey() { + return \Base64Url\Base64Url::encode('JQ17DAF8-C99A-4E27-FBW4-GV23B043C993'); + } + + public function getOidPrivateServerKey() { + 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() { + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWK::parseKeySet([ "keys" => [[ + "kty" => "RSA", + "e" => "AQAB", + "use" => "sig", + "kid" => "0123456789", + "alg" => "RS256", + "n" => "6WCdDo8KuksEFaFlzvmsaoYhfOoMt5XgnX98dx-F1OUz53SG0lQlFt-xkwra5B4GZ-13lki0qCa2CjA1aLa9kEvDdYhz_0Uc5qOy5haDj8Jn547s6gFyaLzJ0RN5i5eKeDMHcjeEC0_NjiB2UNUFJJ61b2nXIlUvp_vBfKCv4A-8C3mLSbCKJQhX84QRDgt_Abz0MXj_ga72Ka2cwVLo4OFQAK5m57Qfu9ZvseMcgoinyhIQ18b98SkWinn3DM0W1KXLkWLk0S3XEMxLV1M7-9RLo4fgEGOpX1xmmM6KbsC5SxXvRUO7tjU-o35fcewDwXYHnRbxqhRkEFfWb7b8nQ" + ]]]); + } + + public function getOidTestCode() { + return "66844608"; + } + + public function getOidTestState() { + return "4VSL5T274MJEMLZI1810HUFDA07CEPXZ"; + } + + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->realOidClaims = array( + "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" => time(), + "exp" => time() + 7200, + 'iat' => time(), + "urn:custom.com:session_token" => "ad0fff71-e013-11ec-9e17-39677d2c891c", + "nonce" => "CVMI8I3JZPALSL5DIM6I1PDP8SDSEN4K", + "aud" => array("USER_NC_OPENID_TEST") ); + } + + protected function createSignToken(array $claims) : string { + // The algorithm manager with the HS256 algorithm. + $algorithmManager = new AlgorithmManager([ + new RS256(), + ]); + + // use a different key for an invalid signature + $jwk = new JWK($this->getOidPrivateServerKey()); + $jwsBuilder = new JWSBuilder($algorithmManager); + $jws = $jwsBuilder->create() // We want to create a new JWS + ->withPayload(json_encode($claims)) // We set the payload + ->addSignature($jwk, ['alg' => 'RS256', "kid" => "0123456789"]) // We add a signature with a simple protected header + ->build(); + + $serializer = new CompactSerializer(); + return $serializer->serialize($jws, 0); + } +} diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php new file mode 100644 index 00000000..d478220f --- /dev/null +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -0,0 +1,460 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\Controller\LoginController; +use OCA\UserOIDC\Service\DiscoveryService; +use OCA\UserOIDC\Service\ProviderService; +use OCA\UserOIDC\Service\LdapService; +use OCA\UserOIDC\Service\LocalIdService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Db\ProviderMapper; +use OCA\UserOIDC\Db\Provider; +use OCA\UserOIDC\Db\UserMapper; +use OCA\UserOIDC\Db\SessionMapper; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\UserOIDC\Event\UserAccountChangeEvent; +use OCA\UserOIDC\Event\AttributeMappedEvent; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OC\AppFramework\Bootstrap\Coordinator; +use OC\Authentication\Token\IProvider; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IGroupManager; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IUser; +use OCP\IDBConnection; +use OCP\ICacheFactory; +use Psr\Log\LoggerInterface; +use OCP\ILogger; // deprecated! +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OC\Security\Crypto; + +use OCP\AppFramework\App; + +use PHPUnit\Framework\MockObject\MockObject; +use OCA\UserOIDC\BaseTest\OpenidTokenTestCase; + +class ProvisioningEventServiceTest extends OpenidTokenTestCase { + /** + * Set up needed system and app configurations + */ + protected function getConfigSetup() :MockObject { + $config = $this->getMockForAbstractClass(IConfig::class); + + $config->expects($this->any()) + ->method("getSystemValue") + ->with($this->logicalOr($this->equalTo('user_oidc'), $this->equalTo('secret'))) + ->willReturn($this->returnCallback(function ($key, $default) { + if ($key == 'user_oidc') { + return [ + 'auto_provisioning' => true, + ]; + } elseif ($key == 'secret') { + return "Streng_geheim"; + } + })); + return $config; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getOidSessionSetup() :MockObject { + $session = $this->getMockForAbstractClass(ISession::class); + + $session->expects($this->any()) + ->method('get') + ->willReturn($this->returnCallback(function ($key) { + $values = [ + 'oidc.state' => $this->getOidTestState(), + 'oidc.providerid' => $this->getProviderId(), + 'oidc.nonce' => $this->getOidNonce(), + 'oidc.redirect' => 'https://welcome.to.magenta' + ]; + + return $values[$key] ? $values[$key] : "some_" . $key; + })); + $this->sessionMapper = $this->getMockBuilder(SessionMapper::class) + ->setConstructorArgs([ $this->getMockForAbstractClass(IDBConnection::class) ]) + ->getMock(); + $this->sessionMapper->expects($this->any()) + ->method('createSession'); + + return $session; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getProviderSetup() :MockObject { + $provider = $this->getMockBuilder(Provider::class) + ->addMethods(['getClientId', 'getClientSecret']) + ->getMock(); + $provider->expects($this->any()) + ->method('getClientId') + ->willReturn($this->getOidClientId()); + $provider->expects($this->once()) + ->method('getClientSecret') + ->willReturn($this->crypto->encrypt($this->getOidClientSecret())); + $this->providerMapper->expects($this->once()) + ->method('getProvider') + ->with($this->equalTo($this->getProviderId())) + ->willReturn($provider); + + return $provider; + } + + + /** + * Prepare a proper mapping configuration for the provider + */ + protected function getProviderServiceSetup() :MockObject { + $providerService = $this->getMockBuilder(ProviderService::class) + ->setConstructorArgs([ $this->config, $this->providerMapper]) + ->getMock(); + $providerService->expects($this->any()) + ->method('getSetting') + ->with($this->equalTo($this->getProviderId()), $this->logicalOr( + $this->equalTo(ProviderService::SETTING_MAPPING_UID), + $this->equalTo(ProviderService::SETTING_MAPPING_DISPLAYNAME), + $this->equalTo(ProviderService::SETTING_MAPPING_QUOTA), + $this->equalTo(ProviderService::SETTING_MAPPING_EMAIL), + $this->anything())) + ->will($this->returnCallback(function ($providerid, $key, $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' + ]; + return $values[$key]; + })); + return $providerService; + } + + /** + * Prepare a proper session as if the handshake with an + * OpenID authenticator entity has already been done. + */ + protected function getUserManagerSetup() :MockObject { + $userManager = $this->getMockForAbstractClass(IUserManager::class); + $this->user = $this->getMockForAbstractClass(IUser::class); + $this->user->expects($this->any()) + ->method("canChangeAvatar") + ->willReturn(false); + + return $userManager; + } + + + /** + * This is the standard execution sequence until provisoning + * is triggered in LoginController, set up with an artificial + * yet valid OpenID token. + */ + public function setUp(): void { + parent::setUp(); + + $this->app = new App(Application::APP_ID); + $this->config = $this->getConfigSetup(); + $this->crypto = $this->getMockBuilder(Crypto::class) + ->setConstructorArgs([ $this->config ]) + ->getMock(); + + $this->request = $this->getMockForAbstractClass(IRequest::class); + $this->request->expects($this->once()) + ->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 ]) + ->getMock(); + $this->discoveryService = $this->getMockBuilder(DiscoveryService::class) + ->setConstructorArgs([ $this->app->getContainer()->get(LoggerInterface::class), + $this->getMockForAbstractClass(IClientService::class), + $this->providerService, + $this->app->getContainer()->get(ICacheFactory::class) ]) + ->getMock(); + $this->discoveryService->expects($this->once()) + ->method('obtainDiscovery') + ->willReturn(array( 'token_endpoint' => 'https://whatever.to.discover/token', + 'issuer' => 'https:\/\/accounts.login00.custom.de' )); + $this->discoveryService->expects($this->once()) + ->method('obtainJWK') + ->willReturn($this->getOidPublicServerKey()); + $this->session = $this->getOidSessionSetup(); + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->response = $this->getMockForAbstractClass(IResponse::class); + //$this->usersession = $this->getMockForAbstractClass(IUserSession::class); + $this->usersession = $this->getMockBuilder(IUserSession::class) + ->disableOriginalConstructor() + ->onlyMethods(['setUser', 'login', 'logout', 'getUser', 'isLoggedIn', + 'getImpersonatingUserID', 'setImpersonatingUserID']) + ->addMethods(['completeLogin', 'createSessionToken', 'createRememberMeToken']) + ->getMock(); + $this->usermanager = $this->getUserManagerSetup(); + $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); + $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::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(ILogger::class)); + // here is where the token magic comes in + $this->token = array( 'id_token' => + $this->createSignToken($this->getRealOidClaims(), + $this->getOidServerKey())); + $this->tokenResponse = $this->getMockForAbstractClass(IResponse::class); + $this->tokenResponse->expects($this->once()) + ->method("getBody") + ->willReturn(json_encode($this->token)); + + // mock token retrieval + $this->client = $this->getMockForAbstractClass(IClient::class); + $this->client->expects($this->once()) + ->method("post") + ->with($this->equalTo('https://whatever.to.discover/token'), $this->arrayHasKey('body')) + ->willReturn($this->tokenResponse); + $this->clientService = $this->getMockForAbstractClass(IClientService::class); + $this->clientService->expects($this->once()) + ->method("newClient") + ->willReturn($this->client); + $this->registrationContext = + $this->app->getContainer()->get(Coordinator::class)->getRegistrationContext(); + $this->loginController = new LoginController($this->request, + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->clientService, + $this->app->getContainer()->get(IUrlGenerator::class), + $this->usersession, + $this->usermanager, + $this->app->getContainer()->get(ITimeFactory::class), + $this->dispatcher, + $this->config, + $this->app->getContainer()->get(IProvider::class), + $this->sessionMapper, + $this->provisioningService, + $this->app->getContainer()->get(IL10N::class), + $this->app->getContainer()->get(ILogger::class), + $this->crypto); + + $this->attributeListener = null; + $this->accountListener = null; + } + + /** + * Seems like the event dispatcher requires explicit unregistering + */ + public function tearDown(): void { + parent::tearDown(); + if ($this->accountListener != null) { + $this->dispatcher->removeListener(UserAccountChangeEvent::class, $this->accountListener); + } + if ($this->attributeListener != null) { + $this->dispatcher->removeListener(AttributeMappedEvent::class, $this->attributeListener); + } + } + + protected function mockAssertLoginSuccess() { + $this->usermanager->expects($this->once()) + ->method('get') + ->willReturn($this->user); + $this->session->expects($this->once()) + ->method("set") + ->with($this->equalTo('oidc.id_token'), $this->anything()); + $this->usersession->expects($this->once()) + ->method("setUser") + ->with($this->equalTo($this->user)); + $this->usersession->expects($this->once()) + ->method("completeLogin") + ->with($this->anything(), $this->anything()); + $this->usersession->expects($this->once()) + ->method("createSessionToken"); + $this->usersession->expects($this->once()) + ->method("createRememberMeToken"); + } + + protected function assertLoginRedirect($result) { + $this->assertInstanceOf(RedirectResponse::class, + $result, "LoginController->code() did not end with success redirect: Status: " . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + protected function assertLogin403($result) { + $this->assertInstanceOf(TemplateResponse::class, + $result, "LoginController->code() did not end with 403 Forbidden: Actual status: " . + strval($result->getStatus() . ' ' . json_encode($result->getThrottleMetadata()))); + } + + /** + * Test with the default mapping, no mapping by attribute events + * provisioning with successful result. + */ + public function testNoMap_AccessOk() { + $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->assertEquals('https://welcome.to.magenta', $result->getRedirectURL()); + } + + /** + * For multiple reasons, uid should com directly from a token + * field, usually sub. Thus, uid is not remapped by event, even + * if you try with a listener. + */ + public function testUidNoMapEvent_AccessOk() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } + }; + $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(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.magenta', $result->getRedirectURL()); + } + + + + /** + * Test displayname set by event scheduling and negative result + */ + public function testDisplaynameMapEvent_NOk_NoRedirect() { + $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() { + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent && + $event->getAttribute() == ProviderService::SETTING_MAPPING_EMAIL) { + //$defaultUID = $event->getValue(); + $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() { + $this->mockAssertLoginSuccess(); + $this->attributeListener = function (Event $event): void { + if ($event instanceof AttributeMappedEvent) { + if ($event->getAttribute() == ProviderService::SETTING_MAPPING_UID) { + $this->fail('UID event mapping not supported'); + } elseif ($event->getAttribute() == ProviderService::SETTING_MAPPING_DISPLAYNAME) { + $event->setValue("Lisa, Mona"); + } elseif ($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('https://welcome.to.magenta', $result->getRedirectURL()); + } +} diff --git a/tests/unit/MagentaCloud/RegistrationsTest.php b/tests/unit/MagentaCloud/RegistrationsTest.php new file mode 100644 index 00000000..5a70f556 --- /dev/null +++ b/tests/unit/MagentaCloud/RegistrationsTest.php @@ -0,0 +1,33 @@ + + * + * @license GNU AGPL version 3 or any later version + * + */ + +declare(strict_types=1); + +use OCA\UserOIDC\AppInfo\Application; +use OCA\UserOIDC\Service\ProvisioningService; +use OCA\UserOIDC\Service\ProvisioningEventService; +use OC\AppFramework\Bootstrap\Coordinator; + +use PHPUnit\Framework\TestCase; + +class RegistrationsTest extends TestCase { + public function setUp() :void { + parent::setUp(); + + $this->app = new Application(); + $coordinator = \OC::$server->get(Coordinator::class); + $this->app->register($coordinator->getRegistrationContext()->for('user_oidc')); + } + + public function testRegistration() :void { + $provisioningService = $this->app->getContainer()->get(ProvisioningService::class); + $this->assertInstanceOf(ProvisioningEventService::class, $provisioningService); + } +} From 1bb1671b0b22325f3f354557ccdc1f1b24716bf1 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 13:59:21 +0200 Subject: [PATCH 05/10] Remove old test approach --- .../Service/EventProvisioningServiceTest.php | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 tests/unit/Service/EventProvisioningServiceTest.php diff --git a/tests/unit/Service/EventProvisioningServiceTest.php b/tests/unit/Service/EventProvisioningServiceTest.php deleted file mode 100644 index a470decd..00000000 --- a/tests/unit/Service/EventProvisioningServiceTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -declare(strict_types=1); - - -use OCP\Http\Client\IClientService; -use OCP\Http\Client\IClient; -use OCP\Http\Client\IResponse; - -use OCP\AppFramework\App; -use OCA\UserOIDC\AppInfo\Application; - -use OCA\UserOIDC\Db\Provider; - -use PHPUnit\Framework\TestCase; - -class EventProvisioningServiceTest extends TestCase { - public function setUp(): void { - parent::setUp(); - $this->app = new App(Application::APP_ID); - - $this->provider = $this->getMockBuilder(Provider::class) - ->addMethods(['getDiscoveryEndpoint']) - ->getMock(); - $this->client = $this->getMockForAbstractClass(IClient::class); - $this->clientFactory = $this->getMockForAbstractClass(IClientService::class); - $this->clientFactory->expects($this->any()) - ->method('newClient') - ->willReturn($this->client); - $this->response = $this->getMockForAbstractClass(IResponse::class); - } - - public function testUidMapped() { - } - - public function testUidNotMapped() { - } - - public function testDisplaynameMapped() { - } - - public function testDisplaynameNotMapped() { - } - - public function testQuotaMapped() { - } - - public function testQuotaNotMapped() { - } - - public function testMappingProblem() { - } - - public function testSuccess() { - } - - public function testDenied() { - } - - public function testDeniedRedirect() { - } -} From d74020ce6e9992f237cec95480c87b4cfb0fe23f Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 16:57:29 +0200 Subject: [PATCH 06/10] Update for changed JWT dependency and test adaptions --- lib/Controller/LoginController.php | 2 +- lib/Service/ProvisioningDeniedException.php | 2 +- tests/unit/MagentaCloud/OpenidTokenTestCase.php | 2 +- tests/unit/MagentaCloud/ProvisioningEventServiceTest.php | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 2792dbae..917da664 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -39,7 +39,7 @@ use OCA\UserOIDC\Service\EventProvisioningService; use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; -use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; +use Firebase\JWT\JWT; use OCA\UserOIDC\AppInfo\Application; use OCA\UserOIDC\Db\ProviderMapper; use OCP\AppFramework\Db\DoesNotExistException; diff --git a/lib/Service/ProvisioningDeniedException.php b/lib/Service/ProvisioningDeniedException.php index 4ebf8fd9..58c2fb9b 100644 --- a/lib/Service/ProvisioningDeniedException.php +++ b/lib/Service/ProvisioningDeniedException.php @@ -23,7 +23,7 @@ * */ -namespace OCP; +namespace OCA\UserOIDC\Service; /** * Exception if the precondition of the config update method isn't met diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php index cbaa9847..57cda9ca 100644 --- a/tests/unit/MagentaCloud/OpenidTokenTestCase.php +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -77,7 +77,7 @@ public function getOidPrivateServerKey() { public function getOidPublicServerKey() { - return \OCA\UserOIDC\Vendor\Firebase\JWT\JWK::parseKeySet([ "keys" => [[ + return \Firebase\JWT\JWK::parseKeySet([ "keys" => [[ "kty" => "RSA", "e" => "AQAB", "use" => "sig", diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php index d478220f..e5d51b68 100644 --- a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -298,9 +298,10 @@ protected function mockAssertLoginSuccess() { $this->usermanager->expects($this->once()) ->method('get') ->willReturn($this->user); - $this->session->expects($this->once()) + $this->session->expects($this->exactly(2)) ->method("set") - ->with($this->equalTo('oidc.id_token'), $this->anything()); + ->withConsecutive([$this->equalTo('oidc.id_token'), $this->anything()], + [$this->equalTo('last-password-confirm'), $this->anything()] ); $this->usersession->expects($this->once()) ->method("setUser") ->with($this->equalTo($this->user)); From 133d102642f6af658ff34120f4f57e249b4f82e7 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 19:47:26 +0200 Subject: [PATCH 07/10] Revert namespace adoptions --- lib/Controller/LoginController.php | 2 +- tests/unit/MagentaCloud/OpenidTokenTestCase.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 917da664..2792dbae 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -39,7 +39,7 @@ use OCA\UserOIDC\Service\EventProvisioningService; use OCA\UserOIDC\Service\ProvisioningDeniedException; use OCA\UserOIDC\Service\ProvisioningService; -use Firebase\JWT\JWT; +use OCA\UserOIDC\Vendor\Firebase\JWT\JWT; use OCA\UserOIDC\AppInfo\Application; use OCA\UserOIDC\Db\ProviderMapper; use OCP\AppFramework\Db\DoesNotExistException; diff --git a/tests/unit/MagentaCloud/OpenidTokenTestCase.php b/tests/unit/MagentaCloud/OpenidTokenTestCase.php index 57cda9ca..cbaa9847 100644 --- a/tests/unit/MagentaCloud/OpenidTokenTestCase.php +++ b/tests/unit/MagentaCloud/OpenidTokenTestCase.php @@ -77,7 +77,7 @@ public function getOidPrivateServerKey() { public function getOidPublicServerKey() { - return \Firebase\JWT\JWK::parseKeySet([ "keys" => [[ + return \OCA\UserOIDC\Vendor\Firebase\JWT\JWK::parseKeySet([ "keys" => [[ "kty" => "RSA", "e" => "AQAB", "use" => "sig", From fffb7e1f58a8c11bb7bec2ee0f1b210eece017ff Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Fri, 1 Sep 2023 21:48:35 +0200 Subject: [PATCH 08/10] Clean up code --- tests/unit/MagentaCloud/ProvisioningEventServiceTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php index e5d51b68..553faaab 100644 --- a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -300,8 +300,8 @@ protected function mockAssertLoginSuccess() { ->willReturn($this->user); $this->session->expects($this->exactly(2)) ->method("set") - ->withConsecutive([$this->equalTo('oidc.id_token'), $this->anything()], - [$this->equalTo('last-password-confirm'), $this->anything()] ); + ->withConsecutive([$this->equalTo('oidc.id_token'), $this->anything()], + [$this->equalTo('last-password-confirm'), $this->anything()]); $this->usersession->expects($this->once()) ->method("setUser") ->with($this->equalTo($this->user)); From c6380ba97821d05ea9a7bc635d2f8b44cc59281d Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 14 Jan 2025 08:32:00 +0100 Subject: [PATCH 09/10] update provisioning unit test --- lib/Db/UserMapper.php | 50 +++++++++ .../ProvisioningEventServiceTest.php | 100 ++++++++++-------- 2 files changed, 108 insertions(+), 42 deletions(-) diff --git a/lib/Db/UserMapper.php b/lib/Db/UserMapper.php index fe209967..9bc257cf 100644 --- a/lib/Db/UserMapper.php +++ b/lib/Db/UserMapper.php @@ -13,6 +13,7 @@ use OCP\AppFramework\Db\QBMapper; use OCP\Cache\CappedMemoryCache; use OCP\IDBConnection; +use Psr\Log\LoggerInterface; /** * @extends QBMapper @@ -20,13 +21,16 @@ class UserMapper extends QBMapper { private CappedMemoryCache $userCache; + private LoggerInterface $logger; public function __construct( IDBConnection $db, + LoggerInterface $logger, private LocalIdService $idService, ) { parent::__construct($db, 'user_oidc', User::class); $this->userCache = new CappedMemoryCache(); + $this->logger = $logger; } /** @@ -57,6 +61,29 @@ public function getUser(string $uid): User { public function find(string $search, $limit = null, $offset = null): array { $qb = $this->db->getQueryBuilder(); + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $stack = []; + + foreach ($backtrace as $index => $trace) { + $class = $trace['class'] ?? ''; + $type = $trace['type'] ?? ''; + $function = $trace['function'] ?? ''; + $file = $trace['file'] ?? 'unknown file'; + $line = $trace['line'] ?? 'unknown line'; + + $stack[] = sprintf( + "#%d %s%s%s() called at [%s:%s]", + $index, + $class, + $type, + $function, + $file, + $line + ); + } + + $this->logger->debug("Find user by string: " . $search . " -- Call Stack:\n" . implode("\n", $stack)); + $qb->select('user_id', 'display_name') ->from($this->getTableName(), 'u') ->leftJoin('u', 'preferences', 'p', $qb->expr()->andX( @@ -77,6 +104,29 @@ public function find(string $search, $limit = null, $offset = null): array { public function findDisplayNames(string $search, $limit = null, $offset = null): array { $qb = $this->db->getQueryBuilder(); + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $stack = []; + + foreach ($backtrace as $index => $trace) { + $class = $trace['class'] ?? ''; + $type = $trace['type'] ?? ''; + $function = $trace['function'] ?? ''; + $file = $trace['file'] ?? 'unknown file'; + $line = $trace['line'] ?? 'unknown line'; + + $stack[] = sprintf( + "#%d %s%s%s() called at [%s:%s]", + $index, + $class, + $type, + $function, + $file, + $line + ); + } + + $this->logger->debug("Find user display names by string: " . $search . " -- Call Stack:\n" . implode("\n", $stack)); + $qb->select('user_id', 'display_name') ->from($this->getTableName(), 'u') ->leftJoin('u', 'preferences', 'p', $qb->expr()->andX( diff --git a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php index 553faaab..b844f640 100644 --- a/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php +++ b/tests/unit/MagentaCloud/ProvisioningEventServiceTest.php @@ -16,11 +16,11 @@ use OCA\UserOIDC\Service\LdapService; use OCA\UserOIDC\Service\LocalIdService; use OCA\UserOIDC\Service\ProvisioningEventService; -use OCA\UserOIDC\AppInfo\Application; -use OCA\UserOIDC\Db\ProviderMapper; -use OCA\UserOIDC\Db\Provider; -use OCA\UserOIDC\Db\UserMapper; -use OCA\UserOIDC\Db\SessionMapper; +use OCP\Accounts\IAccountManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; use OCA\UserOIDC\Event\UserAccountChangeEvent; @@ -28,12 +28,8 @@ use OCP\Http\Client\IClientService; use OCP\Http\Client\IClient; use OCP\Http\Client\IResponse; -use OCP\AppFramework\Utility\ITimeFactory; -use OC\AppFramework\Bootstrap\Coordinator; -use OC\Authentication\Token\IProvider; -use OCP\AppFramework\Http\RedirectResponse; -use OCP\AppFramework\Http\TemplateResponse; -use OCP\IGroupManager; +use OCP\IAvatarManager; +use OCP\ICacheFactory; use OCP\IConfig; use OCP\IL10N; use OCP\IUser; @@ -46,6 +42,8 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; + + use OCP\Security\ISecureRandom; use OC\Security\Crypto; @@ -218,23 +216,40 @@ public function setUp(): void { $this->response = $this->getMockForAbstractClass(IResponse::class); //$this->usersession = $this->getMockForAbstractClass(IUserSession::class); $this->usersession = $this->getMockBuilder(IUserSession::class) - ->disableOriginalConstructor() - ->onlyMethods(['setUser', 'login', 'logout', 'getUser', 'isLoggedIn', - 'getImpersonatingUserID', 'setImpersonatingUserID']) - ->addMethods(['completeLogin', 'createSessionToken', 'createRememberMeToken']) - ->getMock(); + ->disableOriginalConstructor() + ->onlyMethods([ + 'setUser', + 'login', + 'logout', + 'getUser', + 'isLoggedIn', + 'getImpersonatingUserID', + 'setImpersonatingUserID', + 'setVolatileActiveUser' // Diese Methode hinzufügen, falls sie gebraucht wird. + ]) + ->addMethods([ + 'completeLogin', + 'createSessionToken', + 'createRememberMeToken' + ]) + ->getMock(); + $this->usermanager = $this->getUserManagerSetup(); $this->groupmanager = $this->getMockForAbstractClass(IGroupManager::class); $this->dispatcher = $this->app->getContainer()->get(IEventDispatcher::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(ILogger::class)); + $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->app->getContainer()->get(IConfig::class)); // here is where the token magic comes in $this->token = array( 'id_token' => $this->createSignToken($this->getRealOidClaims(), @@ -257,25 +272,25 @@ public function setUp(): void { $this->registrationContext = $this->app->getContainer()->get(Coordinator::class)->getRegistrationContext(); $this->loginController = new LoginController($this->request, - $this->providerMapper, - $this->providerService, - $this->discoveryService, - $this->app->getContainer()->get(LdapService::class), - $this->app->getContainer()->get(ISecureRandom::class), - $this->session, - $this->clientService, - $this->app->getContainer()->get(IUrlGenerator::class), - $this->usersession, - $this->usermanager, - $this->app->getContainer()->get(ITimeFactory::class), - $this->dispatcher, - $this->config, - $this->app->getContainer()->get(IProvider::class), - $this->sessionMapper, - $this->provisioningService, - $this->app->getContainer()->get(IL10N::class), - $this->app->getContainer()->get(ILogger::class), - $this->crypto); + $this->providerMapper, + $this->providerService, + $this->discoveryService, + $this->app->getContainer()->get(LdapService::class), + $this->app->getContainer()->get(ISecureRandom::class), + $this->session, + $this->clientService, + $this->app->getContainer()->get(IUrlGenerator::class), + $this->usersession, + $this->usermanager, + $this->app->getContainer()->get(ITimeFactory::class), + $this->dispatcher, + $this->config, + $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->attributeListener = null; $this->accountListener = null; @@ -413,6 +428,7 @@ public function testMainEmailMap_Nok_Redirect() { $event->setValue("mona.lisa@louvre.fr"); } }; + $this->accountListener = function (Event $event) :void { $this->assertInstanceOf(UserAccountChangeEvent::class, $event); $this->assertEquals('jgyros', $event->getUid()); From 3268d20d7dd29a9bcd2d9126503ce2abd6df3a5b Mon Sep 17 00:00:00 2001 From: memurats Date: Tue, 14 Jan 2025 11:33:18 +0100 Subject: [PATCH 10/10] Fix slow queries in scenarios where we do not need a search --- lib/Controller/LoginController.php | 13 +++++++++---- lib/Service/LdapService.php | 10 ++++++++++ lib/Service/ProvisioningService.php | 11 +++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 5e605cd8..2ca9240b 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -518,11 +518,16 @@ public function code(string $state = '', string $code = '', string $scope = '', } $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); + $softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']); + + $shouldDoUserLookup = !$autoProvisionAllowed || ($softAutoProvisionAllowed && !$this->provisioningService->hasOidcUserProvisitioned($userId)); + if ($shouldDoUserLookup && $this->ldapService->isLDAPEnabled()) { + // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results + // so new users will be directly available even if they were not synced before this login attempt + $this->userManager->search($userId, 1, 0); + $this->ldapService->syncUser($userId); + } - // in case user is provisioned by user_ldap, userManager->search() triggers an ldap search which syncs the results - // so new users will be directly available even if they were not synced before this login attempt - $this->userManager->search($userId); - $this->ldapService->syncUser($userId); $userFromOtherBackend = $this->userManager->get($userId); if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { $userFromOtherBackend = null; diff --git a/lib/Service/LdapService.php b/lib/Service/LdapService.php index 53cf80fa..f56fa12f 100644 --- a/lib/Service/LdapService.php +++ b/lib/Service/LdapService.php @@ -8,6 +8,7 @@ namespace OCA\UserOIDC\Service; +use OCP\App\IAppManager; use OCP\AppFramework\QueryException; use OCP\IUser; use Psr\Log\LoggerInterface; @@ -16,9 +17,14 @@ class LdapService { public function __construct( private LoggerInterface $logger, + private IAppManager $appManager, ) { } + public function isLDAPEnabled(): bool { + return $this->appManager->isEnabledForUser('user_ldap'); + } + /** * @param IUser $user * @return bool @@ -26,6 +32,10 @@ public function __construct( * @throws \Psr\Container\NotFoundExceptionInterface */ public function isLdapDeletedUser(IUser $user): bool { + if ($this->isLDAPEnabled()) { + return false; + } + $className = $user->getBackendClassName(); if ($className !== 'LDAP') { return false; diff --git a/lib/Service/ProvisioningService.php b/lib/Service/ProvisioningService.php index 02122a1d..e06d07bd 100644 --- a/lib/Service/ProvisioningService.php +++ b/lib/Service/ProvisioningService.php @@ -10,6 +10,8 @@ use OCA\UserOIDC\Db\UserMapper; use OCA\UserOIDC\Event\AttributeMappedEvent; use OCP\Accounts\IAccountManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; @@ -40,6 +42,15 @@ public function __construct( ) { } + public function hasOidcUserProvisitioned(string $userId): bool { + try { + $this->userMapper->getUser($userId); + return true; + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + } + return false; + } + /** * @param string $tokenUserId * @param int $providerId