diff --git a/config/authentication.yaml b/config/authentication.yaml index b6548576950..8bff154e7f3 100644 --- a/config/authentication.yaml +++ b/config/authentication.yaml @@ -13,12 +13,6 @@ parameters: urlAccessToken: '' urlResourceOwnerDetails: '' responseResourceOwnerId: 'sub' - # accessTokenMethod: 'POST' - # responseError: 'error' - # responseCode: '' - # scopeSeparator: ' ' - scopes: - - openid allow_create_new_users: true allow_update_user_info: false resource_owner_username_field: null @@ -38,8 +32,7 @@ parameters: title: 'Facebook' client_id: '' client_secret: '' - graph_api_version: 'v20.0' - redirect_params: { } + #graph_api_version: 'v20.0' keycloak: enabled: false @@ -48,8 +41,10 @@ parameters: client_secret: '' auth_server_url: '' realm: '' - version: '' - encryption_algorithm: null - encryption_key_path: null - encryption_key: null - redirect_params: { } + #version: '' + + azure: + enabled: false + title: 'Azure' + client_id: '' + client_secret: '' diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 587858bc464..862b46b5ed4 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -5,6 +5,10 @@ knpu_oauth2_client: provider_class: League\OAuth2\Client\Provider\GenericProvider client_id: '' client_secret: '' + provider_options: + responseResourceOwnerId: 'sub' + scopes: + - openid redirect_route: chamilo.oauth2_generic_check facebook: @@ -12,16 +16,20 @@ knpu_oauth2_client: client_id: '' client_secret: '' redirect_route: chamilo.oauth2_facebook_check - graph_api_version: '' - redirect_params: { } + graph_api_version: 'v20.0' keycloak: type: keycloak client_id: '' client_secret: '' redirect_route: chamilo.oauth2_keycloak_check - redirect_params: { } auth_server_url: null realm: null + azure: + type: azure + client_id: '' + redirect_route: chamilo.oauth2_azure_check + client_secret: ' ' + # 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 17642dff65b..21b9f98dd8e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -118,6 +118,7 @@ security: - Chamilo\CoreBundle\Security\Authenticator\OAuth2\GenericAuthenticator - Chamilo\CoreBundle\Security\Authenticator\OAuth2\FacebookAuthenticator - Chamilo\CoreBundle\Security\Authenticator\OAuth2\KeycloakAuthenticator + - Chamilo\CoreBundle\Security\Authenticator\OAuth2\AzureAuthenticator access_control: - {path: ^/login, roles: PUBLIC_ACCESS} diff --git a/src/CoreBundle/Controller/OAuth2/AzureProviderController.php b/src/CoreBundle/Controller/OAuth2/AzureProviderController.php new file mode 100644 index 00000000000..3ca7bd2f791 --- /dev/null +++ b/src/CoreBundle/Controller/OAuth2/AzureProviderController.php @@ -0,0 +1,26 @@ +getStartResponse('azure', $clientRegistry, $authenticationConfigHelper); + } + + #[Route('/connect/azure/check', name: 'chamilo.oauth2_azure_check')] + public function connectCheck(): void {} +} \ No newline at end of file diff --git a/src/CoreBundle/DataFixtures/ExtraFieldFixtures.php b/src/CoreBundle/DataFixtures/ExtraFieldFixtures.php index 806b26653e8..2c9b8f0a636 100644 --- a/src/CoreBundle/DataFixtures/ExtraFieldFixtures.php +++ b/src/CoreBundle/DataFixtures/ExtraFieldFixtures.php @@ -8,6 +8,7 @@ use Chamilo\CoreBundle\Entity\ExtraField; use Chamilo\CoreBundle\Entity\ExtraFieldOptions; +use Chamilo\CoreBundle\ServiceHelper\AzureAuthenticatorHelper; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Persistence\ObjectManager; @@ -599,6 +600,24 @@ public static function getExtraFields(): array 'item_type' => ExtraField::QUESTION_FIELD_TYPE, 'value_type' => ExtraField::FIELD_TYPE_INTEGER, ], + [ + 'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_ORGANISATION_EMAIL, + 'display_text' => 'Organisation e-mail', + 'item_type' => ExtraField::USER_FIELD_TYPE, + 'value_type' => ExtraField::FIELD_TYPE_TEXT, + ], + [ + 'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_AZURE_ID, + 'display_text' => 'Azure ID (mailNickname)', + 'item_type' => ExtraField::USER_FIELD_TYPE, + 'value_type' => ExtraField::FIELD_TYPE_TEXT, + ], + [ + 'variable' => AzureAuthenticatorHelper::EXTRA_FIELD_AZURE_UID, + 'display_text' => 'Azure UID (internal ID)', + 'item_type' => ExtraField::USER_FIELD_TYPE, + 'value_type' => ExtraField::FIELD_TYPE_TEXT, + ], ]; } diff --git a/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php b/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php index 43727e4d4a3..430f4ed51bb 100644 --- a/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php +++ b/src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php @@ -7,15 +7,14 @@ namespace Chamilo\CoreBundle\Decorator; use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper; -use KnpU\OAuth2ClientBundle\DependencyInjection\KnpUOAuth2ClientExtension; use KnpU\OAuth2ClientBundle\DependencyInjection\ProviderFactory; -use KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle; use League\OAuth2\Client\Provider\AbstractProvider; use League\OAuth2\Client\Provider\Facebook; use League\OAuth2\Client\Provider\GenericProvider; use Stevenmaguire\OAuth2\Client\Provider\Keycloak; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; +use TheNetworg\OAuth2\Client\Provider\Azure; #[AsDecorator(decorates: 'knpu.oauth2.provider_factory')] readonly class OAuth2ProviderFactoryDecorator @@ -33,22 +32,31 @@ public function createProvider( array $redirectParams = [], array $collaborators = [] ): AbstractProvider { - $options = match ($class) { - GenericProvider::class => $this->getProviderOptions('generic'), - Facebook::class => $this->getProviderOptions('facebook'), - Keycloak::class => $this->getProviderOptions('keycloak'), + $customConfig = match ($class) { + GenericProvider::class => $this->authenticationConfigHelper->getProviderConfig('generic'), + Facebook::class => $this->authenticationConfigHelper->getProviderConfig('facebook'), + Keycloak::class => $this->authenticationConfigHelper->getProviderConfig('keycloak'), + Azure::class => $this->authenticationConfigHelper->getProviderConfig('azure'), }; - return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators); - } - - private function getProviderOptions(string $providerName): array - { - /** @var KnpUOAuth2ClientExtension $extension */ - $extension = (new KnpUOAuth2ClientBundle())->getContainerExtension(); + $redirectParams = $customConfig['redirect_params'] ?? []; + + $customOptions = match ($class) { + GenericProvider::class => $this->authenticationConfigHelper->getProviderOptions( + 'generic', + [ + 'client_id' => $customConfig['client_id'], + 'client_secret' => $customConfig['client_secret'], + ...$customConfig['provider_options'], + ], + ), + Facebook::class => $this->authenticationConfigHelper->getProviderOptions('facebook', $customConfig), + Keycloak::class => $this->authenticationConfigHelper->getProviderOptions('keycloak', $customConfig), + Azure::class => $this->authenticationConfigHelper->getProviderOptions('azure', $customConfig), + }; - $configParams = $this->authenticationConfigHelper->getParams($providerName); + $options = $customOptions + $options; - return $extension->getConfigurator($providerName)->getProviderOptions($configParams); + return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators); } } diff --git a/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php b/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php new file mode 100644 index 00000000000..d57a88e77cd --- /dev/null +++ b/src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php @@ -0,0 +1,83 @@ +attributes->get('_route'); + } + + /** + * @throws NonUniqueResultException + */ + protected function userLoader(AccessToken $accessToken): User + { + /** @var Azure $provider */ + $provider = $this->client->getOAuth2Provider(); + + $me = $provider->get('/me', $accessToken); + + if (empty($me['mail'])) { + throw new UnauthorizedHttpException( + 'The mail field is empty in Azure AD and is needed to set the organisation email for this user.' + ); + } + + if (empty($me['mailNickname'])) { + throw new UnauthorizedHttpException( + 'The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.' + ); + } + + if (empty($me['objectId'])) { + throw new UnauthorizedHttpException( + 'The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.' + ); + } + + $userId = $this->azureHelper->registerUser($me); + + return $this->userRepository->find($userId); + } +} \ No newline at end of file diff --git a/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php b/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php index 165968b5446..bb87d3b5da3 100644 --- a/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php +++ b/src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php @@ -62,7 +62,7 @@ public function supports(Request $request): ?bool protected function userLoader(AccessToken $accessToken): User { - $providerParams = $this->authenticationConfigHelper->getParams('generic'); + $providerParams = $this->authenticationConfigHelper->getProviderConfig('generic'); /** @var GenericResourceOwner $resourceOwner */ $resourceOwner = $this->client->fetchUserFromToken($accessToken); diff --git a/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php index 8dbc1a09fa1..270b8bd9d1f 100644 --- a/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php +++ b/src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php @@ -21,7 +21,7 @@ public function __construct( private UrlGeneratorInterface $urlGenerator, ) {} - public function getParams(string $providerName, ?AccessUrl $url = null): array + public function getProviderConfig(string $providerName, ?AccessUrl $url = null): array { $providers = $this->getProvidersForUrl($url); @@ -34,7 +34,7 @@ public function getParams(string $providerName, ?AccessUrl $url = null): array public function isEnabled(string $methodName, ?AccessUrl $url = null): bool { - $configParams = $this->getParams($methodName, $url); + $configParams = $this->getProviderConfig($methodName, $url); return $configParams['enabled'] ?? false; } @@ -50,7 +50,7 @@ public function getEnabledProviders(?AccessUrl $url = null): array $enabledProviders[] = [ 'name' => $providerName, 'title' => $providerParams['title'] ?? u($providerName)->title(), - 'url' => $this->urlGenerator->generate("chamilo.oauth2_{$providerName}_start"), + 'url' => $this->urlGenerator->generate(sprintf("chamilo.oauth2_%s_start", $providerName)), ]; } } @@ -74,4 +74,58 @@ private function getProvidersForUrl(?AccessUrl $url): array throw new InvalidArgumentException('Invalid access URL configuration'); } + + public function getProviderOptions(string $providerType, array $config): array + { + $defaults = match($providerType) { + 'generic' => [ + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'urlAuthorize' => $config['urlAuthorize'], + 'urlAccessToken' => $config['urlAccessToken'], + 'urlResourceOwnerDetails' => $config['urlResourceOwnerDetails'], + 'accessTokenMethod' => $config['accessTokenMethod'] ?? null, + 'accessTokenResourceOwnerId' => $config['accessTokenResourceOwnerId'] ?? null, + 'scopeSeparator' => $config['scopeSeparator'] ?? null, + 'responseError' => $config['responseError'] ?? null, + 'responseCode' => $config['responseCode'] ?? null, + 'responseResourceOwnerId' => $config['responseResourceOwnerId'] ?? null, + 'scopes' => $config['scopes'] ?? null, + 'pkceMethod' => $config['pkceMethod'] ?? null, + ], + 'facebook' => [ + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'graphApiVersion' => $config['graph_api_version'] ?? null, + ], + 'keycloak' => [ + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'authServerUrl' => $config['auth_server_url'], + 'realm' => $config['realm'], + 'version' => $config['version'] ?? null, + 'encryptionAlgorithm' => $config['encryption_algorithm'] ?? null, + 'encryptionKeyPath' => $config['encryption_key_path'] ?? null, + 'encryptionKey' => $config['encryption_key'] ?? null, + ], + 'azure' => [ + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'clientCertificatePrivateKey' => $config['client_certificate_private_key'] ?? null, + 'clientCertificateThumbprint' => $config['client_certificate_thumbprint'] ?? null, + 'urlLogin' => $config['url_login'] ?? null, + 'pathAuthorize' => $config['path_authorize'] ?? null, + 'pathToken' => $config['path_token'] ?? null, + 'scope' => $config['scope'] ?? null, + 'tenant' => $config['tenant'] ?? null, + 'urlAPI' => $config['url_api'] ?? null, + 'resource' => $config['resource'] ?? null, + 'API_VERSION' => $config['api_version'] ?? null, + 'authWithResource' => $config['auth_with_resource'] ?? null, + 'defaultEndPointVersion' => $config['default_end_point_version'] ?? null, + ], + }; + + return array_filter($defaults, fn($value) => $value !== null); + } } diff --git a/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php b/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php new file mode 100644 index 00000000000..8fa1a11563e --- /dev/null +++ b/src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php @@ -0,0 +1,198 @@ +formatUserData($azureUserInfo, $azureUidKey); + + $userId = $this->getUserIdByVerificationOrder($azureUserInfo, $azureUidKey); + + if (empty($userId)) { + $user = (new User()) + ->setCreatorId($this->userRepository->getRootUser()->getId()) + ; + } else { + $user = $this->userRepository->find($userId); + } + + $user + ->setFirstname($firstNme) + ->setLastname($lastName) + ->setEmail($email) + ->setUsername($username) + ->setPlainPassword('azure') + ->setStatus(STUDENT) + ->setAuthSource($authSource) + ->setPhone($phone) + ->setActive($active) + ->setRoleFromStatus(STUDENT) + ; + + $this->userRepository->updateUser($user); + + $url = $this->urlHelper->getCurrent(); + $url->addUser($user); + + $this->entityManager->flush(); + + $this->extraFieldValuesRepo->updateItemData( + $this->getOrganizationEmailField(), + $user, + $extra['extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL] + ); + + $this->extraFieldValuesRepo->updateItemData( + $this->getAzureIdField(), + $user, + $extra['extra_'.self::EXTRA_FIELD_AZURE_ID] + ); + + $this->extraFieldValuesRepo->updateItemData( + $this->getAzureUidField(), + $user, + $extra['extra_'.self::EXTRA_FIELD_AZURE_UID] + ); + + return $user; + } + + private function getOrganizationEmailField() + { + return $this->extraFieldRepo->findByVariable( + ExtraField::USER_FIELD_TYPE, + self::EXTRA_FIELD_ORGANISATION_EMAIL + ); + } + + private function getAzureIdField() + { + return $this->extraFieldRepo->findByVariable( + ExtraField::USER_FIELD_TYPE, + self::EXTRA_FIELD_AZURE_ID + ); + } + + private function getAzureUidField() + { + return $this->extraFieldRepo->findByVariable( + ExtraField::USER_FIELD_TYPE, + self::EXTRA_FIELD_AZURE_UID + ); + } + + /** + * @throws NonUniqueResultException + */ + public function getUserIdByVerificationOrder(array $azureUserData, string $azureUidKey = 'objectId'): ?int + { + $selectedOrder = $this->getExistingUserVerificationOrder(); + + $organisationEmailField = $this->getOrganizationEmailField(); + $azureIdField = $this->getAzureIdField(); + $azureUidField = $this->getAzureUidField(); + + /** @var array $positionsAndFields */ + $positionsAndFields = [ + 1 => $this->extraFieldValuesRepo->findByVariableAndValue($organisationEmailField, $azureUserData['mail']), + 2 => $this->extraFieldValuesRepo->findByVariableAndValue($azureIdField, $azureUserData['mailNickname']), + 3 => $this->extraFieldValuesRepo->findByVariableAndValue($azureUidField, $azureUserData[$azureUidKey]), + ]; + + foreach ($selectedOrder as $position) { + if (!empty($positionsAndFields[$position])) { + return $positionsAndFields[$position]->getItemId(); + } + } + + return null; + } + + public function getExistingUserVerificationOrder(): array + { + return [1, 2, 3]; + } + + private function formatUserData( + array $azureUserData, + string $azureUidKey + ): array { + $phone = null; + + if (isset($azureUserData['telephoneNumber'])) { + $phone = $azureUserData['telephoneNumber']; + } elseif (isset($azureUserData['businessPhones'][0])) { + $phone = $azureUserData['businessPhones'][0]; + } elseif (isset($azureUserData['mobilePhone'])) { + $phone = $azureUserData['mobilePhone']; + } + + // If the option is set to create users, create it + $firstNme = $azureUserData['givenName']; + $lastName = $azureUserData['surname']; + $email = $azureUserData['mail']; + $username = $azureUserData['userPrincipalName']; + $authSource = 'azure'; + $active = ($azureUserData['accountEnabled'] ? 1 : 0); + $extra = [ + 'extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL => $azureUserData['mail'], + 'extra_'.self::EXTRA_FIELD_AZURE_ID => $azureUserData['mailNickname'], + 'extra_'.self::EXTRA_FIELD_AZURE_UID => $azureUserData[$azureUidKey], + ]; + + return [ + $firstNme, + $lastName, + $username, + $email, + $phone, + $authSource, + $active, + $extra, + ]; + } +} \ No newline at end of file