Mugo Web main content.

Single sign-ons on a multi-site Ibexa DXP install using Symfony kernel events

By: Thiago Campos Viana | February 17, 2021 | User experience and ibexadxp

The Ibexa DXP excels at multi-site installations. For example, on the same installation, you can create multiple sites that share the same database, or that share the same code but use independent databases. Different sites can share the same base design and templates, but make use of site-specific overrides where relevant. For sites with user logins, you might need Single Sign-On (SSO) behavior, so that whenever a user logs in to one site, they are automatically logged in to all sites without having to re-enter their credentials. In this post, we'll review how to implement SSO logins on an Ibexa DXP website, specifically one where some of the sites are still using eZ Publish legacy siteaccesses.

Symfony events

The Symfony framework offers several generic code-level events where we can inject custom code during the handling of an HTTP request. We are thus able to put our single sign-on code early on in the execution of a page response.

For our purposes, the most interesting Symfony events are: kernel.request, kernel.controller, and kernel.response.

Implementing an SSO handler with Symfony events

We should start by reviewing when these events execute, so that we can implement the right strategy for our SSO handler.

The kernel.request event is one of the earliest events triggered by Symfony. Therefore, it is a good candidate for automatically logging in a user. We just need to be aware that the HTTP response has not yet been defined. The next event is kernel.controller, which is called when the system has identified which controller to execute. There is no clear advantage in this case over kernel.request. Finally, kernel.response is one of the last events to be triggered, which is ideal to set any necessary session related cookies, since the HTTP response has been defined.

It is important to note that these events are also invoked whenever you call the Twig templates' render method. This means that they can be called several times during the rendering of an HTTP response.

With that in mind, we opted to execute the majority of our code in the kernel.request event. Here, we can inspect any SSO-related cookies in order to check whether the user is currently logged in to another siteaccess. Then we can automatically log the user in to the current siteaccess. This guarantees that user related variables such as app.user are correctly set in Twig templates. Then, we set any new session-related cookies during the kernel.response event, and mark the response as private, so that sessions don't get trapped in any shared cache and thus cannot leak between users.

The code

Here is the key SSO code. Note that we are in the process of migrating each siteaccess to use the "new stack", which is to say the full Ibexa DXP and Symfony stack. This process involves using what is called the "legacy bridge" setup. In our case, users who arrive at the siteaccess in question have already logged in to a legacy siteaccess.

// Resources/config/services.yml // Define all the kernel event listeners here services:     Mugo\SSOBundle\EventListener\KernelRequestListener:         class: %mugo_sso.kernel_listener.class%         arguments: ['@service_container']         tags:             - { name: 'kernel.event_listener', event: 'kernel.request', method: 'onKernelRequest' }     Mugo\SSOBundle\EventListener\KernelResponseListener:         class: %mugo_sso.kernel_listener.class%         arguments: ['@service_container']         tags:             - { name: 'kernel.event_listener', event: 'kernel.response', method: 'onKernelResponse' }   // EventListener/KernelListener.php // The code itself <?php namespace Mugo\SSOBundle\EventListener; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; use \Symfony\Component\DependencyInjection\Container; use eZ\Publish\Core\MVC\Symfony\Security\User; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; class KernelListener {     /** @var Container */     protected $container;     /** @var bool */     protected static $isPrivate = false;     public function __construct(Container $container)     {         $this->container    = $container;     }     /**      * We login the user onKernelRequest but we need the onKernelResponse to set the cookies      * The response is not avaiable at this point      * @param GetResponseEvent $event      * @return bool      */     public function onKernelRequest(GetResponseEvent $event)     {         // do nothing for certain legacy siteaccesses or sub requests         if( isIgnoredSiteaccess() || !$event->isMasterRequest() )         {             return;         }         // Get the User entity.         $token = $this->container->get('security.token_storage')->getToken();         $user = false;         if($token)         {             $user = $this->container->get('security.token_storage')->getToken()->getUser();         }         $userIsLoggedIn = $user instanceof User;         if( !$userIsLoggedIn && isset($_COOKIE['ssologin_cookie']) && !empty($_COOKIE['ssologin_cookie']) )         {             $cookieUser = $this->getUserFromCookies(); // check db for custom sessions associated with current cookies             if($cookieUser)             {                 $repository = $this->container->get( 'ezpublish.api.repository' );                 $ezUser = $repository->getUserService()->loadUserByLogin($cookieUser->getUsername());                 $repository->getPermissionResolver()->setCurrentUserReference($ezUser);                 $user = new \eZ\Publish\Core\MVC\Symfony\Security\User($ezUser, ['ROLE_USER']);                 $token = new \Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken($user, null,'ezpublish_front', $user->getRoles());                 $this->container->get('security.token_storage')->setToken($token);                 $this->container->get('session')->set('_security_ezpublish_front', serialize($token));                 self::$isPrivate = true;             }         }     }     /**      * We set the cookies here because the response is not set onKernelRequest       * @param FilterResponseEvent $event      * @return bool      */     public function onKernelResponse(FilterResponseEvent $event)     {         // do nothing for certain legacy siteaccesses or sub requests         if( isIgnoredSiteaccess() )         {             return;         }         $response = $event->getResponse();         // Get the User entity.         $token = $this->container->get('security.token_storage')->getToken();         $user = false;         if($token)         {             $user = $this->container->get('security.token_storage')->getToken()->getUser();         }         $userIsLoggedIn = $user instanceof User;         if( $userIsLoggedIn && !isset($_COOKIE['tssologin_cookie']) )         {             $login = $user->getUsername();             // get all the extra information you need here             $cookieDomain = $this->getCookieDomain();             $response->headers->setCookie(new Cookie('ssologin_cookie', cookieVal, $cookieExpires, '/', $cookieDomain, false, false));             ... // define other login cookies here             self::$isPrivate = true;         }         // when setting the user cookies it is recommended to set the response private to avoid shared cache issues         if( self::$isPrivate )         {             $response->setPrivate();             $response->setSharedMaxAge(0);         }     } ...

Conclusion

Understanding when each event executes is very important in any Symfony application. It is also important to ensure that code is not unnecessarily executed multiple times during a single page response, in order to prevent undesirable effects. This can help us to build robust solutions for SSO logins and much more!

Comments

blog comments powered by Disqus