Merge pull request #5974 from AngelFQC/sso_azure

SSO: Start implementing Azure authentication
pull/5978/head
Angel Fernando Quiroz Campos 9 months ago committed by GitHub
commit c0930e7678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 21
      config/authentication.yaml
  2. 14
      config/packages/knpu_oauth2_client.yaml
  3. 1
      config/packages/security.yaml
  4. 26
      src/CoreBundle/Controller/OAuth2/AzureProviderController.php
  5. 19
      src/CoreBundle/DataFixtures/ExtraFieldFixtures.php
  6. 38
      src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php
  7. 83
      src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php
  8. 2
      src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php
  9. 60
      src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php
  10. 198
      src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php

@ -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: ''

@ -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

@ -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}

@ -0,0 +1,26 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller\OAuth2;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AzureProviderController extends AbstractProviderController
{
#[Route('/connect/azure', name: 'chamilo.oauth2_azure_start')]
public function connect(
ClientRegistry $clientRegistry,
AuthenticationConfigHelper $authenticationConfigHelper,
): Response {
return $this->getStartResponse('azure', $clientRegistry, $authenticationConfigHelper);
}
#[Route('/connect/azure/check', name: 'chamilo.oauth2_azure_check')]
public function connectCheck(): void {}
}

@ -8,6 +8,7 @@ namespace Chamilo\CoreBundle\DataFixtures;
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 @@ class ExtraFieldFixtures extends Fixture implements FixtureGroupInterface
'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,
],
];
}

@ -7,15 +7,14 @@ declare(strict_types=1);
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 @@ readonly class OAuth2ProviderFactoryDecorator
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);
}
}

@ -0,0 +1,83 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Security\Authenticator\OAuth2;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use Chamilo\CoreBundle\ServiceHelper\AzureAuthenticatorHelper;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\RouterInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
class AzureAuthenticator extends AbstractAuthenticator
{
protected string $providerName = 'azure';
public function __construct(
ClientRegistry $clientRegistry,
RouterInterface $router,
UserRepository $userRepository,
AuthenticationConfigHelper $authenticationConfigHelper,
AccessUrlHelper $urlHelper,
EntityManagerInterface $entityManager,
private readonly AzureAuthenticatorHelper $azureHelper,
) {
parent::__construct(
$clientRegistry,
$router,
$userRepository,
$authenticationConfigHelper,
$urlHelper,
$entityManager
);
}
public function supports(Request $request): ?bool
{
return 'chamilo.oauth2_azure_check' === $request->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);
}
}

@ -62,7 +62,7 @@ class GenericAuthenticator extends AbstractAuthenticator
protected function userLoader(AccessToken $accessToken): User
{
$providerParams = $this->authenticationConfigHelper->getParams('generic');
$providerParams = $this->authenticationConfigHelper->getProviderConfig('generic');
/** @var GenericResourceOwner $resourceOwner */
$resourceOwner = $this->client->fetchUserFromToken($accessToken);

@ -21,7 +21,7 @@ readonly class AuthenticationConfigHelper
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 @@ readonly class AuthenticationConfigHelper
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 @@ readonly class AuthenticationConfigHelper
$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 @@ readonly class AuthenticationConfigHelper
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);
}
}

@ -0,0 +1,198 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\ServiceHelper;
use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\ExtraFieldRepository;
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
readonly class AzureAuthenticatorHelper
{
public const EXTRA_FIELD_ORGANISATION_EMAIL = 'organisationemail';
public const EXTRA_FIELD_AZURE_ID = 'azure_id';
public const EXTRA_FIELD_AZURE_UID = 'azure_uid';
public function __construct(
private ExtraFieldValuesRepository $extraFieldValuesRepo,
private ExtraFieldRepository $extraFieldRepo,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private AccessUrlHelper $urlHelper,
) {}
/**
* @throws NonUniqueResultException
*/
public function registerUser(array $azureUserInfo, string $azureUidKey = 'objectId'): User
{
if (empty($azureUserInfo)) {
throw new UnauthorizedHttpException('User info not found.');
}
[
$firstNme,
$lastName,
$username,
$email,
$phone,
$authSource,
$active,
$extra,
] = $this->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<int, ExtraFieldValues> $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,
];
}
}
Loading…
Cancel
Save