diff --git a/main/auth/external_login/login.azure.php b/main/auth/external_login/login.azure.php index 95acea4530f..e00da56c878 100644 --- a/main/auth/external_login/login.azure.php +++ b/main/auth/external_login/login.azure.php @@ -12,6 +12,16 @@ api_not_allowed(true); } + $uidField = new ExtraFieldValue('user'); + $uidValue = $uidField->get_values_by_handler_and_field_variable( + $uData['user_id'], + AzureActiveDirectory::EXTRA_FIELD_AZURE_UID + ); + + if (empty($uidValue) || empty($uidValue['value'])) { + api_not_allowed(true); + } + $azureIdField = new ExtraFieldValue('user'); $azureIdValue = $azureIdField->get_values_by_handler_and_field_variable( $uData['user_id'], diff --git a/main/inc/lib/usermanager.lib.php b/main/inc/lib/usermanager.lib.php index 183f082b298..2893af7fd1e 100755 --- a/main/inc/lib/usermanager.lib.php +++ b/main/inc/lib/usermanager.lib.php @@ -6250,7 +6250,7 @@ public static function get_favicon_from_url($url1, $url2 = null) return $icon_link; } - public static function addUserAsAdmin(User $user) + public static function addUserAsAdmin(User $user, bool $andFlush = true) { if ($user) { $userId = $user->getId(); @@ -6261,11 +6261,11 @@ public static function addUserAsAdmin(User $user) } $user->addRole('ROLE_SUPER_ADMIN'); - self::getManager()->updateUser($user, true); + self::getManager()->updateUser($user, $andFlush); } } - public static function removeUserAdmin(User $user) + public static function removeUserAdmin(User $user, bool $andFlush = true) { $userId = (int) $user->getId(); if (self::is_admin($userId)) { @@ -6273,7 +6273,7 @@ public static function removeUserAdmin(User $user) $sql = "DELETE FROM $table WHERE user_id = $userId"; Database::query($sql); $user->removeRole('ROLE_SUPER_ADMIN'); - self::getManager()->updateUser($user, true); + self::getManager()->updateUser($user, $andFlush); } } diff --git a/plugin/azure_active_directory/CHANGELOG.md b/plugin/azure_active_directory/CHANGELOG.md index 8185067329d..cb19ecdeb9a 100644 --- a/plugin/azure_active_directory/CHANGELOG.md +++ b/plugin/azure_active_directory/CHANGELOG.md @@ -1,5 +1,26 @@ # Azure Active Directory Changelog +## 2.4 - 2024-08-28 + +* Added a new user extra field to save the unique Azure ID (internal UID). +This requires manually doing the following changes to your database if you are upgrading from v2.3 +```sql +INSERT INTO extra_field (extra_field_type, field_type, variable, display_text, default_value, field_order, visible_to_self, visible_to_others, changeable, filter, created_at) VALUES (1, 1, 'azure_uid', 'Azure UID (internal ID)', '', 1, null, null, null, null, '2024-08-28 00:00:00'); +``` +* Added a new option to set the order to verify the existing user in Chamilo +```sql +INSERT INTO settings_current (variable, subkey, type, category, selected_value, title, comment, scope, subkeytext, access_url, access_url_changeable, access_url_locked) VALUES ('azure_active_directory_existing_user_verification_order', 'azure_active_directory', 'setting', 'Plugins', '', 'azure_active_directory', '', '', '', 1, 1, 0); +``` +* Added a new option to update user info during the login proccess. +```sql +INSERT INTO settings_current (variable, subkey, type, category, selected_value, title, comment, scope, subkeytext, access_url, access_url_changeable, access_url_locked) VALUES ('azure_active_directory_update_users', 'azure_active_directory', 'setting', 'Plugins', '', 'azure_active_directory', '', '', '', 1, 1, 0); +``` +* Added new scripts to syncronize users and groups with users and usergroups (classes). And an option to deactivate accounts in Chamilo that do not exist in Azure. +```sql +INSERT INTO settings_current (variable, subkey, type, category, selected_value, title, comment, scope, subkeytext, access_url, access_url_changeable, access_url_locked) VALUES ('azure_active_directory_tenant_id', 'azure_active_directory', 'setting', 'Plugins', '', 'azure_active_directory', '', '', '', 1, 1, 0); +INSERT INTO settings_current (variable, subkey, type, category, selected_value, title, comment, scope, subkeytext, access_url, access_url_changeable, access_url_locked) VALUES ('azure_active_directory_deactivate_nonexisting_users', 'azure_active_directory', 'setting', 'Plugins', '', 'azure_active_directory', '', '', '', 1, 1, 0); +``` + ## 2.3 - 2021-03-30 * Added admin, session admin and teacher groups. This requires adding the following fields to your database if diff --git a/plugin/azure_active_directory/lang/dutch.php b/plugin/azure_active_directory/lang/dutch.php index 7f3e03e5631..32885f3f68c 100644 --- a/plugin/azure_active_directory/lang/dutch.php +++ b/plugin/azure_active_directory/lang/dutch.php @@ -22,11 +22,18 @@ .'U zult moeten kopiëren de /plugin/azure_active_directory/layout/login_form.tpl bestand in het /main/template/overrides/layout/ dossier.'; $strings['management_login_name'] = 'Naam voor de beheeraanmelding'; $strings['management_login_name_help'] = 'De standaardinstelling is "Beheer login".'; +$strings['existing_user_verification_order'] = 'Existing user verification order'; +$strings['existing_user_verification_order_help'] = 'This value indicates the order in which the user will be searched in Chamilo to verify its existence. ' + .'By default is 1, 2, 3.' + .'
  1. EXTRA_FIELD_ORGANISATION_EMAIL (mail)
  2. EXTRA_FIELD_AZURE_ID (mailNickname)
  3. EXTRA_FIELD_AZURE_UID (id of objectId)
'; $strings['OrganisationEmail'] = 'Organisatie e-mail'; $strings['AzureId'] = 'Azure ID (mailNickname)'; +$strings['AzureUid'] = 'Azure UID (internal ID)'; $strings['ManagementLogin'] = 'Beheer Login'; $strings['InvalidId'] = 'Deze identificatie is niet geldig (verkeerde log-in of wachtwoord). Errocode: AZMNF'; $strings['provisioning'] = 'Geautomatiseerde inrichting'; +$strings['update_users'] = 'Update users'; +$strings['update_users_help'] = 'Allow user data to be updated at the start of the session.'; $strings['provisioning_help'] = 'Maak automatisch nieuwe gebruikers (als studenten) vanuit Azure wanneer ze niet in Chamilo zijn.'; $strings['group_id_admin'] = 'Groeps-ID voor platformbeheerders'; $strings['group_id_admin_help'] = 'De groeps-ID is te vinden in de details van de gebruikersgroep en ziet er ongeveer zo uit: ae134eef-cbd4-4a32-ba99-49898a1314b6. Indien leeg, wordt er automatisch geen gebruiker aangemaakt als admin.'; @@ -35,3 +42,7 @@ $strings['group_id_teacher'] = 'Groeps-ID voor docenten'; $strings['group_id_teacher_help'] = 'De groeps-ID voor docenten. Indien leeg, wordt er automatisch geen gebruiker aangemaakt als docent.'; $strings['additional_interaction_required'] = 'Er is aanvullende interactie vereist om u te authenticeren. Log rechtstreeks in via uw authenticatiesysteem en kom dan terug naar deze pagina om in te loggen.'; +$strings['tenant_id'] = 'Mandanten-ID'; +$strings['tenant_id_help'] = 'Required to run scripts.'; +$strings['deactivate_nonexisting_users'] = 'Deactivate non-existing users'; +$strings['deactivate_nonexisting_users_help'] = 'Compare registered users in Chamilo with those in Azure and deactivate accounts in Chamilo that do not exist in Azure.'; diff --git a/plugin/azure_active_directory/lang/english.php b/plugin/azure_active_directory/lang/english.php index 5faf822a740..0d000a7d797 100644 --- a/plugin/azure_active_directory/lang/english.php +++ b/plugin/azure_active_directory/lang/english.php @@ -22,12 +22,19 @@ .'You will need to copy the /plugin/azure_active_directory/layout/login_form.tpl file to /main/template/overrides/layout/ directory.'; $strings['management_login_name'] = 'Name for the management login'; $strings['management_login_name_help'] = 'The default is "Management Login".'; +$strings['existing_user_verification_order'] = 'Existing user verification order'; +$strings['existing_user_verification_order_help'] = 'This value indicates the order in which the user will be searched in Chamilo to verify its existence. ' + .'By default is 1, 2, 3.' + .'
  1. EXTRA_FIELD_ORGANISATION_EMAIL (mail)
  2. EXTRA_FIELD_AZURE_ID (mailNickname)
  3. EXTRA_FIELD_AZURE_UID (id or objectId)
'; $strings['OrganisationEmail'] = 'Organisation e-mail'; $strings['AzureId'] = 'Azure ID (mailNickname)'; +$strings['AzureUid'] = 'Azure UID (internal ID)'; $strings['ManagementLogin'] = 'Management Login'; $strings['InvalidId'] = 'Login failed - incorrect login or password. Errocode: AZMNF'; $strings['provisioning'] = 'Automated provisioning'; $strings['provisioning_help'] = 'Automatically create new users (as students) from Azure when they are not in Chamilo.'; +$strings['update_users'] = 'Update users'; +$strings['update_users_help'] = 'Allow user data to be updated at the start of the session.'; $strings['group_id_admin'] = 'Group ID for platform admins'; $strings['group_id_admin_help'] = 'The group ID can be found in the user group details, looking similar to this: ae134eef-cbd4-4a32-ba99-49898a1314b6. If empty, no user will be automatically created as admin.'; $strings['group_id_session_admin'] = 'Group ID for session admins'; @@ -35,3 +42,7 @@ $strings['group_id_teacher'] = 'Group ID for teachers'; $strings['group_id_teacher_help'] = 'The group ID for teachers. If empty, no user will be automatically created as teacher.'; $strings['additional_interaction_required'] = 'Some additional interaction is required to authenticate you. Please login directly through your authentication system, then come back to this page to login.'; +$strings['tenant_id'] = 'Tenant ID'; +$strings['tenant_id_help'] = 'Required to run scripts.'; +$strings['deactivate_nonexisting_users'] = 'Deactivate non-existing users'; +$strings['deactivate_nonexisting_users_help'] = 'Compare registered users in Chamilo with those in Azure and deactivate accounts in Chamilo that do not exist in Azure.'; diff --git a/plugin/azure_active_directory/lang/french.php b/plugin/azure_active_directory/lang/french.php index 2fdb5480830..e699c6d91d2 100644 --- a/plugin/azure_active_directory/lang/french.php +++ b/plugin/azure_active_directory/lang/french.php @@ -22,12 +22,19 @@ .'Vous devez, pour cela, copier le fichier /plugin/azure_active_directory/layout/login_form.tpl dans le répertoire /main/template/overrides/layout/.'; $strings['management_login_name'] = 'Nom du login de gestion'; $strings['management_login_name_help'] = 'Le nom par défaut est "Login de gestion".'; +$strings['existing_user_verification_order'] = 'Existing user verification order'; +$strings['existing_user_verification_order_help'] = 'This value indicates the order in which the user will be searched in Chamilo to verify its existence. ' + .'By default is 1, 2, 3.' + .'
  1. EXTRA_FIELD_ORGANISATION_EMAIL (mail)
  2. EXTRA_FIELD_AZURE_ID (mailNickname)
  3. EXTRA_FIELD_AZURE_UID (id ou objectId)
'; $strings['OrganisationEmail'] = 'E-mail professionnel'; $strings['AzureId'] = 'ID Azure (mailNickname)'; +$strings['AzureUid'] = 'Azure UID (internal ID)'; $strings['ManagementLogin'] = 'Login de gestion'; $strings['InvalidId'] = 'Échec du login - nom d\'utilisateur ou mot de passe incorrect. Errocode: AZMNF'; $strings['provisioning'] = 'Création automatisée'; $strings['provisioning_help'] = 'Créer les utilisateurs automatiquement (en tant qu\'apprenants) depuis Azure s\'ils n\'existent pas encore dans Chamilo.'; +$strings['update_users'] = 'Actualiser les utilisateurs'; +$strings['update_users_help'] = 'Permettre d\'actualiser les données de l\'utilisateur lors du démarrage de la session.'; $strings['group_id_admin'] = 'ID du groupe administrateur'; $strings['group_id_admin_help'] = 'L\'id du groupe peut être trouvé dans les détails du groupe, et ressemble à ceci : ae134eef-cbd4-4a32-ba99-49898a1314b6. Si ce champ est laissé vide, aucun utilisateur ne sera créé en tant qu\'administrateur.'; $strings['group_id_session_admin'] = 'ID du groupe administrateur de sessions'; @@ -35,3 +42,7 @@ $strings['group_id_teacher'] = 'ID du groupe enseignant'; $strings['group_id_teacher_help'] = 'The group ID for teachers. Si ce champ est laissé vide, aucun utilisateur ne sera créé en tant qu\'enseignant.'; $strings['additional_interaction_required'] = 'Une interaction supplémentaire est nécessaire pour vous authentifier. Veuillez vous connecter directement auprès de votre système d\'authentification, puis revenir ici pour vous connecter.'; +$strings['tenant_id'] = 'ID du client'; +$strings['tenant_id_help'] = 'Nécessaire pour exécuter des scripts.'; +$strings['deactivate_nonexisting_users'] = 'Deactivate non-existing users'; +$strings['deactivate_nonexisting_users_help'] = 'Compare registered users in Chamilo with those in Azure and deactivate accounts in Chamilo that do not exist in Azure.'; diff --git a/plugin/azure_active_directory/lang/spanish.php b/plugin/azure_active_directory/lang/spanish.php index b6aef5cd368..389a4912e49 100644 --- a/plugin/azure_active_directory/lang/spanish.php +++ b/plugin/azure_active_directory/lang/spanish.php @@ -22,12 +22,19 @@ .'Para ello, tendrá que copiar el archivo /plugin/azure_active_directory/layout/login_form.tpl en la carpeta /main/template/overrides/layout/.'; $strings['management_login_name'] = 'Nombre del bloque de login de gestión'; $strings['management_login_name_help'] = 'El nombre por defecto es "Login de gestión".'; +$strings['existing_user_verification_order'] = 'Orden de verificación de usuario existente'; +$strings['existing_user_verification_order_help'] = 'Este valor indica el orden en que el usuario serña buscado en Chamilo para verificar su existencia. ' + .'Por defecto es 1, 2, 3.' + .'
  1. EXTRA_FIELD_ORGANISATION_EMAIL (mail)
  2. EXTRA_FIELD_AZURE_ID (mailNickname)
  3. EXTRA_FIELD_AZURE_UID (id o objectId)
'; $strings['OrganisationEmail'] = 'E-mail profesional'; $strings['AzureId'] = 'ID Azure (mailNickname)'; +$strings['AzureUid'] = 'UID Azure (ID interno)'; $strings['ManagementLogin'] = 'Login de gestión'; $strings['InvalidId'] = 'Problema en el login - nombre de usuario o contraseña incorrecto. Errocode: AZMNF'; $strings['provisioning'] = 'Creación automatizada'; $strings['provisioning_help'] = 'Crear usuarios automáticamente (como alumnos) desde Azure si no existen en Chamilo todavía.'; +$strings['update_users'] = 'Actualizar los usuarios'; +$strings['update_users_help'] = 'Permite actualizar los datos del usuario al iniciar sesión.'; $strings['group_id_admin'] = 'ID de grupo administrador'; $strings['group_id_admin_help'] = 'El ID de grupo se encuentra en los detalles del grupo en Azure, y parece a: ae134eef-cbd4-4a32-ba99-49898a1314b6. Si deja este campo vacío, ningún usuario será creado como administrador.'; $strings['group_id_session_admin'] = 'ID de grupo admin de sesiones'; @@ -35,3 +42,7 @@ $strings['group_id_teacher'] = 'ID de grupo profesor'; $strings['group_id_teacher_help'] = 'El ID de grupo para profesores. Si deja este campo vacío, ningún usuario será creado como profesor.'; $strings['additional_interaction_required'] = 'Alguna interacción adicional es necesaria para identificarlo/a. Por favor conéctese primero a través de su sistema de autenticación, luego regrese aquí para logearse.'; +$strings['tenant_id'] = 'Id. del inquilino'; +$strings['tenant_id_help'] = 'Necesario para ejecutar scripts.'; +$strings['deactivate_nonexisting_users'] = 'Desactivar usuarios no existentes'; +$strings['deactivate_nonexisting_users_help'] = 'Compara los usuarios registrados en Chamilo con los de Azure y desactiva las cuentas en Chamilo que no existan en Azure.'; diff --git a/plugin/azure_active_directory/src/AzureActiveDirectory.php b/plugin/azure_active_directory/src/AzureActiveDirectory.php index 8e0f33f3d3b..fce9cf9b689 100644 --- a/plugin/azure_active_directory/src/AzureActiveDirectory.php +++ b/plugin/azure_active_directory/src/AzureActiveDirectory.php @@ -1,6 +1,7 @@ 'boolean', self::SETTING_MANAGEMENT_LOGIN_NAME => 'text', self::SETTING_PROVISION_USERS => 'boolean', + self::SETTING_UPDATE_USERS => 'boolean', self::SETTING_GROUP_ID_ADMIN => 'text', self::SETTING_GROUP_ID_SESSION_ADMIN => 'text', self::SETTING_GROUP_ID_TEACHER => 'text', + self::SETTING_EXISTING_USER_VERIFICATION_ORDER => 'text', + self::SETTING_TENANT_ID => 'text', + self::SETTING_DEACTIVATE_NONEXISTING_USERS => 'boolean', ]; - parent::__construct('2.3', 'Angel Fernando Quiroz Campos, Yannick Warnier', $settings); + parent::__construct('2.4', 'Angel Fernando Quiroz Campos, Yannick Warnier', $settings); } /** @@ -88,6 +100,17 @@ public function getProvider() return $provider; } + public function getProviderForApiGraph(): Azure + { + $provider = $this->getProvider(); + $provider->urlAPI = "https://graph.microsoft.com/v1.0/"; + $provider->resource = "https://graph.microsoft.com/"; + $provider->tenant = $this->get(AzureActiveDirectory::SETTING_TENANT_ID); + $provider->authWithResource = false; + + return $provider; + } + /** * @param string $urlType Type of URL to generate * @@ -123,5 +146,243 @@ public function install() $this->get_lang('AzureId'), '' ); + UserManager::create_extra_field( + self::EXTRA_FIELD_AZURE_UID, + ExtraField::FIELD_TYPE_TEXT, + $this->get_lang('AzureUid'), + '' + ); + } + + public function getExistingUserVerificationOrder(): array + { + $defaultOrder = [1, 2, 3]; + + $settingValue = $this->get(self::SETTING_EXISTING_USER_VERIFICATION_ORDER); + $selectedOrder = array_filter( + array_map( + 'trim', + explode(',', $settingValue) + ) + ); + $selectedOrder = array_map('intval', $selectedOrder); + $selectedOrder = array_filter( + $selectedOrder, + function ($position) use ($defaultOrder): bool { + return in_array($position, $defaultOrder); + } + ); + + if ($selectedOrder) { + return $selectedOrder; + } + + return $defaultOrder; + } + + public function getUserIdByVerificationOrder(array $azureUserData, string $azureUidKey = 'objectId'): ?int + { + $selectedOrder = $this->getExistingUserVerificationOrder(); + + $extraFieldValue = new ExtraFieldValue('user'); + $positionsAndFields = [ + 1 => $extraFieldValue->get_item_id_from_field_variable_and_field_value( + AzureActiveDirectory::EXTRA_FIELD_ORGANISATION_EMAIL, + $azureUserData['mail'] + ), + 2 => $extraFieldValue->get_item_id_from_field_variable_and_field_value( + AzureActiveDirectory::EXTRA_FIELD_AZURE_ID, + $azureUserData['mailNickname'] + ), + 3 => $extraFieldValue->get_item_id_from_field_variable_and_field_value( + AzureActiveDirectory::EXTRA_FIELD_AZURE_UID, + $azureUserData[$azureUidKey] + ), + ]; + + foreach ($selectedOrder as $position) { + if (!empty($positionsAndFields[$position]) && isset($positionsAndFields[$position]['item_id'])) { + return (int) $positionsAndFields[$position]['item_id']; + } + } + + return null; + } + + /** + * @throws Exception + */ + public function registerUser( + array $azureUserInfo, + string $azureUidKey = 'objectId' + ) { + if (empty($azureUserInfo)) { + throw new Exception('Groups info not found.'); + } + + $userId = $this->getUserIdByVerificationOrder($azureUserInfo, $azureUidKey); + + if (empty($userId)) { + // If we didn't find the user + if ($this->get(self::SETTING_PROVISION_USERS) !== 'true') { + throw new Exception('User not found when checking the extra fields from '.$azureUserInfo['mail'].' or '.$azureUserInfo['mailNickname'].' or '.$azureUserInfo[$azureUidKey].'.'); + } + + [ + $firstNme, + $lastName, + $username, + $email, + $phone, + $authSource, + $active, + $extra, + ] = $this->formatUserData($azureUserInfo, $azureUidKey); + + // If the option is set to create users, create it + $userId = UserManager::create_user( + $firstNme, + $lastName, + STUDENT, + $email, + $username, + '', + null, + null, + $phone, + null, + $authSource, + null, + $active, + null, + $extra, + null, + null + ); + + if (!$userId) { + throw new Exception(get_lang('UserNotAdded').' '.$azureUserInfo['userPrincipalName']); + } + + return $userId; + } + + if ($this->get(self::SETTING_UPDATE_USERS) === 'true') { + [ + $firstNme, + $lastName, + $username, + $email, + $phone, + $authSource, + $active, + $extra, + ] = $this->formatUserData($azureUserInfo, $azureUidKey); + + $userId = UserManager::update_user( + $userId, + $firstNme, + $lastName, + $username, + '', + $authSource, + $email, + STUDENT, + null, + $phone, + null, + null, + $active, + null, + 0, + $extra + ); + + if (!$userId) { + throw new Exception(get_lang('CouldNotUpdateUser').' '.$azureUserInfo['userPrincipalName']); + } + } + + return $userId; + } + + /** + * @return array + */ + public function getGroupUidByRole(): array + { + $groupUidList = [ + 'admin' => $this->get(self::SETTING_GROUP_ID_ADMIN), + 'sessionAdmin' => $this->get(self::SETTING_GROUP_ID_SESSION_ADMIN), + 'teacher' => $this->get(self::SETTING_GROUP_ID_TEACHER), + ]; + + return array_filter($groupUidList); + } + + /** + * @return array + */ + public function getUpdateActionByRole(): array + { + return [ + 'admin' => function (User $user) { + $user->setStatus(COURSEMANAGER); + + UserManager::addUserAsAdmin($user, false); + }, + 'sessionAdmin' => function (User $user) { + $user->setStatus(SESSIONADMIN); + + UserManager::removeUserAdmin($user, false); + }, + 'teacher' => function (User $user) { + $user->setStatus(COURSEMANAGER); + + UserManager::removeUserAdmin($user, false); + }, + ]; + } + + /** + * @throws Exception + */ + private function formatUserData( + array $azureUserInfo, + string $azureUidKey + ): array { + $phone = null; + + if (isset($azureUserInfo['telephoneNumber'])) { + $phone = $azureUserInfo['telephoneNumber']; + } elseif (isset($azureUserInfo['businessPhones'][0])) { + $phone = $azureUserInfo['businessPhones'][0]; + } elseif (isset($azureUserInfo['mobilePhone'])) { + $phone = $azureUserInfo['mobilePhone']; + } + + // If the option is set to create users, create it + $firstNme = $azureUserInfo['givenName']; + $lastName = $azureUserInfo['surname']; + $email = $azureUserInfo['mail']; + $username = $azureUserInfo['userPrincipalName']; + $authSource = 'azure'; + $active = ($azureUserInfo['accountEnabled'] ? 1 : 0); + $extra = [ + 'extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL => $azureUserInfo['mail'], + 'extra_'.self::EXTRA_FIELD_AZURE_ID => $azureUserInfo['mailNickname'], + 'extra_'.self::EXTRA_FIELD_AZURE_UID => $azureUserInfo[$azureUidKey], + ]; + + return [ + $firstNme, + $lastName, + $username, + $email, + $phone, + $authSource, + $active, + $extra, + ]; } } diff --git a/plugin/azure_active_directory/src/AzureCommand.php b/plugin/azure_active_directory/src/AzureCommand.php new file mode 100644 index 00000000000..ce79c45ca2f --- /dev/null +++ b/plugin/azure_active_directory/src/AzureCommand.php @@ -0,0 +1,188 @@ +plugin = AzureActiveDirectory::create(); + $this->plugin->get_settings(true); + $this->provider = $this->plugin->getProviderForApiGraph(); + } + + /** + * @throws IdentityProviderException + */ + protected function generateOrRefreshToken(?AccessTokenInterface &$token) + { + if (!$token || ($token->getExpires() && !$token->getRefreshToken())) { + $token = $this->provider->getAccessToken( + 'client_credentials', + ['resource' => $this->provider->resource] + ); + } + } + + /** + * @throws Exception + * + * @return Generator> + */ + protected function getAzureUsers(): Generator + { + $userFields = [ + 'givenName', + 'surname', + 'mail', + 'userPrincipalName', + 'businessPhones', + 'mobilePhone', + 'accountEnabled', + 'mailNickname', + 'id', + ]; + + $query = sprintf( + '$top=%d&$select=%s', + AzureActiveDirectory::API_PAGE_SIZE, + implode(',', $userFields) + ); + + $token = null; + + do { + $this->generateOrRefreshToken($token); + + try { + $azureUsersRequest = $this->provider->request( + 'get', + "users?$query", + $token + ); + } catch (Exception $e) { + throw new Exception('Exception when requesting users from Azure: '.$e->getMessage()); + } + + $azureUsersInfo = $azureUsersRequest['value'] ?? []; + + foreach ($azureUsersInfo as $azureUserInfo) { + yield $azureUserInfo; + } + + $hasNextLink = false; + + if (!empty($azureUsersRequest['@odata.nextLink'])) { + $hasNextLink = true; + $query = parse_url($azureUsersRequest['@odata.nextLink'], PHP_URL_QUERY); + } + } while ($hasNextLink); + } + + /** + * @throws Exception + * + * @return Generator> + */ + protected function getAzureGroups(): Generator + { + $groupFields = [ + 'id', + 'displayName', + 'description', + ]; + + $query = sprintf( + '$top=%d&$select=%s', + AzureActiveDirectory::API_PAGE_SIZE, + implode(',', $groupFields) + ); + + $token = null; + + do { + $this->generateOrRefreshToken($token); + + try { + $azureGroupsRequest = $this->provider->request('get', "groups?$query", $token); + } catch (Exception $e) { + throw new Exception('Exception when requesting groups from Azure: '.$e->getMessage()); + } + + $azureGroupsInfo = $azureGroupsRequest['value'] ?? []; + + foreach ($azureGroupsInfo as $azureGroupInfo) { + yield $azureGroupInfo; + } + + $hasNextLink = false; + + if (!empty($azureGroupsRequest['@odata.nextLink'])) { + $hasNextLink = true; + $query = parse_url($azureGroupsRequest['@odata.nextLink'], PHP_URL_QUERY); + } + } while ($hasNextLink); + } + + /** + * @throws Exception + * + * @return Generator> + */ + protected function getAzureGroupMembers(string $groupUid): Generator + { + $userFields = [ + 'mail', + 'mailNickname', + 'id', + ]; + + $query = sprintf( + '$top=%d&$select=%s', + AzureActiveDirectory::API_PAGE_SIZE, + implode(',', $userFields) + ); + + $token = null; + + do { + $this->generateOrRefreshToken($token); + + try { + $azureGroupMembersRequest = $this->provider->request( + 'get', + "groups/$groupUid/members?$query", + $token + ); + } catch (Exception $e) { + throw new Exception('Exception when requesting group members from Azure: '.$e->getMessage()); + } + + $azureGroupMembers = $azureGroupMembersRequest['value'] ?? []; + + foreach ($azureGroupMembers as $azureGroupMember) { + yield $azureGroupMember; + } + + $hasNextLink = false; + + if (!empty($azureGroupMembersRequest['@odata.nextLink'])) { + $hasNextLink = true; + $query = parse_url($azureGroupMembersRequest['@odata.nextLink'], PHP_URL_QUERY); + } + } while ($hasNextLink); + } +} diff --git a/plugin/azure_active_directory/src/AzureSyncUsergroupsCommand.php b/plugin/azure_active_directory/src/AzureSyncUsergroupsCommand.php new file mode 100644 index 00000000000..30087a16e61 --- /dev/null +++ b/plugin/azure_active_directory/src/AzureSyncUsergroupsCommand.php @@ -0,0 +1,74 @@ + + */ + public function __invoke(): Generator + { + yield 'Synchronizing groups from Azure.'; + + $usergroup = new UserGroup(); + + $groupIdByUid = []; + + foreach ($this->getAzureGroups() as $azureGroupInfo) { + if ($usergroup->usergroup_exists($azureGroupInfo['displayName'])) { + $groupId = $usergroup->getIdByName($azureGroupInfo['displayName']); + + if ($groupId) { + $usergroup->subscribe_users_to_usergroup($groupId, []); + + yield sprintf('Class exists, all users unsubscribed: %s', $azureGroupInfo['displayName']); + } + } else { + $groupId = $usergroup->save([ + 'name' => $azureGroupInfo['displayName'], + 'description' => $azureGroupInfo['description'], + ]); + + if ($groupId) { + yield sprintf('Class created: %s', $azureGroupInfo['displayName']); + } + } + + $groupIdByUid[$azureGroupInfo['id']] = $groupId; + } + + yield '----------------'; + yield 'Subscribing users to groups'; + + foreach ($groupIdByUid as $azureGroupUid => $groupId) { + $newGroupMembers = []; + + yield sprintf('Obtaining members for group (ID %d)', $groupId); + + try { + foreach ($this->getAzureGroupMembers($azureGroupUid) as $azureGroupMember) { + if ($userId = $this->plugin->getUserIdByVerificationOrder($azureGroupMember, 'id')) { + $newGroupMembers[] = $userId; + } + } + } catch (Exception $e) { + yield $e->getMessage(); + + continue; + } + + if ($newGroupMembers) { + $usergroup->subscribe_users_to_usergroup($groupId, $newGroupMembers); + + yield sprintf( + 'User IDs subscribed in class (ID %d): %s', + $groupId, + implode(', ', $newGroupMembers) + ); + } + } + } +} diff --git a/plugin/azure_active_directory/src/AzureSyncUsersCommand.php b/plugin/azure_active_directory/src/AzureSyncUsersCommand.php new file mode 100644 index 00000000000..36b60f9a2f7 --- /dev/null +++ b/plugin/azure_active_directory/src/AzureSyncUsersCommand.php @@ -0,0 +1,98 @@ + + */ + public function __invoke(): Generator + { + yield 'Synchronizing users from Azure.'; + + /** @var array $existingUsers */ + $existingUsers = []; + + foreach ($this->getAzureUsers() as $azureUserInfo) { + try { + $userId = $this->plugin->registerUser($azureUserInfo, 'id'); + } catch (Exception $e) { + yield $e->getMessage(); + + continue; + } + + $existingUsers[$azureUserInfo['id']] = $userId; + + yield sprintf('User (ID %d) with received info: %s ', $userId, serialize($azureUserInfo)); + } + + yield '----------------'; + yield 'Updating users status'; + + $roleGroups = $this->plugin->getGroupUidByRole(); + $roleActions = $this->plugin->getUpdateActionByRole(); + + $userManager = UserManager::getManager(); + $em = Database::getManager(); + + foreach ($roleGroups as $userRole => $groupUid) { + try { + $azureGroupMembersInfo = iterator_to_array($this->getAzureGroupMembers($groupUid)); + } catch (Exception $e) { + yield $e->getMessage(); + + continue; + } + + $azureGroupMembersUids = array_column($azureGroupMembersInfo, 'id'); + + foreach ($azureGroupMembersUids as $azureGroupMembersUid) { + $userId = $existingUsers[$azureGroupMembersUid] ?? null; + + if (!$userId) { + continue; + } + + if (isset($roleActions[$userRole])) { + /** @var User $user */ + $user = $userManager->find($userId); + + $roleActions[$userRole]($user); + + yield sprintf('User (ID %d) status %s', $userId, $userRole); + } + } + + $em->flush(); + } + + if ('true' === $this->plugin->get(AzureActiveDirectory::SETTING_DEACTIVATE_NONEXISTING_USERS)) { + yield '----------------'; + + yield 'Trying deactivate non-existing users in Azure'; + + $users = UserManager::getRepository()->findByAuthSource('azure'); + $userIdList = array_map( + function ($user) { + return $user->getId(); + }, + $users + ); + + $nonExistingUsers = array_diff($userIdList, $existingUsers); + + UserManager::deactivate_users($nonExistingUsers); + + yield sprintf( + 'Deactivated users IDs: %s', + implode(', ', $nonExistingUsers) + ); + } + } +} diff --git a/plugin/azure_active_directory/src/callback.php b/plugin/azure_active_directory/src/callback.php index 08e140676e0..3bc85d443c5 100644 --- a/plugin/azure_active_directory/src/callback.php +++ b/plugin/azure_active_directory/src/callback.php @@ -4,6 +4,9 @@ * Callback script for Azure. The URL of this file is sent to Azure as a * point of contact to send particular signals. */ + +use Chamilo\UserBundle\Entity\User; + require __DIR__.'/../../../main/inc/global.inc.php'; if (!empty($_GET['error']) && !empty($_GET['state'])) { @@ -79,100 +82,38 @@ throw new Exception('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 Exception('The mailNickname field is empty in Azure AD and is needed to set the unique Azure ID for this user.'); + throw new Exception('The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.'); } - - $extraFieldValue = new ExtraFieldValue('user'); - $organisationValue = $extraFieldValue->get_item_id_from_field_variable_and_field_value( - AzureActiveDirectory::EXTRA_FIELD_ORGANISATION_EMAIL, - $me['mail'] - ); - $azureValue = $extraFieldValue->get_item_id_from_field_variable_and_field_value( - AzureActiveDirectory::EXTRA_FIELD_AZURE_ID, - $me['mailNickname'] - ); - - $userId = null; - // Get the user ID (if any) from the EXTRA_FIELD_ORGANISATION_EMAIL extra - // field - if (!empty($organisationValue) && isset($organisationValue['item_id'])) { - $userId = $organisationValue['item_id']; + if (empty($me['objectId'])) { + throw new Exception('The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.'); } - if (empty($userId)) { - // If the previous step didn't work, get the user ID from - // EXTRA_FIELD_AZURE_ID - if (!empty($azureValue) && isset($azureValue['item_id'])) { - $userId = $azureValue['item_id']; - } - } + $userId = $plugin->registerUser($me); - if (empty($userId)) { - // If we didn't find the user - if ($plugin->get(AzureActiveDirectory::SETTING_PROVISION_USERS) === 'true') { - // Get groups info, if any - $groups = $provider->get('me/memberOf', $token); - if (empty($me)) { - throw new Exception('Groups info not found.'); - } - // If any specific group ID has been defined for a specific role, use that - // ID to give the user the right role - $givenAdminGroup = $plugin->get(AzureActiveDirectory::SETTING_GROUP_ID_ADMIN); - $givenSessionAdminGroup = $plugin->get(AzureActiveDirectory::SETTING_GROUP_ID_SESSION_ADMIN); - $givenTeacherGroup = $plugin->get(AzureActiveDirectory::SETTING_GROUP_ID_TEACHER); - $userRole = STUDENT; - $isAdmin = false; - foreach ($groups as $group) { - if ($isAdmin) { - break; - } - if ($givenAdminGroup == $group['objectId']) { - $userRole = COURSEMANAGER; - $isAdmin = true; - } elseif (!$isAdmin && $givenSessionAdminGroup == $group['objectId']) { - $userRole = SESSIONADMIN; - } elseif (!$isAdmin && $userRole != SESSIONADMIN && $givenTeacherGroup == $group['objectId']) { - $userRole = COURSEMANAGER; - } - } + if ($roleGroups = $plugin->getGroupUidByRole()) { + $roleActions = $plugin->getUpdateActionByRole(); + /** @var User $user */ + $user = UserManager::getManager()->find($userId); + + $azureGroups = $provider->get('me/memberOf', $token); + + foreach ($roleGroups as $userRole => $groupUid) { + foreach ($azureGroups as $azureGroup) { + $azureGroupUid = $azureGroup['objectId']; + if ($azureGroupUid === $groupUid) { + $roleActions[$userRole]($user); - // If the option is set to create users, create it - $userId = UserManager::create_user( - $me['givenName'], - $me['surname'], - $userRole, - $me['mail'], - $me['mailNickname'], - '', - null, - null, - $me['telephoneNumber'], - null, - 'azure', - null, - ($me['accountEnabled'] ? 1 : 0), - null, - [ - 'extra_'.AzureActiveDirectory::EXTRA_FIELD_ORGANISATION_EMAIL => $me['mail'], - 'extra_'.AzureActiveDirectory::EXTRA_FIELD_AZURE_ID => $me['mailNickname'], - ], - null, - null, - $isAdmin - ); - if (!$userId) { - throw new Exception(get_lang('UserNotAdded').' '.$me['mailNickname']); + break 2; + } } - } else { - throw new Exception('User not found when checking the extra fields from '.$me['mail'].' or '.$me['mailNickname'].'.'); } + + Database::getManager()->flush(); } $userInfo = api_get_user_info($userId); - //TODO add user update management for groups - - //TODO add support if user exists in another URL but is validated in this one, add the user to access_url_rel_user + /* @TODO add support if user exists in another URL but is validated in this one, add the user to access_url_rel_user */ if (empty($userInfo)) { throw new Exception('User '.$userId.' not found.'); diff --git a/plugin/azure_active_directory/src/scripts/sync_usergroups.php b/plugin/azure_active_directory/src/scripts/sync_usergroups.php new file mode 100644 index 00000000000..8ad128c5db2 --- /dev/null +++ b/plugin/azure_active_directory/src/scripts/sync_usergroups.php @@ -0,0 +1,21 @@ +getMessage()); +} diff --git a/plugin/azure_active_directory/src/scripts/sync_users.php b/plugin/azure_active_directory/src/scripts/sync_users.php new file mode 100644 index 00000000000..33eb4768e62 --- /dev/null +++ b/plugin/azure_active_directory/src/scripts/sync_users.php @@ -0,0 +1,21 @@ +getMessage()); +} diff --git a/src/Chamilo/UserBundle/Repository/UserRepository.php b/src/Chamilo/UserBundle/Repository/UserRepository.php index 8ddbf2ce427..dae382a02fb 100644 --- a/src/Chamilo/UserBundle/Repository/UserRepository.php +++ b/src/Chamilo/UserBundle/Repository/UserRepository.php @@ -1382,4 +1382,9 @@ public function getLastLogin(User $user) ->getQuery() ->getOneOrNullResult(); } + + public function findByAuthSource(string $authSource): array + { + return $this->findBy(['authSource' => $authSource]); + } }