From 21d5f05b0dfedb874ec8b0ea106d9e29c02c479d Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 26 Aug 2024 05:35:07 -0500 Subject: [PATCH] SSO: Implement generic OAuth2 login/registration - refs BT#21881 --- config/authentication.yaml | 29 +++ config/packages/knpu_oauth2_client.yaml | 7 + config/packages/security.yaml | 3 + config/services.yaml | 1 + .../OAuth2/AbstractProviderController.php | 27 +++ .../OAuth2/GenericProviderController.php | 26 +++ .../OAuth2ProviderFactoryDecorator.php | 50 +++++ .../OAuth2/AbstractAuthenticator.php | 80 +++++++ .../OAuth2/GenericAuthenticator.php | 201 ++++++++++++++++++ .../AuthenticationConfigHelper.php | 43 ++++ 10 files changed, 467 insertions(+) create mode 100644 config/authentication.yaml create mode 100644 src/CoreBundle/Controller/OAuth2/AbstractProviderController.php create mode 100644 src/CoreBundle/Controller/OAuth2/GenericProviderController.php create mode 100644 src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php create mode 100644 src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php create mode 100644 src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php create mode 100644 src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php diff --git a/config/authentication.yaml b/config/authentication.yaml new file mode 100644 index 0000000000..3dda760fe5 --- /dev/null +++ b/config/authentication.yaml @@ -0,0 +1,29 @@ +# authentication configuration for each Access URL +# Access URL Id / authentication method / params +parameters: + authentication: + 1: + generic: + enabled: false + client_id: '' + client_secret: '' + provider_options: + urlAuthorize: '' + urlAccessToken: '' + urlResourceOwnerDetails: '' + responseResourceOwnerId: 'sub' + accessTokenMethod: 'POST' + scopes: + - openid + allow_create_new_users: true + allow_update_user_info: false + resource_owner_username_field: null + resource_owner_firstname_field: null + resource_owner_lastname_field: null + resource_owner_email_field: null + resource_owner_status_field: null + resource_owner_teacher_status_field: null + resource_owner_sessadmin_status_field: null + resource_owner_hr_status_field: null + resource_owner_status_status_field: null + resource_owner_anon_status_field: null diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 05e8533996..f1a91e08e6 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -1,3 +1,10 @@ knpu_oauth2_client: clients: + generic: + type: generic + provider_class: League\OAuth2\Client\Provider\GenericProvider + client_id: '' + client_secret: '' + redirect_route: chamilo.oauth2_generic_check + # configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 4adfcabd9d..65c1132615 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -114,6 +114,9 @@ security: # username_path: security.credentials.login # password_path: security.credentials.password + custom_authenticators: + - Chamilo\CoreBundle\Security\Authenticator\OAuth2\GenericAuthenticator + access_control: - {path: ^/login, roles: PUBLIC_ACCESS} - {path: ^/api/authentication_token, roles: PUBLIC_ACCESS} diff --git a/config/services.yaml b/config/services.yaml index c3930249dd..bfd2deba75 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -117,3 +117,4 @@ cocur_slugify: imports: - {resource: ../src/CoreBundle/Resources/config/services.yml} - {resource: ../src/LtiBundle/Resources/config/services.yml} + - { resource: ./authentication.yaml } diff --git a/src/CoreBundle/Controller/OAuth2/AbstractProviderController.php b/src/CoreBundle/Controller/OAuth2/AbstractProviderController.php new file mode 100644 index 0000000000..584994490d --- /dev/null +++ b/src/CoreBundle/Controller/OAuth2/AbstractProviderController.php @@ -0,0 +1,27 @@ +isEnabled($providerName)) { + throw $this->createAccessDeniedException(); + } + + return $clientRegistry->getClient($providerName)->redirect(); + } +} diff --git a/src/CoreBundle/Controller/OAuth2/GenericProviderController.php b/src/CoreBundle/Controller/OAuth2/GenericProviderController.php new file mode 100644 index 0000000000..8b597231d6 --- /dev/null +++ b/src/CoreBundle/Controller/OAuth2/GenericProviderController.php @@ -0,0 +1,26 @@ +getStartResponse('generic', $clientRegistry, $authenticationConfigHelper); + } + + #[Route('/connect/generic/check', name: 'chamilo.oauth2_generic_check')] + public function connectCheck(): void {} +} diff --git a/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php b/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php new file mode 100644 index 0000000000..5e81b597af --- /dev/null +++ b/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php @@ -0,0 +1,50 @@ + $this->getProviderOptions('generic'), + }; + + return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators); + } + + private function getProviderOptions(string $providerName): array + { + /** @var KnpUOAuth2ClientExtension $extension */ + $extension = (new KnpUOAuth2ClientBundle())->getContainerExtension(); + + $configParams = $this->authenticationConfigHelper->getParams($providerName); + + return $extension->getConfigurator($providerName)->getProviderOptions($configParams); + } +} diff --git a/src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php b/src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php new file mode 100644 index 0000000000..a2c2a3db0f --- /dev/null +++ b/src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php @@ -0,0 +1,80 @@ +client = $this->clientRegistry->getClient($this->providerName); + } + + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + $targetUrl = $this->router->generate('login'); + + return new RedirectResponse($targetUrl); + } + + abstract public function supports(Request $request): ?bool; + + public function authenticate(Request $request): Passport + { + /** @var AccessToken $accessToken */ + $accessToken = $this->fetchAccessToken($this->client); + + $user = $this->userLoader($accessToken); + + return new SelfValidatingPassport( + new UserBadge( + $user->getUserIdentifier() + ), + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + $targetUrl = $this->router->generate('index'); + + return new RedirectResponse($targetUrl); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $message = strtr($exception->getMessage(), $exception->getMessageData()); + + return new Response($message, Response::HTTP_FORBIDDEN); + } + + abstract protected function userLoader(AccessToken $accessToken): User; +} diff --git a/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php b/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php new file mode 100644 index 0000000000..a938edcb9c --- /dev/null +++ b/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php @@ -0,0 +1,201 @@ +attributes->get('_route'); + } + + protected function userLoader(AccessToken $accessToken): User + { + $providerParams = $this->authenticationConfigHelper->getParams('generic'); + + /** @var GenericResourceOwner $resourceOwner */ + $resourceOwner = $this->client->fetchUserFromToken($accessToken); + $resourceOwnerData = $resourceOwner->toArray(); + $resourceOwnerId = $resourceOwner->getId(); + + if (empty($resourceOwnerId)) { + throw new UnexpectedValueException('Value for the resource owner identifier not found at the configured key'); + } + + $fieldType = (int) ExtraField::getExtraFieldTypeFromString('user'); + $extraField = $this->extraFieldRepository->findByVariable($fieldType, self::EXTRA_FIELD_OAUTH2_ID); + + $existingUserExtraFieldValue = $this->extraFieldValuesRepository->findByVariableAndValue( + $extraField, + $resourceOwnerId + ); + + if (null === $existingUserExtraFieldValue) { + $username = $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_username_field'], + "oauth2user_$resourceOwnerId" + ); + + /** @var User $user */ + $user = $this->userRepository->findOneBy(['username' => $username]); + + if (!$user || 'platform' !== $user->getAuthSource()) { + if (!$providerParams['allow_create_new_users']) { + throw new AuthenticationException('This user doesn\'t have an account yet and auto-provisioning is not enabled. Please contact this portal administration team to request access.'); + } + + // set default values, real values are set in self::updateUserInfo method + $user = (new User()) + ->setFirstname('OAuth2 User default firstname') + ->setLastname('OAuth2 User default firstname') + ->setEmail('oauth2user_'.$resourceOwnerId.'@'.(gethostname() or 'localhost')) + ->setUsername($username) + ->setPlainPassword($username) + ->setStatus(STUDENT) + ->setCreatorId($this->userRepository->getRootUser()->getId()) + ; + } + + $this->saveUserInfo($user, $resourceOwnerData, $providerParams); + + $this->extraFieldValuesRepository->updateItemData( + $extraField, + $user, + $resourceOwnerId + ); + } else { + /** @var User $user */ + $user = $this->userRepository->find( + $existingUserExtraFieldValue->getItemId() + ); + + if ($providerParams['allow_update_user_info']) { + $this->saveUserInfo($user, $resourceOwnerData, $providerParams); + } + } + + return $user; + } + + /** + * Set user information from the resource owner's data or the user itself + */ + public function saveUserInfo(User $user, array $resourceOwnerData, array $providerParams): void + { + $status = $this->getUserStatus($resourceOwnerData, $user->getStatus(), $providerParams); + + $user + ->setFirstname( + $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_firstname_field'], + $user->getFirstname() + ) + ) + ->setLastname( + $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_lastname_field'], + $user->getLastname() + ) + ) + ->setUsername( + $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_username_field'], + $user->getUsername() + ) + ) + ->setEmail( + $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_email_field'], + $user->getEmail() + ) + ) + ->setAuthSource('oauth2') + ->setStatus($status) + ->setRoleFromStatus($status) + ; + + $this->userRepository->updateUser($user); + // updateAccessUrls ? + } + + private function getUserStatus(array $resourceOwnerData, int $defaultStatus, array $providerParams): int + { + $status = $this->getValueByKey( + $resourceOwnerData, + $providerParams['resource_owner_status_field'], + $defaultStatus + ); + + $responseStatus = []; + + if ($teacherStatus = $providerParams['resource_owner_teacher_status_field']) { + $responseStatus[COURSEMANAGER] = $teacherStatus; + } + + if ($sessAdminStatus = $providerParams['resource_owner_sessadmin_status_field']) { + $responseStatus[SESSIONADMIN] = $sessAdminStatus; + } + + if ($drhStatus = $providerParams['resource_owner_hr_status_field']) { + $responseStatus[DRH] = $drhStatus; + } + + if ($studentStatus = $providerParams['resource_owner_status_status_field']) { + $responseStatus[STUDENT] = $studentStatus; + } + + if ($anonStatus = $providerParams['resource_owner_anon_status_field']) { + $responseStatus[ANONYMOUS] = $anonStatus; + } + + $map = array_flip($responseStatus); + + return $map[$status] ?? $status; + } +} diff --git a/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php new file mode 100644 index 0000000000..a3b08e254c --- /dev/null +++ b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php @@ -0,0 +1,43 @@ +getId() : $this->urlHelper->getCurrent()->getId(); + + $authentication = $this->parameterBag->get('authentication'); + + if (!isset($authentication[$urlId])) { + throw new InvalidArgumentException('Invalid access URL Id'); + } + + if (!isset($authentication[$urlId][$providerName])) { + throw new InvalidArgumentException('Invalid authentication source'); + } + + return $authentication[$urlId][$providerName]; + } + + public function isEnabled(string $methodName, ?AccessUrl $url = null): bool + { + $configParams = $this->getParams($methodName, $url); + + return $configParams['enabled'] ?? false; + } +}