Friday, July 13, 2012

Logic Exception: Symfony2 SecurityBundle and FOSUserBundle integration: How does it work?

Logic Exception: Symfony2 SecurityBundle and FOSUserBundle integration: How does it work?


Symfony2 SecurityBundle and FOSUserBundle integration: How does it work?

OVERVIEW

A couple of days ago, I realized I needed to add some new functionality to the login process. Specifically, I needed to track all previous login attempts. Not knowing anything about the new Symfony2 SecurityBundle, I had to go through the underlying code to understand what was going on. In the process, I think got a basic idea about how the new SecurityBundle interacts with FOSUserBundle.

CONFIGURATION

I have a basic security configuration as illustrated below.
app/config/security.yml
security:
    encoders:
        Symfony\Component\Security\Core\User\User: plaintext
        
    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN:  ROLE_ADMIN
        
    providers:
        fos_userbundle:
            id: fos_user.user_manager

    firewalls:
        main:
            pattern: .*
            form_login:
                provider:   fos_userbundle
                check_path: /user/login_check
                login_path: /user/login
            logout:
                path: /user/logout
            anonymous:    true
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        login:
            pattern: ^/user/login$
            security: false
Full security configuration reference can be found at here
app/config/routing.yml
fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"
    prefix: /user
As you can see above, we are importing all FOSUserBundle security routing rules with the /user prefix. Now, let's have a look at the security routing rules under FOSUserBundle.
vendor/bundles/FOS/UserBundle/Resources/config/routing/security.xml




    
        FOSUserBundle:Security:login
    

    
        FOSUserBundle:Security:check
    

    
        FOSUserBundle:Security:logout
    


It should be pretty self explanatory up to this point. We have a default security routing config file that we are importing into our application with the /user prefix and we have all the matching paths (login_path, check_path, etc.) defined in our app/config/config.yml for a working FOSUserBundle implementation. Now, let's have a look at FOSUserBundle SecurityController.
vendor/bundles/FOS/UserBundle/Controller/SecurityController.php
  1. namespace FOS\UserBundle\Controller;  
  2.   
  3. use Symfony\Component\DependencyInjection\ContainerAware;  
  4. use Symfony\Component\Security\Core\SecurityContext;  
  5. use Symfony\Component\Security\Core\Exception\AuthenticationException;  
  6.   
  7. class SecurityController extends ContainerAware  
  8. {  
  9.     public function loginAction()  
  10.     {  
  11.         $request = $this->container->get('request');  
  12.         /* @var $request \Symfony\Component\HttpFoundation\Request */  
  13.         $session = $request->getSession();  
  14.         /* @var $session \Symfony\Component\HttpFoundation\Session */  
  15.   
  16.         // get the error if any (works with forward and redirect -- see below)  
  17.         if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {  
  18.             $error = $request->attributes->get(SecurityContext::AUTHENTICATION_ERROR);  
  19.         } elseif (null !== $session && $session->has(SecurityContext::AUTHENTICATION_ERROR)) {  
  20.             $error = $session->get(SecurityContext::AUTHENTICATION_ERROR);  
  21.             $session->remove(SecurityContext::AUTHENTICATION_ERROR);  
  22.         } else {  
  23.             $error = '';  
  24.         }  
  25.   
  26.         if ($error) {  
  27.             // TODO: this is a potential security risk (see http://trac.symfony-project.org/ticket/9523)  
  28.             $error = $error->getMessage();  
  29.         }  
  30.         // last username entered by the user  
  31.         $lastUsername = (null === $session) ? '' : $session->get(SecurityContext::LAST_USERNAME);  
  32.   
  33.         return $this->container->get('templating')->renderResponse('FOSUserBundle:Security:login.html.'.$this->container->getParameter('fos_user.template.engine'), array(  
  34.             'last_username' => $lastUsername,  
  35.             'error'         => $error,  
  36.         ));  
  37.     }  
  38.   
  39.     public function checkAction()  
  40.     {  
  41.         throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.');  
  42.     }  
  43.   
  44.     public function logoutAction()  
  45.     {  
  46.         throw new \RuntimeException('You must activate the logout in your security firewall configuration.');  
  47.     }  
  48. }  
In the security controller, we have both check and logout action methods included but they are not defined. Yet, the security process works. How is this possible?

HOW DOES IT WORK?

Well, a single word: listeners.

  • First, an appropriate listener factory class is identified based on the authentication type.
  • Second, this listener factory class registers a user provider identified by the provider key under the firewall definition in security.yml. In our case, our provider is called fos_userbundle. This process basically links SecurityBundle to FOSUserBundle for authenticating submitted from values against a database.
  • Next, the listener factory class registers an authentication listener identified by "security.authentication.listener.form". This code listens to login attempts at the /user/login path.
  • Finally, FOSUserBundle registers a login listener for security.interactive_login event. This event indicates a successful login and the listener is used to execute post login code.

Let's go back to the beginning; everything starts when SecurityBundle is initiated and our security factory definitions are loaded from security_factories.xml.
Symfony/Bundle/SecurityBundle/Resources/config/security_factories.xml
  1. xml version="1.0" ?>  
  2.   
  3. <container xmlns="http://symfony.com/schema/dic/services"  
  4.     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
  5.     xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">  
  6.   
  7.     <services>  
  8.         <service id="security.authentication.factory.form" class="Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory">  
  9.             <tag name="security.listener.factory" />  
  10.         service>  
  11.   
  12.         <service id="security.authentication.factory.x509" class="Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory">  
  13.             <tag name="security.listener.factory" />  
  14.         service>  
  15.   
  16.         <service id="security.authentication.factory.basic" class="Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicFactory">  
  17.             <tag name="security.listener.factory" />  
  18.         service>  
  19.   
  20.         <service id="security.authentication.factory.digest" class="Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpDigestFactory">  
  21.             <tag name="security.listener.factory" />  
  22.         service>  
  23.   
  24.         <service id="security.authentication.factory.remember_me" class="Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory">  
  25.             <tag name="security.listener.factory" />  
  26.         service>  
  27.     services>  
  28. container>  
Basically, the call to load the SecurityBundle (see SecurityExtension::load()) simply calls the SecurityExtension::createFirewalls() method which in turn makes a call to the SecurityExtension::createListenerFactories() method. This method then loads all services tagged with "security.listener.factory" in the security_factories.xml file which is included above.
Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
  1. private function createFirewalls($config, ContainerBuilder $container)  
  2. {  
  3.     // ...  
  4.   
  5.     // create security listener factories  
  6.     $factories = $this->createListenerFactories($container$config);  
  7.   
  8.     // ...  
  9. }  
Listener factories are classes that are responsible for initiating listeners based on the authentication type. Symfony SecurityBundle provides factories for multiple authentication types. These authentication types include HTTP Basic, HTTP Digest, X509, RememberMe, and FormLogin. In our case, we are interested in the FormLogin type.
Once all listener factories are loaded, the code then loops over the firewall definitions in our app/config/security.yml file and calls SecurityExtension::createFirewall() method for each one found. In our app/config/config.yml file, we have three firewalls defined: main, dev, and login.
Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
  1. private function createFirewalls($config, ContainerBuilder $container)  
  2. {  
  3.     // ...  
  4.   
  5.     foreach ($firewalls as $name => $firewall) {  
  6.         list($matcher$listeners$exceptionListener) = $this->createFirewall($container$name$firewall$authenticationProviders$providerIds$factories);  
  7.   
  8.         $contextId = 'security.firewall.map.context.'.$name;  
  9.         $context = $container->setDefinition($contextIdnew DefinitionDecorator('security.firewall.context'));  
  10.         $context  
  11.             ->replaceArgument(0, $listeners)  
  12.             ->replaceArgument(1, $exceptionListener)  
  13.         ;  
  14.         $map[$contextId] = $matcher;  
  15.     }  
  16.   
  17.     // ...  
  18. }  
Subsequently, the SecurityExtension::createFirewall() method initiates authentication listeners for each particular firewall by calling the SecurityExtension::createAuthenticationListeners() method.
Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
  1. private function createFirewall(ContainerBuilder $container$id$firewall, &$authenticationProviders$providerIdsarray $factories)  
  2. {  
  3.     // ...  
  4.   
  5.     // Authentication listeners  
  6.     list($authListeners$defaultEntryPoint) = $this->createAuthenticationListeners($container$id$firewall$authenticationProviders$defaultProvider$factories);  
  7.   
  8.     // ...  
  9. }  
The SecurityExtension::createAuthenticationListeners() method identifies the correct factory classes for a firewall by simply looping over all factories loaded into memory previously in a specific order (see SecurityBundle::$listenerPositions property and getPosition() methods inside factory classes) and then matching the keys found under the firewall definition (in our case, we have pattern, form_login, logout, and anonymous keys under the main firewall) to a factory instance (see getKey() methods inside factory classes). At the end, there is only one factory class defined with the form_login key and it is located at Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php.
Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php
  1. private function createAuthenticationListeners($container$id$firewall, &$authenticationProviders$defaultProviderarray $factories)  
  2. {  
  3.     // ...  
  4.   
  5.     foreach ($this->listenerPositions as $position) {  
  6.         foreach ($factories[$positionas $factory) {  
  7.             $key = str_replace('-''_'$factory->getKey());  
  8.   
  9.             if (isset($firewall[$key])) {  
  10.                 $userProvider = isset($firewall[$key]['provider']) ? $this->getUserProviderId($firewall[$key]['provider']) : $defaultProvider;  
  11.   
  12.                 list($provider$listenerId$defaultEntryPoint) = $factory->create($container$id$firewall[$key], $userProvider$defaultEntryPoint);  
  13.   
  14.                 $listeners[] = new Reference($listenerId);  
  15.                 $authenticationProviders[] = $provider;  
  16.                 $hasListeners = true;  
  17.             }  
  18.         }  
  19.     }  
  20.   
  21.     // ...  
  22. }  
Note: SecurityExtension::createAuthenticationListeners() mehod also creates a listener for anonymous authentication. Code is excluded for the sake of brevity.
Once an appropriate factory class is identified, the FormLoginFactory::create() method is called. This registers a "security.authentication.listener.form" authentication listener that intercepts login attempts.
The FormLoginFactory::create() method also calls createAuthProvider() which is responsible for registering our custom user provider (fos_userbundle). This user provider class is used to verify login credentials against a database.
The FormLoginFactory class also declares security.authentication.listener.form as our listener id. (See FormLoginFactory::getListenerId())
Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml
  1. xml version="1.0" ?>  
  2.   
  3. <container xmlns="http://symfony.com/schema/dic/services"  
  4.     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
  5.     xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">  
  6.   
  7.     <parameters>  
  8.   
  9.         <parameter key="security.authentication.listener.form.class">Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListenerparameter>  
  10.   
  11.     parameters>  
  12.   
  13.     <services>  
  14.   
  15.         <service id="security.authentication.listener.form"  
  16.                  class="%security.authentication.listener.form.class%"  
  17.                  parent="security.authentication.listener.abstract"  
  18.                  abstract="true">  
  19.         service>  
  20.   
  21.     services>  
  22. container>  
As you can see in the security_listeners.xml, our listener class is Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener. Things get easier after this point. Any call to our UsernamePasswordFormAuthenticationListener::handle() method triggers attemptAuthentication() method. This method is where the interaction between FOSUserBundle and SecurityBundle begins.
First, the authenticate() method is called on our authentication provider, which then makes a call to our user provider. This is the UserManager class included in the FOSUserBundle package.
Symfony/Component/Security/Http/Firewall/UsernamePasswordFormAuthenticationListener.php
  1. protected function attemptAuthentication(Request $request)  
  2. {  
  3.     // ...  
  4.   
  5.     return $this->authenticationManager->authenticate(new UsernamePasswordToken($username$password$this->providerKey));  
  6. }  
Then, based on return value of the authenticate() call, onSuccess() or onFailure() method is called. If user login is a success, the AbstractAuthenticationListener::onSuccess() method dispatches an event identfied by the SecurityEvents::INTERACTIVE_LOGIN constant.
Symfony/Component/Security/Http/Firewall/AbstractAuthenticationListener.php
  1. private function onSuccess(GetResponseEvent $event, Request $request, TokenInterface $token)  
  2. {  
  3.     // ...  
  4.   
  5.     if (null !== $this->dispatcher) {  
  6.         $loginEvent = new InteractiveLoginEvent($request$token);  
  7.         $this->dispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $loginEvent);  
  8.     }  
  9.   
  10.     // ...  
  11. }  
SecurityEvents::INTERACTIVE_LOGIN constant has a value of security.interactive_login so we can easily locate where the login listener registration happens:
vendor/bundles/FOS/UserBundle/Resources/config/security.xml
  1. xml version="1.0" encoding="UTF-8"?>  
  2.   
  3. <container xmlns="http://symfony.com/schema/dic/services"  
  4.     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
  5.     xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">  
  6.   
  7.     <parameters>  
  8.         <parameter key="fos_user.encoder_factory.class">FOS\UserBundle\Security\Encoder\EncoderFactoryparameter>  
  9.         <parameter key="fos_user.security.interactive_login_listener.class">FOS\UserBundle\Security\InteractiveLoginListenerparameter>  
  10.     parameters>  
  11.   
  12.     <services>  
  13.         <service id="fos_user.encoder_factory" class="%fos_user.encoder_factory.class%" public="false">  
  14.             <argument>%security.encoder.digest.class%argument>  
  15.             <argument>%fos_user.encoder.encode_as_base64%argument>  
  16.             <argument>%fos_user.encoder.iterations%argument>  
  17.             <argument type="service" id="fos_user.encoder_factory.parent" />  
  18.         service>  
  19.   
  20.         <service id="fos_user.security.interactive_login_listener" class="%fos_user.security.interactive_login_listener.class%">  
  21.             <argument type="service" id="fos_user.user_manager" />  
  22.             <tag name="kernel.event_listener" event="security.interactive_login" method="onSecurityInteractiveLogin" />  
  23.         service>  
  24.     services>  
  25.   
  26. container>  
And here is the custom listener code attached to the security.interactive_login event. It is purpose is to update the last login date for our users after a successful login.
FOS/UserBundle/Security/InteractiveLoginListener.php
  1. namespace FOS\UserBundle\Security;  
  2.   
  3. use FOS\UserBundle\Model\UserManagerInterface;  
  4. use FOS\UserBundle\Model\UserInterface;  
  5. use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;  
  6. use DateTime;  
  7.   
  8. class InteractiveLoginListener  
  9. {  
  10.     protected $userManager;  
  11.   
  12.     public function __construct(UserManagerInterface $userManager)  
  13.     {  
  14.         $this->userManager = $userManager;  
  15.     }  
  16.   
  17.     public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)  
  18.     {  
  19.         $user = $event->getAuthenticationToken()->getUser();  
  20.   
  21.         if ($user instanceof UserInterface) {  
  22.             $user->setLastLogin(new DateTime());  
  23.             $this->userManager->updateUser($user);  
  24.         }  
  25.     }  
  26. }  
In my next post, I will illustrate how we can extend this behavior to implement additional functionality after a successful login.


More than 3 requests, I'll translate this to Chinese.
超过3个请求,我就会把这篇文章翻译成中文。

No comments: