SSO: Implement generic OAuth2 login/registration - refs BT#21881

pull/5753/head
Angel Fernando Quiroz Campos 1 year ago
parent d71542d19d
commit 21d5f05b0d
No known key found for this signature in database
GPG Key ID: B284841AE3E562CD
  1. 29
      config/authentication.yaml
  2. 7
      config/packages/knpu_oauth2_client.yaml
  3. 3
      config/packages/security.yaml
  4. 1
      config/services.yaml
  5. 27
      src/CoreBundle/Controller/OAuth2/AbstractProviderController.php
  6. 26
      src/CoreBundle/Controller/OAuth2/GenericProviderController.php
  7. 50
      src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php
  8. 80
      src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php
  9. 201
      src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php
  10. 43
      src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php

@ -0,0 +1,29 @@
# authentication configuration for each Access URL
# Access URL Id / authentication method / params
parameters:
authentication:
1:
generic:
enabled: false
client_id: ''
client_secret: ''
provider_options:
urlAuthorize: ''
urlAccessToken: ''
urlResourceOwnerDetails: ''
responseResourceOwnerId: 'sub'
accessTokenMethod: 'POST'
scopes:
- openid
allow_create_new_users: true
allow_update_user_info: false
resource_owner_username_field: null
resource_owner_firstname_field: null
resource_owner_lastname_field: null
resource_owner_email_field: null
resource_owner_status_field: null
resource_owner_teacher_status_field: null
resource_owner_sessadmin_status_field: null
resource_owner_hr_status_field: null
resource_owner_status_status_field: null
resource_owner_anon_status_field: null

@ -1,3 +1,10 @@
knpu_oauth2_client:
clients:
generic:
type: generic
provider_class: League\OAuth2\Client\Provider\GenericProvider
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_generic_check
# configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration

@ -114,6 +114,9 @@ security:
# username_path: security.credentials.login
# password_path: security.credentials.password
custom_authenticators:
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\GenericAuthenticator
access_control:
- {path: ^/login, roles: PUBLIC_ACCESS}
- {path: ^/api/authentication_token, roles: PUBLIC_ACCESS}

@ -117,3 +117,4 @@ cocur_slugify:
imports:
- {resource: ../src/CoreBundle/Resources/config/services.yml}
- {resource: ../src/LtiBundle/Resources/config/services.yml}
- { resource: ./authentication.yaml }

@ -0,0 +1,27 @@
<?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\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractProviderController extends AbstractController
{
protected function getStartResponse(
string $providerName,
ClientRegistry $clientRegistry,
AuthenticationConfigHelper $authenticationConfigHelper,
): Response {
if (!$authenticationConfigHelper->isEnabled($providerName)) {
throw $this->createAccessDeniedException();
}
return $clientRegistry->getClient($providerName)->redirect();
}
}

@ -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 GenericProviderController extends AbstractProviderController
{
#[Route('/connect/generic', name: 'chamilo.oauth2_generic_start')]
public function connect(
ClientRegistry $clientRegistry,
AuthenticationConfigHelper $authenticationConfigHelper,
): Response {
return $this->getStartResponse('generic', $clientRegistry, $authenticationConfigHelper);
}
#[Route('/connect/generic/check', name: 'chamilo.oauth2_generic_check')]
public function connectCheck(): void {}
}

@ -0,0 +1,50 @@
<?php
/* For licensing terms, see /license.txt */
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\GenericProvider;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
#[AsDecorator(decorates: 'knpu.oauth2.provider_factory')]
readonly class OAuth2ProviderFactoryDecorator
{
public function __construct(
#[AutowireDecorated]
private ProviderFactory $inner,
private AuthenticationConfigHelper $authenticationConfigHelper,
) {}
public function createProvider(
$class,
array $options,
?string $redirectUri = null,
array $redirectParams = [],
array $collaborators = []
): AbstractProvider {
$options = match ($class) {
GenericProvider::class => $this->getProviderOptions('generic'),
};
return $this->inner->createProvider($class, $options, $redirectUri, $redirectParams, $collaborators);
}
private function getProviderOptions(string $providerName): array
{
/** @var KnpUOAuth2ClientExtension $extension */
$extension = (new KnpUOAuth2ClientBundle())->getContainerExtension();
$configParams = $this->authenticationConfigHelper->getParams($providerName);
return $extension->getConfigurator($providerName)->getProviderOptions($configParams);
}
}

@ -0,0 +1,80 @@
<?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\AuthenticationConfigHelper;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
abstract class AbstractAuthenticator extends OAuth2Authenticator implements AuthenticationEntryPointInterface
{
protected string $providerName = '';
protected OAuth2ClientInterface $client;
public function __construct(
protected readonly ClientRegistry $clientRegistry,
protected readonly RouterInterface $router,
protected readonly UserRepository $userRepository,
protected readonly AuthenticationConfigHelper $authenticationConfigHelper,
) {
$this->client = $this->clientRegistry->getClient($this->providerName);
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$targetUrl = $this->router->generate('login');
return new RedirectResponse($targetUrl);
}
abstract public function supports(Request $request): ?bool;
public function authenticate(Request $request): Passport
{
/** @var AccessToken $accessToken */
$accessToken = $this->fetchAccessToken($this->client);
$user = $this->userLoader($accessToken);
return new SelfValidatingPassport(
new UserBadge(
$user->getUserIdentifier()
),
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$targetUrl = $this->router->generate('index');
return new RedirectResponse($targetUrl);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessage(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
abstract protected function userLoader(AccessToken $accessToken): User;
}

@ -0,0 +1,201 @@
<?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\ExtraFieldRepository;
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use ExtraField;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Provider\GenericResourceOwner;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Tool\ArrayAccessorTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use UnexpectedValueException;
class GenericAuthenticator extends AbstractAuthenticator
{
use ArrayAccessorTrait;
public const EXTRA_FIELD_OAUTH2_ID = 'oauth2_id';
protected string $providerName = 'generic';
public function __construct(
ClientRegistry $clientRegistry,
RouterInterface $router,
UserRepository $userRepository,
AuthenticationConfigHelper $authenticationConfigHelper,
protected readonly ExtraFieldRepository $extraFieldRepository,
protected readonly ExtraFieldValuesRepository $extraFieldValuesRepository,
) {
parent::__construct(
$clientRegistry,
$router,
$userRepository,
$authenticationConfigHelper
);
}
public function supports(Request $request): ?bool
{
return 'chamilo.oauth2_generic_check' === $request->attributes->get('_route');
}
protected function userLoader(AccessToken $accessToken): User
{
$providerParams = $this->authenticationConfigHelper->getParams('generic');
/** @var GenericResourceOwner $resourceOwner */
$resourceOwner = $this->client->fetchUserFromToken($accessToken);
$resourceOwnerData = $resourceOwner->toArray();
$resourceOwnerId = $resourceOwner->getId();
if (empty($resourceOwnerId)) {
throw new UnexpectedValueException('Value for the resource owner identifier not found at the configured key');
}
$fieldType = (int) ExtraField::getExtraFieldTypeFromString('user');
$extraField = $this->extraFieldRepository->findByVariable($fieldType, self::EXTRA_FIELD_OAUTH2_ID);
$existingUserExtraFieldValue = $this->extraFieldValuesRepository->findByVariableAndValue(
$extraField,
$resourceOwnerId
);
if (null === $existingUserExtraFieldValue) {
$username = $this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_username_field'],
"oauth2user_$resourceOwnerId"
);
/** @var User $user */
$user = $this->userRepository->findOneBy(['username' => $username]);
if (!$user || 'platform' !== $user->getAuthSource()) {
if (!$providerParams['allow_create_new_users']) {
throw new AuthenticationException('This user doesn\'t have an account yet and auto-provisioning is not enabled. Please contact this portal administration team to request access.');
}
// set default values, real values are set in self::updateUserInfo method
$user = (new User())
->setFirstname('OAuth2 User default firstname')
->setLastname('OAuth2 User default firstname')
->setEmail('oauth2user_'.$resourceOwnerId.'@'.(gethostname() or 'localhost'))
->setUsername($username)
->setPlainPassword($username)
->setStatus(STUDENT)
->setCreatorId($this->userRepository->getRootUser()->getId())
;
}
$this->saveUserInfo($user, $resourceOwnerData, $providerParams);
$this->extraFieldValuesRepository->updateItemData(
$extraField,
$user,
$resourceOwnerId
);
} else {
/** @var User $user */
$user = $this->userRepository->find(
$existingUserExtraFieldValue->getItemId()
);
if ($providerParams['allow_update_user_info']) {
$this->saveUserInfo($user, $resourceOwnerData, $providerParams);
}
}
return $user;
}
/**
* Set user information from the resource owner's data or the user itself
*/
public function saveUserInfo(User $user, array $resourceOwnerData, array $providerParams): void
{
$status = $this->getUserStatus($resourceOwnerData, $user->getStatus(), $providerParams);
$user
->setFirstname(
$this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_firstname_field'],
$user->getFirstname()
)
)
->setLastname(
$this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_lastname_field'],
$user->getLastname()
)
)
->setUsername(
$this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_username_field'],
$user->getUsername()
)
)
->setEmail(
$this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_email_field'],
$user->getEmail()
)
)
->setAuthSource('oauth2')
->setStatus($status)
->setRoleFromStatus($status)
;
$this->userRepository->updateUser($user);
// updateAccessUrls ?
}
private function getUserStatus(array $resourceOwnerData, int $defaultStatus, array $providerParams): int
{
$status = $this->getValueByKey(
$resourceOwnerData,
$providerParams['resource_owner_status_field'],
$defaultStatus
);
$responseStatus = [];
if ($teacherStatus = $providerParams['resource_owner_teacher_status_field']) {
$responseStatus[COURSEMANAGER] = $teacherStatus;
}
if ($sessAdminStatus = $providerParams['resource_owner_sessadmin_status_field']) {
$responseStatus[SESSIONADMIN] = $sessAdminStatus;
}
if ($drhStatus = $providerParams['resource_owner_hr_status_field']) {
$responseStatus[DRH] = $drhStatus;
}
if ($studentStatus = $providerParams['resource_owner_status_status_field']) {
$responseStatus[STUDENT] = $studentStatus;
}
if ($anonStatus = $providerParams['resource_owner_anon_status_field']) {
$responseStatus[ANONYMOUS] = $anonStatus;
}
$map = array_flip($responseStatus);
return $map[$status] ?? $status;
}
}

@ -0,0 +1,43 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\ServiceHelper;
use Chamilo\CoreBundle\Entity\AccessUrl;
use InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
readonly class AuthenticationConfigHelper
{
public function __construct(
private ParameterBagInterface $parameterBag,
private AccessUrlHelper $urlHelper,
) {}
public function getParams(string $providerName, ?AccessUrl $url = null): array
{
$urlId = $url ? $url->getId() : $this->urlHelper->getCurrent()->getId();
$authentication = $this->parameterBag->get('authentication');
if (!isset($authentication[$urlId])) {
throw new InvalidArgumentException('Invalid access URL Id');
}
if (!isset($authentication[$urlId][$providerName])) {
throw new InvalidArgumentException('Invalid authentication source');
}
return $authentication[$urlId][$providerName];
}
public function isEnabled(string $methodName, ?AccessUrl $url = null): bool
{
$configParams = $this->getParams($methodName, $url);
return $configParams['enabled'] ?? false;
}
}
Loading…
Cancel
Save