SSO: Start implementing Azure authentication

pull/5968/head
Angel Fernando Quiroz Campos 9 months ago
parent ac17881087
commit 469768213d
No known key found for this signature in database
GPG Key ID: B284841AE3E562CD
  1. 18
      config/authentication.yaml
  2. 9
      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. 2
      src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php
  7. 83
      src/CoreBundle/Security/Authenticator/OAuth2/AzureAuthenticator.php
  8. 198
      src/CoreBundle/ServiceHelper/AzureAuthenticatorHelper.php

@ -53,3 +53,21 @@ parameters:
encryption_key_path: null
encryption_key: null
redirect_params: { }
azure:
enabled: false
title: ''
client_id: ''
client_secret: ''
tenant: 'common'
client_certificate_private_key: ''
client_certificate_thumbprint: ''
url_login: 'https://login.microsoftonline.com/'
path_authorize: '/oauth2/authorize'
path_token: '/oauth2/token'
scope: {}
url_api: 'https://graph.windows.net/'
resource: null
api_version: '1.6'
auth_with_resource: true
default_end_point_version: '1.0'

@ -24,4 +24,13 @@ knpu_oauth2_client:
auth_server_url: null
realm: null
azure:
type: azure
client_id: ''
# a route name you'll create
redirect_route: chamilo.oauth2_azure_check
redirect_params: { }
# The shared client secret if you don't use a certificate
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,
],
];
}

@ -16,6 +16,7 @@ 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
@ -37,6 +38,7 @@ readonly class OAuth2ProviderFactoryDecorator
GenericProvider::class => $this->getProviderOptions('generic'),
Facebook::class => $this->getProviderOptions('facebook'),
Keycloak::class => $this->getProviderOptions('keycloak'),
Azure::class => $this->getProviderOptions('azure'),
};
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);
}
}

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