From e31ff84eb96473726cc9692f6df3e548c5c20f3d Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 8 Dec 2023 12:09:20 +0100 Subject: [PATCH 1/6] implement soft auto provisioning: allow users from other backends to login, update their info Signed-off-by: Julien Veyssier --- lib/Controller/LoginController.php | 31 ++++++++----- lib/Service/ProvisioningService.php | 46 ++++++++++++------- lib/User/Backend.php | 36 ++++++++++++--- .../Provisioning/IProvisioningStrategy.php | 3 +- .../SelfEncodedTokenProvisioning.php | 4 +- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index aca8cec2..ec523c24 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -493,19 +493,28 @@ public function code(string $state = '', string $code = '', string $scope = '', $autoProvisionAllowed = (!isset($oidcSystemConfig['auto_provision']) || $oidcSystemConfig['auto_provision']); - // Provisioning + // 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) + $userFromOtherBackend = $this->userManager->get($userId); + if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { + $userFromOtherBackend = null; + } + 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 ($user !== null && $this->ldapService->isLdapDeletedUser($user)) { - $user = null; + $softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']); + if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) { + // if soft auto-provisioning is disabled, + // we refuse login for a user that already exists in another backend + $message = $this->l10n->t('User conflict'); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict']); } + // use potential user from other backend, create it in out backend if it does not exist + $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $userFromOtherBackend); + } else { + $user = $userFromOtherBackend; } if ($user === null) { diff --git a/lib/Service/ProvisioningService.php b/lib/Service/ProvisioningService.php index 90e6ef7f..f74cd7ed 100644 --- a/lib/Service/ProvisioningService.php +++ b/lib/Service/ProvisioningService.php @@ -65,12 +65,11 @@ public function __construct( * @param string $tokenUserId * @param int $providerId * @param object $idTokenPayload + * @param IUser|null $existingLocalUser * @return IUser|null * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface */ - public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload): ?IUser { + public function provisionUser(string $tokenUserId, int $providerId, object $idTokenPayload, ?IUser $existingLocalUser = null): ?IUser { // get name/email/quota information from the token itself $emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email'); $email = $idTokenPayload->{$emailAttribute} ?? null; @@ -132,9 +131,18 @@ public function provisionUser(string $tokenUserId, int $providerId, object $idTo $event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $idTokenPayload, $tokenUserId); $this->eventDispatcher->dispatchTyped($event); - // Casting to get empty string if value is null - $backendUser = $this->userMapper->getOrCreate($providerId, $event->getValue() ?? ''); - $this->logger->debug('User obtained from the OIDC user backend: ' . $backendUser->getUserId()); + // use an existing user (from another backend) when soft auto provisioning is enabled + if ($existingLocalUser !== null) { + $user = $existingLocalUser; + } else { + $backendUser = $this->userMapper->getOrCreate($providerId, $event->getValue() ?? ''); + $this->logger->debug('User obtained from the OIDC user backend: ' . $backendUser->getUserId()); + + $user = $this->userManager->get($backendUser->getUserId()); + if ($user === null) { + return null; + } + } $user = $this->userManager->get($backendUser->getUserId()); if ($user === null) { @@ -154,17 +162,21 @@ public function provisionUser(string $tokenUserId, int $providerId, object $idTo $this->eventDispatcher->dispatchTyped($event); $this->logger->debug('Displayname mapping event dispatched'); if ($event->hasValue()) { - $oldDisplayName = $backendUser->getDisplayName(); - $newDisplayName = $event->getValue(); - if ($newDisplayName !== $oldDisplayName) { - $backendUser->setDisplayName($newDisplayName); - $this->userMapper->update($backendUser); - } - // 2 reasons why we should update the display name: It does not match the one - // - of our backend - // - returned by the user manager (outdated one before the fix in https://github.com/nextcloud/user_oidc/pull/530) - if ($newDisplayName !== $oldDisplayName || $newDisplayName !== $user->getDisplayName()) { - $this->eventDispatcher->dispatchTyped(new UserChangedEvent($user, 'displayName', $newDisplayName, $oldDisplayName)); + if ($existingLocalUser === null) { + $oldDisplayName = $backendUser->getDisplayName(); + $newDisplayName = $event->getValue(); + if ($newDisplayName !== $oldDisplayName) { + $backendUser->setDisplayName($newDisplayName); + $this->userMapper->update($backendUser); + } + // 2 reasons why we should update the display name: It does not match the one + // - of our backend + // - returned by the user manager (outdated one before the fix in https://github.com/nextcloud/user_oidc/pull/530) + if ($newDisplayName !== $oldDisplayName || $newDisplayName !== $user->getDisplayName()) { + $this->eventDispatcher->dispatchTyped(new UserChangedEvent($user, 'displayName', $newDisplayName, $oldDisplayName)); + } + } else { + $user->setDisplayName($newDisplayName); } } diff --git a/lib/User/Backend.php b/lib/User/Backend.php index a49e2765..5dfe656a 100644 --- a/lib/User/Backend.php +++ b/lib/User/Backend.php @@ -269,15 +269,36 @@ public function getCurrentUserId(): string { $this->eventDispatcher->dispatchTyped(new TokenValidatedEvent(['token' => $headerToken], $provider, $discovery)); if ($autoProvisionAllowed) { - $backendUser = $this->userMapper->getOrCreate($provider->getId(), $tokenUserId); - $userId = $backendUser->getUserId(); + // look for user in other backends + if (!$this->userManager->userExists($tokenUserId)) { + $this->userManager->search($tokenUserId); + $this->ldapService->syncUser($tokenUserId); + } + $userFromOtherBackend = $this->userManager->get($tokenUserId); + if ($this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { + $userFromOtherBackend = null; + } + + $softAutoProvisionAllowed = (!isset($oidcSystemConfig['soft_auto_provision']) || $oidcSystemConfig['soft_auto_provision']); + if (!$softAutoProvisionAllowed && $userFromOtherBackend !== null) { + // if soft auto-provisioning is disabled, + // we refuse login for a user that already exists in another backend + return ''; + } + if ($userFromOtherBackend === null) { + // only create the user in our backend if the user does not exist in another backend + $backendUser = $this->userMapper->getOrCreate($provider->getId(), $tokenUserId); + $userId = $backendUser->getUserId(); + } else { + $userId = $userFromOtherBackend->getUID(); + } $this->checkFirstLogin($userId); if ($this->providerService->getSetting($provider->getId(), ProviderService::SETTING_BEARER_PROVISIONING, '0') === '1') { $provisioningStrategy = $validator->getProvisioningStrategy(); if ($provisioningStrategy) { - $this->provisionUser($validator->getProvisioningStrategy(), $provider, $tokenUserId, $headerToken); + $this->provisionUser($validator->getProvisioningStrategy(), $provider, $tokenUserId, $headerToken, $userFromOtherBackend); } } @@ -291,6 +312,7 @@ public function getCurrentUserId(): string { // to get the user if it has not been synced yet if (!$this->userManager->userExists($tokenUserId)) { $this->userManager->search($tokenUserId); + $this->ldapService->syncUser($tokenUserId); // return nothing, if the user was not found after the user_ldap search if (!$this->userManager->userExists($tokenUserId)) { @@ -351,13 +373,15 @@ private function checkFirstLogin(string $userId): bool { * Triggers user provisioning based on the provided strategy * * @param string $provisioningStrategyClass - * @param string $tokenUserId * @param Provider $provider + * @param string $tokenUserId * @param string $headerToken + * @param IUser|null $userFromOtherBackend * @return IUser|null */ - private function provisionUser(string $provisioningStrategyClass, Provider $provider, string $tokenUserId, string $headerToken): ?IUser { + private function provisionUser(string $provisioningStrategyClass, Provider $provider, string $tokenUserId, string $headerToken, + ?IUser $userFromOtherBackend): ?IUser { $provisioningStrategy = \OC::$server->get($provisioningStrategyClass); - return $provisioningStrategy->provisionUser($provider, $tokenUserId, $headerToken); + return $provisioningStrategy->provisionUser($provider, $tokenUserId, $headerToken, $userFromOtherBackend); } } diff --git a/lib/User/Provisioning/IProvisioningStrategy.php b/lib/User/Provisioning/IProvisioningStrategy.php index 6e1a1421..628c5dcc 100644 --- a/lib/User/Provisioning/IProvisioningStrategy.php +++ b/lib/User/Provisioning/IProvisioningStrategy.php @@ -13,7 +13,8 @@ interface IProvisioningStrategy { * @param Provider $provider * @param string $tokenUserId * @param string $bearerToken + * @param IUser|null $userFromOtherBackend * @return IUser|null */ - public function provisionUser(Provider $provider, string $tokenUserId, string $bearerToken): ?IUser; + public function provisionUser(Provider $provider, string $tokenUserId, string $bearerToken, ?IUser $userFromOtherBackend): ?IUser; } diff --git a/lib/User/Provisioning/SelfEncodedTokenProvisioning.php b/lib/User/Provisioning/SelfEncodedTokenProvisioning.php index d45c1fab..ba958048 100644 --- a/lib/User/Provisioning/SelfEncodedTokenProvisioning.php +++ b/lib/User/Provisioning/SelfEncodedTokenProvisioning.php @@ -27,7 +27,7 @@ public function __construct(ProvisioningService $provisioningService, DiscoveryS $this->logger = $logger; } - public function provisionUser(Provider $provider, string $tokenUserId, string $bearerToken): ?IUser { + public function provisionUser(Provider $provider, string $tokenUserId, string $bearerToken, ?IUser $userFromOtherBackend): ?IUser { JWT::$leeway = 60; try { $jwks = $this->discoveryService->obtainJWK($provider, $bearerToken); @@ -37,6 +37,6 @@ public function provisionUser(Provider $provider, string $tokenUserId, string $b return null; } - return $this->provisioningService->provisionUser($tokenUserId, $provider->getId(), $payload); + return $this->provisioningService->provisionUser($tokenUserId, $provider->getId(), $payload, $userFromOtherBackend); } } From ee1259c340ac0201de8c4e863601509018569f53 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 8 Dec 2023 13:31:15 +0100 Subject: [PATCH 2/6] fix isLdapDeletedUser calls with null user Signed-off-by: Julien Veyssier --- lib/Service/ProvisioningService.php | 2 -- lib/User/Backend.php | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/Service/ProvisioningService.php b/lib/Service/ProvisioningService.php index f74cd7ed..8bba5c7a 100644 --- a/lib/Service/ProvisioningService.php +++ b/lib/Service/ProvisioningService.php @@ -11,8 +11,6 @@ use OCP\IUser; use OCP\IUserManager; use OCP\User\Events\UserChangedEvent; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; class ProvisioningService { diff --git a/lib/User/Backend.php b/lib/User/Backend.php index 5dfe656a..ae2f8f04 100644 --- a/lib/User/Backend.php +++ b/lib/User/Backend.php @@ -275,7 +275,7 @@ public function getCurrentUserId(): string { $this->ldapService->syncUser($tokenUserId); } $userFromOtherBackend = $this->userManager->get($tokenUserId); - if ($this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { + if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { $userFromOtherBackend = null; } @@ -321,7 +321,7 @@ public function getCurrentUserId(): string { } $user = $this->userManager->get($tokenUserId); - if ($this->ldapService->isLdapDeletedUser($user)) { + if ($user === null || $this->ldapService->isLdapDeletedUser($user)) { return ''; } $this->checkFirstLogin($tokenUserId); From 3e80a08f12e2be5f7ff5d21233ec1839a9c8c305 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 8 Dec 2023 15:25:59 +0100 Subject: [PATCH 3/6] fix setting display name Signed-off-by: Julien Veyssier --- lib/Service/ProvisioningService.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Service/ProvisioningService.php b/lib/Service/ProvisioningService.php index 8bba5c7a..80a52339 100644 --- a/lib/Service/ProvisioningService.php +++ b/lib/Service/ProvisioningService.php @@ -160,9 +160,9 @@ public function provisionUser(string $tokenUserId, int $providerId, object $idTo $this->eventDispatcher->dispatchTyped($event); $this->logger->debug('Displayname mapping event dispatched'); if ($event->hasValue()) { + $newDisplayName = $event->getValue(); if ($existingLocalUser === null) { $oldDisplayName = $backendUser->getDisplayName(); - $newDisplayName = $event->getValue(); if ($newDisplayName !== $oldDisplayName) { $backendUser->setDisplayName($newDisplayName); $this->userMapper->update($backendUser); @@ -174,7 +174,10 @@ public function provisionUser(string $tokenUserId, int $providerId, object $idTo $this->eventDispatcher->dispatchTyped(new UserChangedEvent($user, 'displayName', $newDisplayName, $oldDisplayName)); } } else { - $user->setDisplayName($newDisplayName); + $oldDisplayName = $user->getDisplayName(); + if ($newDisplayName !== $oldDisplayName) { + $user->setDisplayName($newDisplayName); + } } } From 01c80984b0d3aa4bd962d02332f9681b36bf21b9 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 17 Jan 2024 12:13:12 +0100 Subject: [PATCH 4/6] do not throttle when there is a user conflict with non-soft auto provisioning Signed-off-by: Julien Veyssier --- lib/Controller/LoginController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index ec523c24..f099bfa1 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -497,7 +497,6 @@ public function code(string $state = '', string $code = '', string $scope = '', // 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) $userFromOtherBackend = $this->userManager->get($userId); if ($userFromOtherBackend !== null && $this->ldapService->isLdapDeletedUser($userFromOtherBackend)) { $userFromOtherBackend = null; @@ -509,11 +508,12 @@ public function code(string $state = '', string $code = '', string $scope = '', // if soft auto-provisioning is disabled, // we refuse login for a user that already exists in another backend $message = $this->l10n->t('User conflict'); - return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict']); + return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'non-soft auto provision, user conflict'], false); } - // use potential user from other backend, create it in out backend if it does not exist + // use potential user from other backend, create it in our backend if it does not exist $user = $this->provisioningService->provisionUser($userId, $providerId, $idTokenPayload, $userFromOtherBackend); } else { + // when auto provision is disabled, we assume the user has been created by another user backend (or manually) $user = $userFromOtherBackend; } From d52b05bb260ee9a6dd1b22f4eef9b83cb334fa4b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 23 Jan 2024 16:33:21 +0100 Subject: [PATCH 5/6] cs:fix Signed-off-by: Julien Veyssier --- lib/User/Backend.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/User/Backend.php b/lib/User/Backend.php index ae2f8f04..a0b55b55 100644 --- a/lib/User/Backend.php +++ b/lib/User/Backend.php @@ -379,8 +379,10 @@ private function checkFirstLogin(string $userId): bool { * @param IUser|null $userFromOtherBackend * @return IUser|null */ - private function provisionUser(string $provisioningStrategyClass, Provider $provider, string $tokenUserId, string $headerToken, - ?IUser $userFromOtherBackend): ?IUser { + private function provisionUser( + string $provisioningStrategyClass, Provider $provider, string $tokenUserId, string $headerToken, + ?IUser $userFromOtherBackend + ): ?IUser { $provisioningStrategy = \OC::$server->get($provisioningStrategyClass); return $provisioningStrategy->provisionUser($provider, $tokenUserId, $headerToken, $userFromOtherBackend); } From 22a4cb60fa6c8bcde3de90d334783aa95397db37 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Feb 2024 10:56:38 +0100 Subject: [PATCH 6/6] document soft auto provisioning in README Signed-off-by: Julien Veyssier --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 6780a207..6cdcca34 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,29 @@ defined in the "Login Attributes" tab. In other words, make sure that your OpenID Connect provider's "User ID mapping" setting is set to an attribute which provides the same values as the LDAP attribute set in "Internal Username" in your LDAP settings. +#### Soft auto provisioning + +If you have existing users managed by another backend (local or LDAP users for example) and you want them to be managed +by user_oidc but you still want user_oidc to auto-provision users +(create new users when they are in the Oidc IdP but not found in any other user backend), +this is possible with **soft** auto provisioning. + +There is a `soft_auto_provision` system config flag that is enabled by default and is effective only if `auto_provision` +is enabled. +``` php +'user_oidc' => [ + 'auto_provision' => true, // default: true + 'soft_auto_provision' => true, // default: true +], +``` + +* When `soft_auto_provision` is enabled + * If the user already exists in another backend, we don't create a new one in the user_oidc backend. + We update the information (mapped attributes) of the existing user. + If the user does not exist in another backend, we create it in the user_oidc backend +* When `soft_auto_provision` is disabled + * We refuse Oidc login of users that already exist in other backends + ### UserInfo request for Bearer token validation The OIDC tokens used to make API call to Nextcloud might have been generated by an external entity.