Merge pull request #5753 from AngelFQC/BT21881

OAuth2
pull/5758/head
Nicolas Ducoulombier 1 year ago committed by GitHub
commit 619f40f3c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      assets/css/scss/atoms/_divider.scss
  2. 1
      assets/css/scss/index.scss
  3. 15
      assets/css/scss/organisms/_external_logins.scss
  4. 3
      assets/vue/components/Login.vue
  5. 37
      assets/vue/components/login/LoginExternalButtons.vue
  6. 4
      assets/vue/store/platformConfig.js
  7. 2
      composer.json
  8. 751
      composer.lock
  9. 55
      config/authentication.yaml
  10. 24
      config/packages/knpu_oauth2_client.yaml
  11. 5
      config/packages/security.yaml
  12. 1
      config/services.yaml
  13. 27
      src/CoreBundle/Controller/OAuth2/AbstractProviderController.php
  14. 26
      src/CoreBundle/Controller/OAuth2/FacebookProviderController.php
  15. 26
      src/CoreBundle/Controller/OAuth2/GenericProviderController.php
  16. 26
      src/CoreBundle/Controller/OAuth2/KeycloakProviderController.php
  17. 3
      src/CoreBundle/Controller/PlatformConfigurationController.php
  18. 54
      src/CoreBundle/Decorator/OAuth2ProviderFactoryDecorator.php
  19. 5
      src/CoreBundle/Repository/ExtraFieldRepository.php
  20. 39
      src/CoreBundle/Repository/ExtraFieldValuesRepository.php
  21. 19
      src/CoreBundle/Repository/Node/AccessUrlRepository.php
  22. 85
      src/CoreBundle/Security/Authenticator/OAuth2/AbstractAuthenticator.php
  23. 83
      src/CoreBundle/Security/Authenticator/OAuth2/FacebookAuthenticator.php
  24. 273
      src/CoreBundle/Security/Authenticator/OAuth2/GenericAuthenticator.php
  25. 58
      src/CoreBundle/Security/Authenticator/OAuth2/KeycloakAuthenticator.php
  26. 77
      src/CoreBundle/ServiceHelper/AuthenticationConfigHelper.php

@ -12,7 +12,7 @@
&[aria-orientation="horizontal"] {
@apply before:absolute before:block before:left-0 before:w-full before:top-1/2 before:content-[""] before:border-t before:border-solid before:border-gray-25
flex w-full relative items-center my-4 px-2;
flex relative items-center my-4 px-2;
div {
@apply first:px-2;

@ -50,6 +50,7 @@
@import "organisms/course_card";
@import "organisms/datatable";
@import "organisms/dataview";
@import "organisms/external_logins";
@import "organisms/modals";
@import "organisms/menu";
@import "organisms/sidebar";

@ -0,0 +1,15 @@
.external-logins {
@apply flex flex-col gap-2 items-center;
&__divider {
@apply w-60 mx-auto uppercase;
}
&__button-list {
@apply space-y-4;
}
&__button {
@apply border border-gray-25 bg-white rounded-lg text-gray-90 py-4 px-12 block font-semibold;
}
}

@ -66,6 +66,8 @@
/>
</div>
</form>
<ExternalLoginButtons />
</div>
</template>
@ -77,6 +79,7 @@ import Password from "primevue/password"
import InputSwitch from "primevue/inputswitch"
import { useI18n } from "vue-i18n"
import { useLogin } from "../composables/auth/login"
import ExternalLoginButtons from "./login/LoginExternalButtons.vue"
const { t } = useI18n()

@ -0,0 +1,37 @@
<script setup>
import BaseAppLink from "../basecomponents/BaseAppLink.vue"
import BaseDivider from "../basecomponents/BaseDivider.vue"
import { useI18n } from "vue-i18n"
import { usePlatformConfig } from "../../store/platformConfig"
const { t } = useI18n()
const platformConfig = usePlatformConfig()
</script>
<template>
<div
v-if="platformConfig.externalAuthentication.length > 0"
class="external-logins"
>
<BaseDivider
:title="t('Or')"
align="center"
class="external-logins__divider"
/>
<ul class="external-logins__button-list">
<li
v-for="(extAuth, idx) in platformConfig.externalAuthentication"
:key="idx"
>
<BaseAppLink
:url="extAuth.url"
class="external-logins__button"
>
{{ t("Continue with %s", [extAuth.title]) }}
</BaseAppLink>
</li>
</ul>
</div>
</template>

@ -8,6 +8,7 @@ export const usePlatformConfig = defineStore("platformConfig", () => {
const studentView = ref("teacherview")
const plugins = ref([])
const visualTheme = ref("chamilo")
const externalAuthentication = ref([])
async function findSettingsRequest() {
isLoading.value = true
@ -22,6 +23,8 @@ export const usePlatformConfig = defineStore("platformConfig", () => {
studentView.value = data.studentview
plugins.value = data.plugins
externalAuthentication.value = data.external_authentication
} catch (e) {
console.log(e)
} finally {
@ -48,5 +51,6 @@ export const usePlatformConfig = defineStore("platformConfig", () => {
getSetting,
isStudentViewActive,
visualTheme,
externalAuthentication,
}
})

@ -97,6 +97,7 @@
"league/glide-symfony": "^2.0",
"league/html-to-markdown": "^5.1",
"league/mime-type-detection": "^1.7",
"league/oauth2-facebook": "^2.2",
"lexik/jwt-authentication-bundle": "^2.20",
"maennchen/zipstream-php": "^2.1",
"masterminds/html5": "^2.0",
@ -116,6 +117,7 @@
"sensio/framework-extra-bundle": "~6.1",
"simpod/doctrine-utcdatetime": "^0.1.2",
"sonata-project/exporter": "^2.2",
"stevenmaguire/oauth2-keycloak": "^5.1",
"stof/doctrine-extensions-bundle": "^1.10",
"sunra/php-simple-html-dom-parser": "~1.5",
"symfony/apache-pack": "^1.0",

751
composer.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,55 @@
# authentication configuration for each Access URL
# Access URL Id / authentication method / params
parameters:
authentication:
default:
generic:
enabled: false
title: 'External'
client_id: ''
client_secret: ''
provider_options:
urlAuthorize: ''
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
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
resource_owner_urls_field: null
facebook:
enabled: false
title: 'Facebook'
client_id: ''
client_secret: ''
graph_api_version: 'v20.0'
redirect_params: { }
keycloak:
enabled: false
title: 'Keycloak'
client_id: ''
client_secret: ''
auth_server_url: ''
realm: ''
version: ''
encryption_algorithm: null
encryption_key_path: null
encryption_key: null
redirect_params: { }

@ -1,3 +1,27 @@
knpu_oauth2_client:
clients:
generic:
type: generic
provider_class: League\OAuth2\Client\Provider\GenericProvider
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_generic_check
facebook:
type: facebook
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_facebook_check
graph_api_version: ''
redirect_params: { }
keycloak:
type: keycloak
client_id: ''
client_secret: ''
redirect_route: chamilo.oauth2_keycloak_check
redirect_params: { }
auth_server_url: null
realm: null
# configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration

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

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

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

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

@ -8,6 +8,7 @@ namespace Chamilo\CoreBundle\Controller;
use bbb;
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use Chamilo\CoreBundle\ServiceHelper\ThemeHelper;
use Chamilo\CoreBundle\ServiceHelper\TicketProjectHelper;
use Chamilo\CoreBundle\ServiceHelper\UserHelper;
@ -29,6 +30,7 @@ class PlatformConfigurationController extends AbstractController
private readonly TicketProjectHelper $ticketProjectHelper,
private readonly UserHelper $userHelper,
private readonly ThemeHelper $themeHelper,
private readonly AuthenticationConfigHelper $authenticationConfigHelper,
) {}
#[Route('/list', name: 'platform_config_list', methods: ['GET'])]
@ -41,6 +43,7 @@ class PlatformConfigurationController extends AbstractController
'studentview' => $requestSession->get('studentview'),
'plugins' => [],
'visual_theme' => $this->themeHelper->getVisualTheme(),
'external_authentication' => $this->authenticationConfigHelper->getEnabledProviders(),
];
$variables = [];

@ -0,0 +1,54 @@
<?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\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;
#[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'),
Facebook::class => $this->getProviderOptions('facebook'),
Keycloak::class => $this->getProviderOptions('keycloak'),
};
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);
}
}

@ -74,4 +74,9 @@ class ExtraFieldRepository extends ServiceEntityRepository
return $fieldInfo;
}
public function findByVariable(int $itemType, string $variable): ?ExtraField
{
return $this->findOneBy(['variable' => $variable, 'itemType' => $itemType]);
}
}

@ -10,6 +10,7 @@ use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\ExtraFieldItemInterface;
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
@ -141,4 +142,42 @@ class ExtraFieldValuesRepository extends ServiceEntityRepository
'value' => $result->getFieldValue(),
];
}
/**
* @return ExtraFieldValues|array<ExtraFieldValues>|null
*
* @throws NonUniqueResultException
*/
public function findByVariableAndValue(
ExtraField $extraField,
string|int $value,
bool $last = false,
bool $all = false,
bool $useLike = false,
): ExtraFieldValues|array|null {
$qb = $this->createQueryBuilder('s');
if ($useLike) {
$qb->andWhere($qb->expr()->like('s.fieldValue', ':value'));
$value = "%$value%";
} else {
$qb->andWhere($qb->expr()->eq('s.fieldValue', ':value'));
}
$query = $qb
->andWhere(
$qb->expr()->eq('s.field', ':f')
)
->orderBy('s.itemId', $last ? 'DESC' : 'ASC')
->setParameter('value', "$value")
->setParameter('f', $extraField)
->getQuery()
;
if ($all) {
return $query->getResult();
}
return $query->getOneOrNullResult();
}
}

@ -7,9 +7,11 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Repository\Node;
use Chamilo\CoreBundle\Entity\AccessUrl;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class AccessUrlRepository extends ResourceRepository
@ -36,4 +38,21 @@ class AccessUrlRepository extends ResourceRepository
return 0;
}
}
/**
* @return array<int, AccessUrl>
*/
public function findByUser(User $user): array
{
/** @var QueryBuilder $qb */
$qb = $this->createQueryBuilder('url');
return $qb
->join('url.users', 'users')
->where($qb->expr()->eq('users.user', ':user'))
->setParameter('user', $user->getId())
->getQuery()
->getResult()
;
}
}

@ -0,0 +1,85 @@
<?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 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,
protected readonly AccessUrlHelper $urlHelper,
) {
$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);
}
/**
* Find or create and save the new user.
*/
abstract protected function userLoader(AccessToken $accessToken): User;
}

@ -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 Cocur\Slugify\SlugifyInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Provider\FacebookUser;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
class FacebookAuthenticator extends AbstractAuthenticator
{
protected string $providerName = 'facebook';
public function __construct(
ClientRegistry $clientRegistry,
RouterInterface $router,
UserRepository $userRepository,
AuthenticationConfigHelper $authenticationConfigHelper,
AccessUrlHelper $urlHelper,
protected readonly SlugifyInterface $slugify,
) {
parent::__construct(
$clientRegistry,
$router,
$userRepository,
$authenticationConfigHelper,
$urlHelper,
);
}
public function supports(Request $request): ?bool
{
return 'chamilo.oauth2_facebook_check' === $request->attributes->get('_route');
}
protected function userLoader(AccessToken $accessToken): User
{
/** @var FacebookUser $resourceOwner */
$resourceOwner = $this->client->fetchUserFromToken($accessToken);
$user = $this->userRepository->findOneBy(['email' => $resourceOwner->getEmail()]);
if (!$user) {
$user = (new User())
->setCreatorId($this->userRepository->getRootUser()->getId())
;
}
$user
->setFirstname($resourceOwner->getFirstName())
->setLastname($resourceOwner->getLastName())
// ->setLocale($resourceOwner->getLocale())
->setEmail($resourceOwner->getEmail())
->setUsername($this->changeToValidChamiloLogin($resourceOwner->getEmail()))
->setPlainPassword('facebook')
->setStatus(STUDENT)
->setAuthSource('facebook')
->setRoleFromStatus(STUDENT)
;
$this->userRepository->updateUser($user);
$url = $this->urlHelper->getCurrent();
$url->addUser($user);
return $user;
}
private function changeToValidChamiloLogin(string $email): string
{
return $this->slugify->slugify($email);
}
}

@ -0,0 +1,273 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Security\Authenticator\OAuth2;
use Chamilo\CoreBundle\Entity\AccessUrl;
use Chamilo\CoreBundle\Entity\AccessUrlRelUser;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\ExtraFieldRepository;
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository;
use Chamilo\CoreBundle\Repository\Node\AccessUrlRepository;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper;
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper;
use Doctrine\ORM\EntityManagerInterface;
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,
AccessUrlHelper $urlHelper,
protected readonly ExtraFieldRepository $extraFieldRepository,
protected readonly ExtraFieldValuesRepository $extraFieldValuesRepository,
protected readonly AccessUrlRepository $accessUrlRepository,
protected readonly EntityManagerInterface $entityManager,
) {
parent::__construct(
$clientRegistry,
$router,
$userRepository,
$authenticationConfigHelper,
$urlHelper,
);
}
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() ?: 'localhost'))
->setUsername($username)
->setPlainPassword($username)
->setStatus(STUDENT)
->setCreatorId($this->userRepository->getRootUser()->getId())
;
}
$this->saveUserInfo($user, $resourceOwnerData, $providerParams);
$this->extraFieldValuesRepository->updateItemData(
$extraField,
$user,
$resourceOwnerId
);
$this->updateUrls($user, $resourceOwnerData, $providerParams);
} else {
/** @var User $user */
$user = $this->userRepository->find(
$existingUserExtraFieldValue->getItemId()
);
if ($providerParams['allow_update_user_info']) {
$this->saveUserInfo($user, $resourceOwnerData, $providerParams);
$this->updateUrls($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);
$url = $this->urlHelper->getCurrent();
$url->addUser($user);
}
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;
}
private function updateUrls(User $user, array $resourceOwnerData, array $providerParams): void
{
if (!($urlsField = $providerParams['resource_owner_urls_field'])) {
return;
}
$availableUrls = [];
$urls = $this->accessUrlRepository->findAll();
/** @var AccessUrl $existingUrl */
foreach ($urls as $existingUrl) {
$availableUrls[(string) $existingUrl->getId()] = $existingUrl->getId();
$availableUrls[$existingUrl->getUrl()] = $existingUrl->getId();
}
$allowedUrlIds = [];
foreach ($this->getValueByKey($resourceOwnerData, $urlsField) as $value) {
if (array_key_exists($value, $availableUrls)) {
$allowedUrlIds[] = $availableUrls[$value];
} else {
$newValue = ($value[-1] === '/') ? substr($value, 0, -1) : $value.'/';
if (array_key_exists($newValue, $availableUrls)) {
$allowedUrlIds[] = $availableUrls[$newValue];
}
}
}
$grantedUrlIds = [];
foreach ($this->accessUrlRepository->findByUser($user) as $grantedUrl) {
$grantedUrlIds[] = $grantedUrl->getId();
}
$urlRelUserRepo = $this->entityManager->getRepository(AccessUrlRelUser::class);
foreach (array_diff($grantedUrlIds, $allowedUrlIds) as $extraUrlId) {
$urlRelUser = $urlRelUserRepo->findOneBy(['user' => $user, 'url' => $extraUrlId]);
if ($urlRelUser) {
$this->entityManager->remove($urlRelUser);
}
}
$this->entityManager->flush();
foreach (array_diff($allowedUrlIds, $grantedUrlIds) as $missingUrlId) {
/** @var AccessUrl $missingUrl */
$missingUrl = $this->accessUrlRepository->find($missingUrlId);
$missingUrl->addUser($user);
}
$this->entityManager->flush();
}
}

@ -0,0 +1,58 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\Security\Authenticator\OAuth2;
use Chamilo\CoreBundle\Entity\User;
use League\OAuth2\Client\Token\AccessToken;
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner;
use Symfony\Component\HttpFoundation\Request;
class KeycloakAuthenticator extends AbstractAuthenticator
{
protected string $providerName = 'keycloak';
public function supports(Request $request): ?bool
{
return 'chamilo.oauth2_keycloak_check' === $request->attributes->get('_route');
}
protected function userLoader(AccessToken $accessToken): User
{
/** @var KeycloakResourceOwner $resourceOwner */
$resourceOwner = $this->client->fetchUserFromToken($accessToken);
$user = $this->userRepository->findOneBy(['username' => $resourceOwner->getUsername()])
?:
$this->userRepository->findOneBy(['username' => $resourceOwner->getId()]);
if (!$user) {
$user = (new User())
->setCreatorId($this->userRepository->getRootUser()->getId())
;
}
$username = $resourceOwner->getUsername() ?: $resourceOwner->getId();
$user
->setFirstname($resourceOwner->getFirstName())
->setLastname($resourceOwner->getLastName())
->setEmail($resourceOwner->getEmail())
->setUsername($username)
->setPlainPassword('keycloak')
->setStatus(STUDENT)
->setAuthSource('keycloak')
->setRoleFromStatus(STUDENT)
;
$this->userRepository->updateUser($user);
$url = $this->urlHelper->getCurrent();
$url->addUser($user);
return $user;
}
}

@ -0,0 +1,77 @@
<?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;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use function Symfony\Component\String\u;
readonly class AuthenticationConfigHelper
{
public function __construct(
private ParameterBagInterface $parameterBag,
private AccessUrlHelper $urlHelper,
private UrlGeneratorInterface $urlGenerator,
) {}
public function getParams(string $providerName, ?AccessUrl $url = null): array
{
$providers = $this->getProvidersForUrl($url);
if (!isset($providers[$providerName])) {
throw new InvalidArgumentException('Invalid authentication provider for access URL');
}
return $providers[$providerName];
}
public function isEnabled(string $methodName, ?AccessUrl $url = null): bool
{
$configParams = $this->getParams($methodName, $url);
return $configParams['enabled'] ?? false;
}
public function getEnabledProviders(?AccessUrl $url = null): array
{
$urlProviders = $this->getProvidersForUrl($url);
$enabledProviders = [];
foreach ($urlProviders as $providerName => $providerParams) {
if ($providerParams['enabled'] ?? false) {
$enabledProviders[] = [
'name' => $providerName,
'title' => $providerParams['title'] ?? u($providerName)->title(),
'url' => $this->urlGenerator->generate("chamilo.oauth2_{$providerName}_start"),
];
}
}
return $enabledProviders;
}
private function getProvidersForUrl(?AccessUrl $url): array
{
$urlId = $url ? $url->getId() : $this->urlHelper->getCurrent()->getId();
$authentication = $this->parameterBag->get('authentication');
if (isset($authentication[$urlId])) {
return $authentication[$urlId];
}
if (isset($authentication['default'])) {
return $authentication['default'];
}
throw new InvalidArgumentException('Invalid access URL configuration');
}
}
Loading…
Cancel
Save