Menu

Bob van de Vijver

Daily business

Symfony 2: Extending the Security Component

Welcome to this article about extending the Symfony 2 Security component. When you've reached this page, you've probably tried a lot already to make your custom Authentication Provider work, or to activate the “remember me” functionality. I will not say that the documentation of the Symfony framework is bad per se, but the Security Component is not very well documented (and there are no examples available) on the Symfony website. Luckily, most of the documentation can be found in the code itself, and in this article, I will share my findings with you. This article will discuss everything I've encountered while implementing my own form-based authentication system. The code used in this Article is written for Symfony 2.3 (and already tested in 2.4), but it should also work with later versions.

To start: I like the flexibility of the Security Component and I do think it is well written code, with a good predefined code base. However, as the Symfony team can not do everything on its own, you might want to extend it with extra authentication methods. To explain this, I will use one of my own projects (the Kick-In website) where I need to combine several login methods and connect them to one Person object. The Person objects are originated from a synchronization between the University database and the website’s database, where multiple login methods can be added to every person. These can be the University Radius login, Google authentication and temporarily passwords. In this article I will specifically discuss the Radius method, and how I've coupled it into the Symfony Security component. I will conclude with how you can implement the "remember me" functionality, which is actually quite simple if you take the right steps from the beginning. Please note: as I've struggled to come this far, it might be that there are some mistakes or pieces that could be done better, but it works as required. If you find errors, or if you have any suggestions, please leave a comment!

This figure illustrates the process Symfony handles when an authentication request occurs.

To illustrate the structure Symfony uses when an authentication request is handled, I've created the figure to the right to show the simplified steps. In this process a lot can be customized:

  • User Provider
  • Token
  • Authentication Listener and Provider
  • Connected to each other by a Factory
  • And more...

I will guide you into a working security layer for your Symfony2 application, by passing over one subject at a time. If you have any questions or suggestions, please let me know!

The User Provider

The first component that you will probably need to extend is the User Provider, and luckily enough, this is quite well covered in this Symfony cookbook. Therefore, I will only discuss a few key components.

User Class

To create a User Provider, first you will need to create a User class, which will need to implement the UserInterface. My own User class is the SecurityLogin class, which looks something like this:

<?php

class SecurityLogin implements AdvancedUserInterface
{
  /**
   * @ORM\ManyToOne(targetEntity="\Idb\PersonBundle\Entity\Person", inversedBy="logins")
   * @ORM\JoinColumn(name="person_id", referencedColumnName="id")
   * @Assert\NotNull();
   */
  protected $person;
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */

  private $id;
  /**
   * @ORM\Column(name="type", type="string", length=10)
   * @Assert\Choice(callback = "getSecurityTypes")
   */
  private $type;

  /**
   * @ORM\Column(name="username", type="string", length=255, nullable=false)
   */
  private $username;

  /**
   * @ORM\Column(name="password", type="string", length=255, nullable=true)
   */
  private $password;

  /**
   * @ORM\Column(name="salt", type="string", length=255, nullable=true)
   */
  private $salt;

  /**
   * @ORM\Column(name="lastUsed", type="datetime", nullable = true)
   */
  private $lastUsed = NULL;

  /**
   * @ORM\Column(name="validUntil", type="datetime", nullable = true)
   */
  private $validUntil = NULL;

  /**
   * @ORM\Column(name="valid", type="boolean", nullable = false)
   */
  private $valid = true;

  public function __construct($username = NULL, $password = NULL)
  {
    $this->username = $username;
    $this->password = $password;
    if ($password != NULL) {
      $this->createSalt();
    }
  }

  public function __sleep()
  {
    return array('id', 'username', 'password', 'salt');
  }

  public function checkCredentials($password, $type)
  {
    switch ($type) {
      case 'utwente':
        return false;
        break;
      case 'grace':
        return ($this->password == $this->getHashedPassword($password)) ? true : false;
        break;
    }

    return false;
  }

  private function getHashedPassword($password)
  {
    // Do something to hash the password
  }

  public function createSalt()
  { // Do something to create a random salt
  }

  public function getRoles()
  { // Find the roles associated to the Person
  }

  public function isCredentialsNonExpired()
  {
    if ($this->getValidUntil() != NULL && $this->getValidUntil() < new \DateTime()) {
      return false;
    }

    return true;
  } 

  // Getters and setters
} 

This is all quite standard, but please note that I do not have any implementation for the Radius password in my SecurityLogin class, as this will be handled by the Radius server itself and it will not be cached locally. Also, note the type option, where I differentiate between the login types.

The User Provider

Now that we have our custom User class, a custom User Provider can be created. This part is also quite well covered by the Symfony Cookbook, including the service definition. My User Provider looks like this:

class IdbUserProvider implements UserProviderInterface
{
  /* @var $em EntityManager */
  private $em;

  public function __construct(EntityManager $em)
  {
    $this->em = $em;
  }

  /**
   * Find the correct user object
   * @param string $username
   * @param string $type
   * @return UserInterface
   * @throws \Symfony\Component\Security\Core\Exception\UsernameNotFoundException
   */
  public function loadUserByUsername($username, $type = null)
    {
      if(!in_array($type, SecurityLogin::getSecurityTypes()) && $type != null){
        throw new UsernameNotFoundException(sprintf("Type %s not supported", $type));
      }

      $repository = $this->em->getRepository('IdbSecurityBundle:SecurityLogin');
      $userData = $repository->findByUsername($username);

      if(count($userData) >= 1){
        if($type == null){
          // Find the most recent used SecurityLogin
          $datetime = $userData[0]->getLastUsed();
          $return = $userData[0];
          foreach($userData as $user){
            if($user->getLastUsed() > $datetime){
              $datetime = $user->getLastUsed();
              $return = $user;
            }
          }

          return $return;
        }

        foreach($userData as $user){
          if($user->getType() == $type && $user->isCredentialsNonExpired()){
            return $user;
          }
        }
      }

      throw new UsernameNotFoundException(sprintf('Username "%s" does not exist.', $username));
    }

    public function refreshUser(UserInterface $user, $type = null)
    {
        if (!$user instanceof SecurityLogin) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        return $this->loadUserByUsername($user->getUsername(), $type);
    }

    public function supportsClass($class)
    {
        return $class === 'Idb\SecurityBundle\Entity\SecurityLogin';
    }
}

It can be seen that this User Provider strongly depends on the type of the SecurityLogin object. To ensure compatibility with the rest of the Symfony Security component, the type argument is optional, but when a type is not provided, the last used SecurityLogin object is used. This is correct, as my own extended Authentication Provider uses the correct syntax and thus always receives the wanted object. When Symfony itself uses the method, the person is already authenticated and thus is the latest used SecurityLogin object the correct one. Finally, you will need to register the User Provider in the security.yml. We do not need to set the encoder option, as we use our own logic to check if the password is correct.

Token

About the token I can be very short: I've implemented an own Token, extending the AbstractToken for the basic features, but possibly the UsernamePasswordToken was the better choice. However, the reason to extend the basic Tokens was to adjust some of the basic methods: The getCredentials method firstly needs to access the User object to get the password, as my token itself does not contain it. Also, I wanted to automatically authenticate the Token when it has roles, and set a "last used" attribute (which I eventually do not use anymore). Essentially, this is a design choice which you should consider for yourself. A UsernamePasswordToken should be enough for all your purposes, but it might be easy to extend the standard while developing your security implementation, with the eye on future adjustments.

The Authentication listener

The Authentication Listener is the class which tries to handle the request and attempts to authenticate the credentials given by creating a unauthenticated token. This token is passed to the Authentication Provider, which actually checks the credentials against any source specified and then returns a authenticated token. This token must be stored in the Security Context to ensure successful authentication. The Authentication listener should extend the AbstractAuthenticationListener, as it defines some quite handy basics methods used internally by Symfony. In the code it can be viewed that this class is the preferred base class for all browser-/HTTP-based authentication request, so let's use it. First of all, we need to create a constructor for this extended class:

namespace Utwente\SecurityBundle\Security\Listener;

use Idb\SecurityBundle\Entity\SecurityLogin;
use Idb\ConfigBundle\Config\IdbConfig;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\RememberMe\TokenBasedRememberMeServices;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
use Utwente\SecurityBundle\Security\Token\UTwenteToken;

class UTwenteListener extends AbstractAuthenticationListener
{
  public function __construct(SecurityContextInterface $securityContext, AuthenticationManagerInterface $authenticationManager,
                              SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils,
                              $providerKey, AuthenticationSuccessHandlerInterface $successHandler,
                              AuthenticationFailureHandlerInterface $failureHandler, array $options = array(),
                              LoggerInterface $logger, UserProviderInterface $userProvider)
  {
    // Needed to successfully use parent methods
    parent::__construct($securityContext, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, $options, $logger);

    if($options["remember_me"]){
        // The 'main' option comes from the configured firewall
        $this->setRememberMeServices(new TokenBasedRememberMeServices(array($userProvider), 'main', $providerKey, array(), $logger));
    }
  }
}

Quite some parameters are given in the construct method, which are needed by the parent class, the AbstractAuthenticationListener. The UserProvider is needed, but only when you want to implement the "remember me" functionality. The service definitions will be discussed later together with the Factory class, as that combines the classes to a functional program. The only thing left to do for this class is implementing the attemptAuthentication method, the only abstract method in the AbstractAuthenticationListener class. This method extracts the login information from the request data, creates an Token with the credentials and passes it to the Authentication Provider to for authentication. The method must return a Token instance for correct functioning of the parent class or null when full authentication is not possible using this listener.

public function attemptAuthentication(Request $request)
{
    // Get the username and password from the request
    $username = trim($request->request->get('_username', NULL));
    $password = $request->request->get('_password', NULL);

     // If an empty password or username, return null and the specified error
    if ($username == NULL || $password == NULL) {
      $request->getSession()->set(SecurityContextInterface::AUTHENTICATION_ERROR,
        new AuthenticationException("Please provide a complete set of credentials");
      $request->getSession()->set(SecurityContextInterface::LAST_USERNAME, $username);

      return null;
    }

    // Probably credentials are correct, time to check!
    $this->logger->debug("UTwenteLogin: $username");

    // Create a UT token
    $token = new UTwenteToken();
    $token->setUser(new SecurityLogin($username, $password));

    // Try to authenticate by retrieving an authenticated token from the manager, catch any authenticationexception
    try {
      $authToken = $this->authenticationManager->authenticate($token);
      $this->logger->debug(sprintf("UT authentication succesfull. Token: %s", $authToken));

      // Set the default event_id in the session
      $request->getSession()->set('event_id', $this->idbConfig->getVal('default_event_id'));

      // Return the authenticated token
      return $authToken;

      // Something went wrong in the authentication process
    } catch (UTwenteAuthenticationException $failed) {
      $this->logger->debug("UT Authentication failed.");

      // Set the error and last username in the session so the form can use it
      $request->getSession()->set(SecurityContextInterface::LAST_USERNAME, $username);
      $request->getSession()->set(SecurityContext::AUTHENTICATION_ERROR, $failed);

      return null;
    }
}

It can be clearly seen that this class only handles the incoming request and creates a token with only the credentials in it, which it passes further to the Authentication Provider for the real authentication process. After that it returns the validated token, or null in case of failure.

The Authentication Provider

The Authentication Provider can receive an unauthenticated token from many sources. Mainly this will be the listener we've created above, but also when the token is invalidated by any controller, it will be passed back to the Authentication Provider for re-validation (which is the key for reloading user-roles). The Authentication Provider I've created implements the AuthenticationProviderInterface, which on his turn implements the AuthenticationManagerInterface. These interfaces define two methods that need to be implemented: The authenticate and the supports methods. The supports method checks if the supplied token can be handled by the Authentication Provider, and the authenticate method obviously tries to authenticate the token. Therefore it needs access to the User Provider. To authenticate the Token a few steps are taken:

  • Check if the supplied username exists.
  • Check if the token has roles: if so it was previously authenticated and we only need to reload the roles and check if the login combination is still valid. We therefore need to create a new Token.
  • Check the username/password combination.
  • Create an authenticated Token
namespace Utwente\SecurityBundle\Security\Provider;

use Doctrine\ORM\EntityManager;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\HttpKernel\Log\LoggerInterface;
use Utwente\SecurityBundle\Security\UtRadius;
use Utwente\SecurityBundle\Security\Token\UTwenteToken;

class UTwenteProvider implements AuthenticationProviderInterface
{
    private $userProvider;
    private $em;
    private $logger;

    /**
     * Constructor
     * @param \Symfony\Component\Security\Core\User\UserProviderInterface $userProvider
     * @param \Doctrine\ORM\EntityManager $em
     * @param \Symfony\Component\HttpKernel\Log\LoggerInterface $logger
     */
    public function __construct(UserProviderInterface $userProvider, EntityManager $em, LoggerInterface $logger)
    {
        $this->userProvider = $userProvider;
        $this->em           = $em;
        $this->logger       = $logger;
    }

    /**
     * Tries to authenticate the given security token
     * @param \Symfony\Component\Security\Core\Authentication\Token\TokenInterface $token
     * @return \Utwente\SecurityBundle\Security\Token\UTwenteToken
     * @throws AuthenticationException On any exception
     */
    public function authenticate(TokenInterface $token)
    {
      $this->logger->debug("UTwente Login: UT user load started");

      // Check s or m number
      $username = strtolower($token->getUsername());

      if(!($this->startsWith($username, 's') || $this->startsWith($username, 'm'))){
        throw new AuthenticationException("No UT number!");
      }

      // Check is the user is in our db
      try {
        $loadedUser = $this->userProvider->loadUserByUsername($username, 'utwente');
      }catch (UsernameNotFoundException $e){
          throw new AuthenticationException('Your have no account in our database.');
      }

      // If the token already has roles, than it can be authenticated
      if(count($token->getRoles()) > 0){
        // A new token in required, as we cannot adjust it in the current token
        $authenticatedToken = new UTwenteToken($loadedUser->getRoles());
        $authenticatedToken->setUser($loadedUser);
        return $authenticatedToken;
      }

      // Check the given credentials
      if(UtRadius::checkPassword($username, $token->getCredentials())){
        $this->logger->debug("UTwente Login: UT account correct!");

        // Save the login time to the DB
        $loadedUser = $this->userProvider->refreshUser($loadedUser, 'utwente');
        $date = new \DateTime();
        $loadedUser->setLastUsed(clone $date);
        $this->em->persist($loadedUser);
        $this->em->flush();

        // Create an new token with the correct data
        $authenticatedToken = new UTwenteToken($loadedUser->getRoles());
        $authenticatedToken->setUser($loadedUser);
        return $authenticatedToken;

      }else{
        throw new AuthenticationException("Your credentials are not correct...");
      }
    }

    /**
     * Checks if token supported
     * @param \Symfony\Component\Security\Core\Authentication\Token\TokenInterface $token
     * @return bool
     */
    public function supports(TokenInterface $token)
    {
        return $token instanceof UTwenteToken;
    }

    /**
     * Check if haystack starts with the needle
     * @param string $haystack
     * @param string $needle
     * @return bool
     */
    function startsWith($haystack, $needle)
    {
      return !strncmp($haystack, $needle, strlen($needle));
    }
}

Now we have an Authentication Provider which returns an authenticated token whenever the login information is correct. The final thing that needs to be done is enabling the whole construction in the configuration!

The Factory

The Factory is the piece of code which tells Symfony that it can use the newly created Listener and Provider as a login method. Therefore, the Factory must at least extend the AbstractFactory for correct functioning. We need to create a few methods:

  • createAuthProvider: Creates the correct instance of the Authentication Provider
  • getListenerId: Return the listener service id
  • getPosition: Returns the injection point of the login procedure. For a form login, this should be 'form'.
  • getKey: Returns the unique identifier key of the login method. This key will later be used in the security.yml
  • The constuctor: We need to add the remember_me to the default options, otherwise it is not passed correctly to the listener
namespace Utwente\SecurityBundle\Security\Factory;

use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractFactory;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy;

class UTwenteFactory extends AbstractFactory{

  // Needed to add the option to the standard passed config
  public function __construct(){
    $this->options['remember_me'] = false;
  }

  public function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId){
    $providerId = $this->getProviderKey().'.'.$id;
    $container
        ->setDefinition($providerId, new DefinitionDecorator($this->getProviderKey()))
        ->replaceArgument(0, new Reference($userProviderId))
    ;

    return $providerId;
  }

  public function getListenerId(){
    return 'utwente.security.authentication.listener';
  }

  /**
   * Return the position of the authentication
   * @return string
   */
  public function getPosition()
  {
    return 'form';
  }

  /**
   * Return the key which needs to be registered in the security context
   * @return string
   */
  public function getKey()
  {
    return 'utwente';
  }

  public function getProviderKey(){
    return 'utwente.security.authentication.provider';
  }
}

Service definitions

The Factory uses some services, which obviously need to be defined in a services.yml. The first two definitions are the Authentication Listener and the Authentication Provider, the last two definitions are meant to overwrite the standard abstract definitions made by Symfony self. The options parameter of those are replaced by the Factory and can now just be '{}'. There are also some empty parameters defined in the first two parameters, which are also filled by the Factory.

# src/Utwente/SecurityBundle/Resources/config/services.yml
services:
  # This class handles the authentication 
  utwente.security.authentication.provider:
    class:  Utwente\SecurityBundle\Security\Provider\UTwenteProvider
    arguments: ["", "@doctrine.orm.entity_manager", "@logger"]

  # This class listens to the utwente security option
  utwente.security.authentication.listener:
    class:  Utwente\SecurityBundle\Security\Listener\UTwenteListener
    arguments: ["@security.context", "@security.authentication.manager", "@security.authentication.session_strategy", "@security.http_utils", "", "", "", {}, "@logger", "@idb.config", "@idb.security.user.provider"]

  # Create the standard handlers
  security.authentication.success_handler:
    class: Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler
    arguments: [ "@security.http_utils", {}]

  security.authentication.failure_handler:
    class: Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler
    arguments: [ "@http_kernel", "@security.http_utils", {}]

Enabling the listener

Finally, all is enabled in the Bundle class by adding the Factory to the kernel as a SecurityListenerFactory:

// src/Utwente/SecurityBundle/UtwenteSecurityBundle.php
namespace Utwente\SecurityBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Utwente\SecurityBundle\Security\Factory\UTwenteFactory;

class UtwenteSecurityBundle extends Bundle
{
  public function build(ContainerBuilder $container)
    {
        parent::build($container);

        // Add the 'utwente' option to the security context
        $container->getExtension('security')->addSecurityListenerFactory(new UTwenteFactory());
    }
}

Now we can use the utwente key in the security.yml!

The security.yml

Now we can adjust the security.yml file, so it uses the 'utwente' method. I've enabled the the "remember me" functionality by adding the corresponding rules, and added it to the 'utwente' login method by simply adding the 'remember_me: true' line.

# app/config/security.yml
security:
    providers:
      idb_provider:
        id: idb.security.user.provider

    firewalls:
      main:
        pattern: ^/
        provider: idb_provider
        utwente:
          remember_me: true
        logout:
          path:   logout
          target: frontend_home
        anonymous: true
        remember_me:
          key:      "%secret%"
          lifetime: 2678400 # 31 days in seconds
          path:     /
          domain:   %domain% # Defaults to the current domain from $_SERVER

      dev:
        pattern:  ^/(_(profiler|wdt)|css|images|js)/
        security: false

    access_control:
      - { path: ^/_wdt/.*, roles: IS_AUTHENTICATED_ANONYMOUSLY }  
      - { path: ^/_profiler/.*, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/login_check$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/(en|nl|en_US)$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/(en|nl|en_US)/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/(en|nl)/idb*, roles: IS_AUTHENTICATED_ANONYMOUSLY }
      - { path: ^/(en|nl)/*, roles: ROLE_USER }

Please note that this an example of the firewall configuration. You can notice that the profiler is freely accessible according to this configuration, however, I use an extra http_basic login for the development environments.

Conclusion

This article described how to extend the Symfony Security Component by adding an extra form login method, in this case coupled with a Radius-authentication. Also, I've simply added the "remember me" option by adding a few line of codes in different classes. Keep in mind the order that your request passes the Security Component:

  1. A request is made
  2. The Security Component checks if the request lies in one of the configured firewalls
  3. If so, the coupled Listeners are used (in this case, the 'utwente' Listener)
  4. Every Listener checks if the path is the 'check_login' path (standard "/login_check") and if action must be taken
  5. If so, the Listener extracts the credentials from the request and creates an unauthenticated Token. This is forwarded to the Authentication Provider
  6. The Authentication Provider tries to authenticate the Token by using the User Provider for a user search and after that it checks the password (if the user is found)
  7. The Authentication Provider returns an authenticated Token (or null on failure) to the Listener, which places the Token in the sessions security context.
  8. The request is successfully used to authenticate the credentials and thus the user is logged in!

Keep in mind that you might not need to extend the Security Component as much as I did: it might be possible do make your method work with less classes. Nevertheless, I hope that you found this article useful while struggling with this specific Symfony part. Again, if you have any comments on my solution, please leave them below!

Written by Bob van de Vijver on Wednesday January 1, 2014

« Laserwriter - Creating automatic changesets using only Doctrine »