Actualización

This commit is contained in:
Xes
2025-04-10 12:36:07 +02:00
parent 1da7c3f3b9
commit 4aff98e77b
3147 changed files with 320647 additions and 0 deletions
@@ -0,0 +1,388 @@
<?php
/* For license terms, see /license.txt */
use Chamilo\UserBundle\Entity\User;
use TheNetworg\OAuth2\Client\Provider\Azure;
/**
* AzureActiveDirectory plugin class.
*
* @author Angel Fernando Quiroz Campos <angel.quiroz@beeznest.com>
*
* @package chamilo.plugin.azure_active_directory
*/
class AzureActiveDirectory extends Plugin
{
public const SETTING_ENABLE = 'enable';
public const SETTING_APP_ID = 'app_id';
public const SETTING_APP_SECRET = 'app_secret';
public const SETTING_BLOCK_NAME = 'block_name';
public const SETTING_FORCE_LOGOUT_BUTTON = 'force_logout';
public const SETTING_MANAGEMENT_LOGIN_ENABLE = 'management_login_enable';
public const SETTING_MANAGEMENT_LOGIN_NAME = 'management_login_name';
public const SETTING_PROVISION_USERS = 'provisioning';
public const SETTING_UPDATE_USERS = 'update_users';
public const SETTING_GROUP_ID_ADMIN = 'group_id_admin';
public const SETTING_GROUP_ID_SESSION_ADMIN = 'group_id_session_admin';
public const SETTING_GROUP_ID_TEACHER = 'group_id_teacher';
public const SETTING_EXISTING_USER_VERIFICATION_ORDER = 'existing_user_verification_order';
public const SETTING_TENANT_ID = 'tenant_id';
public const SETTING_DEACTIVATE_NONEXISTING_USERS = 'deactivate_nonexisting_users';
public const URL_TYPE_AUTHORIZE = 'login';
public const URL_TYPE_LOGOUT = 'logout';
public const EXTRA_FIELD_ORGANISATION_EMAIL = 'organisationemail';
public const EXTRA_FIELD_AZURE_ID = 'azure_id';
public const EXTRA_FIELD_AZURE_UID = 'azure_uid';
public const API_PAGE_SIZE = 999;
/**
* AzureActiveDirectory constructor.
*/
protected function __construct()
{
$settings = [
self::SETTING_ENABLE => 'boolean',
self::SETTING_APP_ID => 'text',
self::SETTING_APP_SECRET => 'text',
self::SETTING_BLOCK_NAME => 'text',
self::SETTING_FORCE_LOGOUT_BUTTON => 'boolean',
self::SETTING_MANAGEMENT_LOGIN_ENABLE => '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.4', 'Angel Fernando Quiroz Campos, Yannick Warnier', $settings);
}
/**
* Instance the plugin.
*
* @staticvar null $result
*
* @return $this
*/
public static function create()
{
static $result = null;
return $result ? $result : $result = new self();
}
/**
* @return string
*/
public function get_name()
{
return 'azure_active_directory';
}
/**
* @return Azure
*/
public function getProvider()
{
$provider = new Azure([
'clientId' => $this->get(self::SETTING_APP_ID),
'clientSecret' => $this->get(self::SETTING_APP_SECRET),
'redirectUri' => api_get_path(WEB_PLUGIN_PATH).'azure_active_directory/src/callback.php',
]);
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
*
* @return string
*/
public function getUrl($urlType)
{
if (self::URL_TYPE_LOGOUT === $urlType) {
$provider = $this->getProvider();
return $provider->getLogoutUrl(
api_get_path(WEB_PATH)
);
}
return api_get_path(WEB_PLUGIN_PATH).$this->get_name().'/src/callback.php';
}
/**
* Create extra fields for user when installing.
*/
public function install()
{
UserManager::create_extra_field(
self::EXTRA_FIELD_ORGANISATION_EMAIL,
ExtraField::FIELD_TYPE_TEXT,
$this->get_lang('OrganisationEmail'),
''
);
UserManager::create_extra_field(
self::EXTRA_FIELD_AZURE_ID,
ExtraField::FIELD_TYPE_TEXT,
$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<string, string|false>
*/
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<string, callable>
*/
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,
];
}
}
@@ -0,0 +1,188 @@
<?php
/* For license terms, see /license.txt */
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessTokenInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
abstract class AzureCommand
{
/**
* @var AzureActiveDirectory
*/
protected $plugin;
/**
* @var Azure
*/
protected $provider;
public function __construct()
{
$this->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<int, array<string, string>>
*/
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<int, array<string, string>>
*/
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<int, array<string, string>>
*/
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);
}
}
@@ -0,0 +1,74 @@
<?php
/* For license terms, see /license.txt */
class AzureSyncUsergroupsCommand extends AzureCommand
{
/**
* @throws Exception
*
* @return Generator<int, string>
*/
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)
);
}
}
}
}
@@ -0,0 +1,98 @@
<?php
/* For license terms, see /license.txt */
use Chamilo\UserBundle\Entity\User;
class AzureSyncUsersCommand extends AzureCommand
{
/**
* @throws Exception
*
* @return Generator<int, string>
*/
public function __invoke(): Generator
{
yield 'Synchronizing users from Azure.';
/** @var array<string, int> $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)
);
}
}
}
@@ -0,0 +1,145 @@
<?php
/* For license terms, see /license.txt */
/**
* 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'])) {
if ($_GET['state'] === ChamiloSession::read('oauth2state')) {
api_not_allowed(
true,
Display::return_message(
$_GET['error_description'] ?? $_GET['error'],
'warning'
)
);
} else {
ChamiloSession::erase('oauth2state');
exit('Invalid state');
}
}
$plugin = AzureActiveDirectory::create();
if ('true' !== $plugin->get(AzureActiveDirectory::SETTING_ENABLE)) {
api_not_allowed(true);
}
$provider = $plugin->getProvider();
if (!isset($_GET['code'])) {
// If we don't have an authorization code then get one by redirecting
// users to Azure (with the callback URL information)
$authUrl = $provider->getAuthorizationUrl();
ChamiloSession::write('oauth2state', $provider->getState());
header('Location: '.$authUrl);
exit;
}
// Check given state against previously stored one to mitigate CSRF attack
if (empty($_GET['state']) || ($_GET['state'] !== ChamiloSession::read('oauth2state'))) {
ChamiloSession::erase('oauth2state');
exit;
}
// Try to get an access token (using the authorization code grant)
try {
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code'],
'resource' => 'https://graph.windows.net',
]);
} catch (Exception $exception) {
if ($exception->getMessage() == 'interaction_required') {
$message = Display::return_message($plugin->get_lang('additional_interaction_required'), 'error', false);
} else {
$message = Display::return_message($exception->getMessage(), 'error');
}
Display::addFlash($message);
header('Location: '.api_get_path(WEB_PATH));
exit;
}
$me = null;
try {
$me = $provider->get('me', $token);
if (empty($me)) {
throw new Exception('Token not found.');
}
// We use the e-mail to authenticate the user, so check that at least one
// e-mail source exists
if (empty($me['mail'])) {
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 username for this user.');
}
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.');
}
$userId = $plugin->registerUser($me);
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);
break 2;
}
}
}
Database::getManager()->flush();
}
$userInfo = api_get_user_info($userId);
/* @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.');
}
if ($userInfo['active'] != '1') {
throw new Exception(get_lang('AccountInactive'));
}
} catch (Exception $exception) {
$message = Display::return_message($exception->getMessage(), 'error');
Display::addFlash($message);
header('Location: '.api_get_path(WEB_PATH));
exit;
}
$userInfo['uidReset'] = true;
$_GET['redirect_after_not_allow_page'] = 1;
$redirectAfterNotAllowPage = ChamiloSession::read('redirect_after_not_allow_page');
ChamiloSession::clear();
ChamiloSession::write('redirect_after_not_allow_page', $redirectAfterNotAllowPage);
ChamiloSession::write('_user', $userInfo);
ChamiloSession::write('_user_auth_source', 'azure_active_directory');
Event::eventLogin($userInfo['user_id']);
Redirect::session_request_uri(true, $userInfo['user_id']);
@@ -0,0 +1,21 @@
<?php
/* For license terms, see /license.txt */
require __DIR__.'/../../../../main/inc/global.inc.php';
if (PHP_SAPI !== 'cli') {
exit('Run this script through the command line or comment this line in the code');
}
// Uncomment to indicate the access url to get the plugin settings when using multi-url
//$_configuration['access_url'] = 1;
$command = new AzureSyncUsergroupsCommand();
try {
foreach ($command() as $str) {
printf("%d - %s".PHP_EOL, time(), $str);
}
} catch (Exception $e) {
printf('%s - Exception: %s'.PHP_EOL, time(), $e->getMessage());
}
@@ -0,0 +1,21 @@
<?php
/* For license terms, see /license.txt */
require __DIR__.'/../../../../main/inc/global.inc.php';
if (PHP_SAPI !== 'cli') {
exit('Run this script through the command line or comment this line in the code');
}
// Uncomment to indicate the access url to get the plugin settings when using multi-url
//$_configuration['access_url'] = 1;
$command = new AzureSyncUsersCommand();
try {
foreach ($command() as $str) {
printf("%d - %s".PHP_EOL, time(), $str);
}
} catch (Exception $e) {
printf('%s - Exception: %s'.PHP_EOL, time(), $e->getMessage());
}