diff --git a/.github/workflows/codeception.yml b/.github/workflows/codeception.yml index 81bf3e7c..e66e4de2 100644 --- a/.github/workflows/codeception.yml +++ b/.github/workflows/codeception.yml @@ -100,7 +100,7 @@ jobs: run: | nohup $CHROMEWEBDRIVER/chromedriver --url-base=/wd/hub /dev/null 2>&1 & - - name: Start Webserver and Chrome + - name: Start Symfony Server run: | curl -sS https://get.symfony.com/cli/installer | bash -s -- --install-dir=$HOME/.symfony/bin ~/.symfony/bin/symfony server:start --port=8080 --dir=public --allow-http --no-tls --daemon diff --git a/UPGRADE.md b/UPGRADE.md index 52d18aca..de69deab 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,9 @@ # Upgrade Notes +### 5.0.1 +- **[IMPROVEMENT]**: Introduce `OAUTH_RESOURCE_MAPPING_REFRESH` Event +- **[IMPROVEMENT]**: Configurable Firewall Name via container parameter `members.firewall_name` + ## Migrating from Version 4.x to Version 5.0 ### Global Changes diff --git a/config/packages/security_auth_manager.yaml b/config/packages/security_auth_manager.yaml index b5b16f03..65b382eb 100644 --- a/config/packages/security_auth_manager.yaml +++ b/config/packages/security_auth_manager.yaml @@ -1,3 +1,7 @@ +# if you're using a different firewall name, you need to enable this parameter +# parameters: +# members.firewall_name: 'your_fw_name' + security: # symfony default is set to "true". diff --git a/config/services/event.yaml b/config/services/event.yaml index 2fa6d9f3..d3857100 100644 --- a/config/services/event.yaml +++ b/config/services/event.yaml @@ -7,6 +7,8 @@ services: # event: check auth MembersBundle\EventListener\AuthenticationListener: + arguments: + $firewallName: '%members.firewall_name%' tags: - { name: kernel.event_subscriber } diff --git a/docs/40_Events.md b/docs/40_Events.md index abab5534..aaf17f4e 100644 --- a/docs/40_Events.md +++ b/docs/40_Events.md @@ -306,7 +306,7 @@ | Type | Reference | |:--- |:--- | | **const** | `\MembersEvent:OAUTH_RESOURCE_MAPPING_PROFILE` | -| **name** | `members.oauth.connection.success` | +| **name** | `members.oauth.resource_mapping.profile` | | **class** | `\MembersBundle\Event\OAuth\OAuthResourceEvent` | | **description** | The OAUTH_RESOURCE_MAPPING_PROFILE event occurs before a sso identity gets assigned to given user profile. This event allows you to map resource data (e.g. google) to your user identity. | @@ -315,10 +315,19 @@ | Type | Reference | |:--- |:--- | | **const** | `\MembersEvent:OAUTH_RESOURCE_MAPPING_REGISTRATION` | -| **name** | `members.oauth.connection.success` | +| **name** | `members.oauth.resource_mapping.registration` | | **class** | `\MembersBundle\Event\OAuth\OAuthResourceEvent` | | **description** | The OAUTH_RESOURCE_MAPPING_REGISTRATION event occurs before the registration form gets rendered. This event allows you to map resource data (e.g. google) to your registration form. | +### members.oauth.resource_mapping.refresh + +| Type | Reference | +|:--- |:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **const** | `\MembersEvent:OAUTH_RESOURCE_MAPPING_REFRESH` | +| **name** | `members.oauth.resource_mapping.refresh` | +| **class** | `\MembersBundle\Event\OAuth\OAuthResourceRefreshEvent` | +| **description** | TThe OAUTH_RESOURCE_MAPPING_REFRESH event occurs after an existing sso identity has been found. This event allows you to map resource data (e.g. google) to your existing user identity. | + *** ### members.oauth.identity_status.profile_completion diff --git a/docs/SSO/12_ResourceMapping.md b/docs/SSO/12_ResourceMapping.md index 1b568a0c..424043c8 100644 --- a/docs/SSO/12_ResourceMapping.md +++ b/docs/SSO/12_ResourceMapping.md @@ -40,7 +40,8 @@ class MembersResourceMappingListener implements EventSubscriberInterface { return [ MembersEvents::OAUTH_RESOURCE_MAPPING_PROFILE => 'onProfileMapping', - MembersEvents::OAUTH_RESOURCE_MAPPING_REGISTRATION => 'onRegistrationMapping' + MembersEvents::OAUTH_RESOURCE_MAPPING_REGISTRATION => 'onRegistrationMapping', + MembersEvents::OAUTH_RESOURCE_MAPPING_REFRESH => 'onRegistrationRefresh', ]; } @@ -62,6 +63,18 @@ class MembersResourceMappingListener implements EventSubscriberInterface $this->mapData($user, $ownerDetails); } + public function onRegistrationRefresh(OAuthResourceRefreshEvent $event): void + { + $user = $event->getUser(); + $resourceOwner = $event->getResourceOwner(); + $ownerDetails = $resourceOwner->toArray(); + + $user->setUserName($ownerDetails['name']); + + // ATTENTION! You need to inform event about changes! + $event->setHasChanged(true); + } + protected function mapData(UserInterface $user, array $ownerDetails): void { if (empty($user->getEmail()) && isset($ownerDetails['email'])) { diff --git a/docs/SSO/20_Installation.md b/docs/SSO/20_Installation.md index 341db964..3c293dd3 100644 --- a/docs/SSO/20_Installation.md +++ b/docs/SSO/20_Installation.md @@ -105,6 +105,14 @@ members: activation_type: 'complete_profile' # choose between "complete_profile" and "instant" ``` +## Configure Firewall +If your using a different name for your firewall than `members_fe` you need to configure the container parameter: + +```yaml +parameters: + members.firewall_name: your_fw_name +``` + ## Configure Client Every provider comes with its own configuration. In this example, we're going to setup the google client: diff --git a/src/DependencyInjection/MembersExtension.php b/src/DependencyInjection/MembersExtension.php index 9aa6b756..97626872 100644 --- a/src/DependencyInjection/MembersExtension.php +++ b/src/DependencyInjection/MembersExtension.php @@ -23,6 +23,11 @@ public function prepend(ContainerBuilder $container): void $configs = $container->getExtensionConfig($this->getAlias()); $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); + /** @phpstan-ignore-next-line */ + if (!$container->hasParameter('members.firewall_name')) { + $container->setParameter('members.firewall_name', 'members_fe'); + } + $oauthEnabled = false; if ($container->hasExtension('security') === true && $config['oauth']['enabled'] === true) { $oauthEnabled = true; @@ -114,6 +119,8 @@ protected function enableOauth(ContainerBuilder $container, array $config): void protected function extendPimcoreSecurityConfiguration(ContainerBuilder $container, bool $oauthEnabled): void { + $firewallName = $container->getParameter('members.firewall_name'); + $container->loadFromExtension('pimcore', [ 'security' => [ 'password_hasher_factories' => [ @@ -125,7 +132,7 @@ protected function extendPimcoreSecurityConfiguration(ContainerBuilder $containe if ($oauthEnabled === true) { $container->loadFromExtension('security', [ 'firewalls' => [ - 'members_fe' => [ + $firewallName => [ 'custom_authenticators' => [ OAuthIdentityAuthenticator::class ] diff --git a/src/Event/OAuth/OAuthResourceRefreshEvent.php b/src/Event/OAuth/OAuthResourceRefreshEvent.php new file mode 100644 index 00000000..0c8e5677 --- /dev/null +++ b/src/Event/OAuth/OAuthResourceRefreshEvent.php @@ -0,0 +1,18 @@ +hasChanged === true; + } + + public function setHasChanged(bool $hasChanged): void + { + $this->hasChanged = $hasChanged; + } +} diff --git a/src/Exception/EntityNotRefreshedException.php b/src/Exception/EntityNotRefreshedException.php new file mode 100644 index 00000000..83a0282f --- /dev/null +++ b/src/Exception/EntityNotRefreshedException.php @@ -0,0 +1,7 @@ +getToken(), function () use ($accessToken, $client, $provider, $type, $parameter) { + $user = $client->fetchUserFromToken($accessToken); $oAuthResponse = new OAuthResponse($provider, $accessToken, $user, $parameter); - $memberUser = $this->ssoIdentityManager->getUserBySsoIdentity($oAuthResponse->getProvider(), $oAuthResponse->getResourceOwner()->getId()); + $memberUser = $this->oAuthRegistrationHandler->getRefreshedUserFromUserResponse($oAuthResponse); if ($memberUser instanceof UserInterface) { return $memberUser; diff --git a/src/Security/OAuth/AccountConnector.php b/src/Security/OAuth/AccountConnector.php index 3469bb2b..7737dcc8 100755 --- a/src/Security/OAuth/AccountConnector.php +++ b/src/Security/OAuth/AccountConnector.php @@ -4,6 +4,7 @@ use MembersBundle\Adapter\Sso\SsoIdentityInterface; use MembersBundle\Adapter\User\UserInterface as MembersUserInterface; +use MembersBundle\Exception\EntityNotRefreshedException; use MembersBundle\Manager\SsoIdentityManagerInterface; use MembersBundle\Service\ResourceMappingService; use Symfony\Component\Security\Core\User\UserInterface; @@ -55,6 +56,18 @@ public function connectToSsoIdentity(UserInterface $user, OAuthResponseInterface return $ssoIdentity; } + /** + * @throws EntityNotRefreshedException + */ + public function refreshSsoIdentityUser(UserInterface $user, OAuthResponseInterface $oAuthResponse): void + { + if (!$user instanceof MembersUserInterface) { + throw new \InvalidArgumentException('User is not supported'); + } + + $this->resourceMappingService->mapResourceData($user, $oAuthResponse->getResourceOwner(), ResourceMappingService::MAP_FOR_REFRESH); + } + protected function applyCredentialsToSsoIdentity(SsoIdentityInterface $ssoIdentity, OAuthResponseInterface $oAuthResponse): void { $token = $oAuthResponse->getAccessToken(); diff --git a/src/Security/OAuth/AccountConnectorInterface.php b/src/Security/OAuth/AccountConnectorInterface.php index 3c428825..88683065 100755 --- a/src/Security/OAuth/AccountConnectorInterface.php +++ b/src/Security/OAuth/AccountConnectorInterface.php @@ -8,4 +8,6 @@ interface AccountConnectorInterface { public function connectToSsoIdentity(UserInterface $user, OAuthResponseInterface $oAuthResponse): SsoIdentityInterface; + + public function refreshSsoIdentityUser(UserInterface $user, OAuthResponseInterface $oAuthResponse): void; } diff --git a/src/Security/OAuth/OAuthRegistrationHandler.php b/src/Security/OAuth/OAuthRegistrationHandler.php index 12458737..91aeab4a 100644 --- a/src/Security/OAuth/OAuthRegistrationHandler.php +++ b/src/Security/OAuth/OAuthRegistrationHandler.php @@ -4,6 +4,7 @@ use MembersBundle\Adapter\Sso\SsoIdentityInterface; use MembersBundle\Adapter\User\UserInterface; +use MembersBundle\Exception\EntityNotRefreshedException; use MembersBundle\Manager\SsoIdentityManagerInterface; use MembersBundle\Manager\UserManagerInterface; use MembersBundle\Service\RequestPropertiesForUserExtractorServiceInterface; @@ -24,6 +25,26 @@ public function getUserFromUserResponse(OAuthResponseInterface $OAuthResponse): return $this->ssoIdentityManager->getUserBySsoIdentity($OAuthResponse->getProvider(), $OAuthResponse->getResourceOwner()->getId()); } + public function getRefreshedUserFromUserResponse(OAuthResponseInterface $oAuthResponse): ?UserInterface + { + $user = $this->ssoIdentityManager->getUserBySsoIdentity($oAuthResponse->getProvider(), $oAuthResponse->getResourceOwner()->getId()); + + if (!$user instanceof UserInterface) { + return null; + } + + try { + $this->accountConnector->refreshSsoIdentityUser($user, $oAuthResponse); + } catch (EntityNotRefreshedException $e) { + // entity hasn't changed. return + return $user; + } + + $this->userManager->updateUser($user); + + return $user; + } + /** * @throws \Exception */ diff --git a/src/Service/ResourceMappingService.php b/src/Service/ResourceMappingService.php index 9493a2dc..a883deb5 100644 --- a/src/Service/ResourceMappingService.php +++ b/src/Service/ResourceMappingService.php @@ -2,10 +2,12 @@ namespace MembersBundle\Service; +use League\OAuth2\Client\Provider\ResourceOwnerInterface; use MembersBundle\Adapter\User\UserInterface; use MembersBundle\Event\OAuth\OAuthResourceEvent; +use MembersBundle\Event\OAuth\OAuthResourceRefreshEvent; +use MembersBundle\Exception\EntityNotRefreshedException; use MembersBundle\MembersEvents; -use League\OAuth2\Client\Provider\ResourceOwnerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface as ComponentEventDispatcherInterface; @@ -13,6 +15,7 @@ class ResourceMappingService { public const MAP_FOR_PROFILE = 'profile'; public const MAP_FOR_REGISTRATION = 'registration'; + public const MAP_FOR_REFRESH = 'refresh'; public function __construct( protected string $authIdentifier, @@ -22,6 +25,7 @@ public function __construct( /** * @throws \Exception + * @throws EntityNotRefreshedException */ public function mapResourceData(UserInterface $user, ResourceOwnerInterface $resourceOwner, string $type): void { @@ -39,11 +43,24 @@ public function mapResourceData(UserInterface $user, ResourceOwnerInterface $res return; } - $this->eventDispatcher->dispatch(new OAuthResourceEvent($user, $resourceOwner), $eventName); + $eventClass = $type === self::MAP_FOR_REFRESH ? OAuthResourceRefreshEvent::class : OAuthResourceEvent::class; + $event = new $eventClass($user, $resourceOwner); + + $this->eventDispatcher->dispatch($event, $eventName); + + if ($event instanceof OAuthResourceRefreshEvent && $event->hasChanged() === false) { + throw new EntityNotRefreshedException(sprintf('entity %d has not changed', $user->getId())); + } + } public function addDefaults(UserInterface $user, ResourceOwnerInterface $resourceOwner, string $type): void { + // do not add default values in refresh mode + if ($type === self::MAP_FOR_REFRESH) { + return; + } + $ownerDetails = $resourceOwner->toArray(); $disallowedProperties = ['lastLogin', 'password', 'confirmationToken', 'passwordRequestedAt', 'groups', 'ssoIdentities']; diff --git a/tests/_envs/github.yml b/tests/_envs/github.yml index dcaa9040..99f9fbe0 100644 --- a/tests/_envs/github.yml +++ b/tests/_envs/github.yml @@ -7,7 +7,7 @@ modules: wait: 1 window_size: 1280x1024 capabilities: - chromeOptions: - args: ['--headless', '--disable-gpu'] + 'goog:chromeOptions': + args: ['--no-sandbox', '--disable-extensions', '--headless', '--disable-gpu', '--disable-dev-shm-usage', '--window-size=1280,1024'] prefs: download.default_directory: '%TEST_BUNDLE_TEST_DIR%/_data/downloads' \ No newline at end of file diff --git a/tests/_etc/config/app/config.yaml b/tests/_etc/config/app/config.yaml index 81437e3a..3cc882ed 100755 --- a/tests/_etc/config/app/config.yaml +++ b/tests/_etc/config/app/config.yaml @@ -62,6 +62,7 @@ pimcore_admin: framework: session: + gc_probability: 0 storage_factory_id: session.storage.factory.native profiler: diff --git a/tests/_etc/config/app/system_settings.yaml b/tests/_etc/config/app/system_settings.yaml index 52951c74..f19aeebf 100755 --- a/tests/_etc/config/app/system_settings.yaml +++ b/tests/_etc/config/app/system_settings.yaml @@ -34,7 +34,7 @@ pimcore: steps: 10 email: debug: - email_addresses: shagspiel@dachcom.ch + email_addresses: development@dachcom.ch pimcore_admin: assets: hide_edit_image: true