diff --git a/.env b/.env index 5906397b..352d553a 100644 --- a/.env +++ b/.env @@ -79,6 +79,14 @@ ERROR_PAGE_SHOW_HELP=1 # Set this to 1 to enable SAML single sign on SAML_ENABLED=0 +# A JSON encoded array of role mappings in the form { "saml_role": PARTDB_GROUP_ID, "*": PARTDB_GROUP_ID } +SAML_ROLE_MAPPING="{}" +# A mapping could look like the following +#SAML_ROLE_MAPPING='{ "*": 2, "editor": 3, "admin": 1 }' + +# When this is set to 1, the group of SAML users will be updated everytime they login based on their SAML roles +SAML_UPDATE_GROUP_ON_LOGIN=1 + # The entity ID of your SAML IDP (e.g. the realm name of your Keycloak server) SAML_IDP_ENTITY_ID="https://idp.changeme.invalid/realms/master" # The URL of your SAML IDP SingleSignOnService (e.g. the endpoint of your Keycloak server) diff --git a/config/parameters.yaml b/config/parameters.yaml index e7b10354..cd372483 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -119,3 +119,5 @@ parameters: env(TRUSTED_HOSTS): '' # Trust all host names by default env(DEFAULT_URI): 'https://partdb.changeme.invalid/' + + env(SAML_ROLE_MAPPING): '{}' diff --git a/config/services.yaml b/config/services.yaml index a5914ee2..925b1c83 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -128,7 +128,13 @@ services: #################################################################################################################### saml_user_factory: - class: App\Security\SamlUserFactory + alias: App\Security\SamlUserFactory + public: true + + App\Security\SamlUserFactory: + arguments: + $saml_role_mapping: '%env(json:SAML_ROLE_MAPPING)%' + $update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%' #################################################################################################################### # Cache diff --git a/config/services_test.yaml b/config/services_test.yaml deleted file mode 100644 index e29da6fc..00000000 --- a/config/services_test.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Service overrides for the test environment - -services: - saml_user_factory: - class: App\Security\SamlUserFactory - public: true - - App\Security\SamlUserFactory: - public: true \ No newline at end of file diff --git a/src/Security/SamlUserFactory.php b/src/Security/SamlUserFactory.php index fd181133..05394306 100644 --- a/src/Security/SamlUserFactory.php +++ b/src/Security/SamlUserFactory.php @@ -20,12 +20,32 @@ namespace App\Security; +use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use Hslavich\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken; use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\User\UserInterface; -class SamlUserFactory implements SamlUserFactoryInterface +class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterface { + private EntityManagerInterface $em; + private array $saml_role_mapping; + private bool $update_group_on_login; + + public function __construct(EntityManagerInterface $entityManager, ?array $saml_role_mapping, bool $update_group_on_login) + { + $this->em = $entityManager; + if ($saml_role_mapping) { + $this->saml_role_mapping = $saml_role_mapping; + } else { + $this->saml_role_mapping = []; + } + $this->update_group_on_login = $update_group_on_login; + } + public const SAML_PASSWORD_PLACEHOLDER = '!!SAML!!'; public function createUser($username, array $attributes = []): UserInterface @@ -37,8 +57,98 @@ class SamlUserFactory implements SamlUserFactoryInterface //This is a SAML user now! $user->setSamlUser(true); + //Update basic user information $user->setSamlAttributes($attributes); + //Check if we can find a group for this user based on the SAML attributes + $group = $this->mapSAMLAttributesToLocalGroup($attributes); + $user->setGroup($group); + return $user; } + + /** + * This method is called after a successful authentication. It is used to update the group of the user, + * based on the new SAML attributes. + * @param AuthenticationSuccessEvent $event + * @return void + */ + public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void + { + if (! $this->update_group_on_login) { + return; + } + + $token = $event->getAuthenticationToken(); + $user = $token->getUser(); + //Only update the group if the user is a SAML user + if (! $token instanceof SamlToken || ! $user instanceof User) { + return; + } + + //Check if we can find a group for this user based on the SAML attributes + $group = $this->mapSAMLAttributesToLocalGroup($token->getAttributes()); + //If needed update the group of the user and save it to DB + if ($group !== $user->getGroup()) { + $user->setGroup($group); + $this->em->flush($user); + } + } + + /** + * Maps the given SAML attributes to a local group. + * @param array $attributes The SAML attributes + * @return Group|null + */ + public function mapSAMLAttributesToLocalGroup(array $attributes): ?Group + { + //Extract the roles from the SAML attributes + $roles = $attributes['group'] ?? []; + $group_id = $this->mapSAMLRolesToLocalGroupID($roles); + + //Check if we can find a group with the given ID + if ($group_id !== null) { + $group = $this->em->find(Group::class, $group_id); + if ($group !== null) { + return $group; + } + } + + //If no group was found, return null + return null; + } + + /** + * Maps a list of SAML roles to a local group ID. + * @param array $roles The list of SAML roles + * @param array $map|null The mapping from SAML roles. If null, the global mapping will be used. + * @return int|null The ID of the local group or null if no mapping was found. + */ + public function mapSAMLRolesToLocalGroupID(array $roles, array $map = null): ?int + { + $map = $map ?? $this->saml_role_mapping; + + //Iterate over all roles and check if we have a mapping for it. + foreach ($roles as $role) { + if (array_key_exists($role, $map)) { + //We use the first available mapping + return (int) $map[$role]; + } + } + + //If no applicable mapping was found, check if we have a default mapping + if (array_key_exists('*', $map)) { + return (int) $map['*']; + } + + //If no mapping was found, return null + return null; + } + + public static function getSubscribedEvents(): array + { + return [ + AuthenticationSuccessEvent::class => 'onAuthenticationSuccess', + ]; + } } \ No newline at end of file diff --git a/tests/Security/SamlUserFactoryTest.php b/tests/Security/SamlUserFactoryTest.php index 6127237a..ab349f68 100644 --- a/tests/Security/SamlUserFactoryTest.php +++ b/tests/Security/SamlUserFactoryTest.php @@ -62,4 +62,27 @@ class SamlUserFactoryTest extends WebTestCase $this->assertEquals('IT', $user->getDepartment()); $this->assertEquals('j.doe@invalid.invalid', $user->getEmail()); } + + public function testMapSAMLRolesToLocalGroupID() + { + $mapping = [ + 'employee' => 1, + 'admin' => 2, + 'manager' => 3, + 'administrator' => 2, + '*' => 4, + ]; + + //Test if mapping works + $this->assertEquals(1, $this->service->mapSAMLRolesToLocalGroupID(['employee'], $mapping)); + //Only the first valid mapping should be used + $this->assertEquals(1, $this->service->mapSAMLRolesToLocalGroupID(['employee', 'admin'], $mapping)); + $this->assertSame(2, $this->service->mapSAMLRolesToLocalGroupID(['does_not_matter', 'admin', 'employee'], $mapping)); + //Test if mapping is case sensitive + $this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID(['ADMIN'], $mapping)); + + //Test that wildcard mapping works + $this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID(['entry1', 'entry2'], $mapping)); + $this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID([], $mapping)); + } }