Two-Factor Authentication (2fa) in Symfony

If you want to add two-factor (2fa) authentication to your project, please use the scheb/2fa bundle for Symfony.
The approach in this blog post is no longer valid and potentially harmful to your application’s security. So don’t do it. Use the bundle instead.


For a project of mine I wanted to have some extra security because it contains critical features, only authorized people should have access to in any case. So I did some research if it’s possible to implement two-factor authentication in Symfony2. Sadly I didn’t find any good how-tos about that topic. Then I’ve found out that SonataUserBundle has Google Authenticator as an optional feature, so I did some reverse enginering to figure out how they did it.

This is how you implement two-factor authentication into Symfony2’s security layer. The following example will send out a random code to the user’s email address. I will do another post for Google Authenticator soon.

The basic principle is quite simple:

  • Do authentication with any kind of authentication provider (e.g. default username/password authentication).
  • Listen for the security.interactive_login event and check if user has two-factor authentication enabled. If that’s the case, add a new attribute to the session, which flags the user with “authentication not completed”.
  • Listen for requests (kernel.request event) and check if the attribute is set in the session. As long as the authentication is not complete, display a page with a form and ask for the authentication code. The content originally requested will not be shown.
  • Check if the request contains an authentication code. If the code is correct, set the attribute in the session to “authentication complete”. Now the user is fully authenticated and can access the secured area.

So let’s go! I’m assuming you’re using a Doctrine entity for the user object. First, we have to create two additional attributes. Also add the getter and setter methods. $twoFactorAuthentication will activate two-factor authentication on a user, $twoFactorCode will be used to store the authentication code.

/**
 * @var boolean $twoFactorAuthentication Enabled yes/no
 * @ORM\Column(type="boolean")
 */
private $twoFactorAuthentication = false;

/**
 * @var integer $twoFactorCode Current authentication code
 * @ORM\Column(type="integer", nullable=true)
 */
private $twoFactorCode;

Then create a helper class, which will generate the authentication code, send the email and take care of the flag in the session.

namespace Acme\UserBundle\Security\TwoFactor\Email;

use Acme\UserBundle\Entity\User;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class Helper
{
    /**
     * @var \Doctrine\ORM\EntityManager $em
     */
    private $em;

    /**
     * @var object $mailer
     */
    private $mailer;

    /**
     * Construct the helper service for mail authenticator
     * @param \Doctrine\ORM\EntityManager $em
     * @param object $mailer
     */
    public function __construct(EntityManager $em, $mailer)
    {
        $this->em = $em;
        $this->mailer = $mailer;
    }

    /**
     * Generate a new authentication code an send it to the user
     * @param \Acme\UserBundle\Entity\User $user
     */
    public function generateAndSend(User $user)
    {
        $code = mt_rand(1000, 9999);
        $user->setTwoFactorCode($code);
        $this->em->persist($user);
        $this->em->flush();
        $this->sendCode($user);
    }

    /**
     * Send email with code to user
     * @param \Acme\UserBundle\Entity\User $user
     */
    private function sendCode(User $user)
    {
        $message = new \Swift_Message();
        $message
            ->setTo($user->getEmail())
            ->setSubject("Acme Authentication Code")
            ->setFrom("security@acme.com")
            ->setBody($user->getTwoFactorCode())
        ;
        $this->mailer->send($message);
    }

    /**
     * Validates the code, which was entered by the user
     * @param \Acme\UserBundle\Entity\User $user
     * @param $code
     * @return bool
     */
    public function checkCode(User $user, $code)
    {
        return $user->twoFactorCode() == $code;
    }

    /**
     * Generates the attribute key for the session
     * @param \Symfony\Component\Security\Core\Authentication\Token\TokenInterface $token
     * @return string
     */
    public function getSessionKey(TokenInterface $token)
    {
        return sprintf('two_factor_%s_%s', $token->getProviderKey(), $token->getUsername());
    }
}

Now we need to create two listeners. This one will listen for successful authentication. It checks if the user supports two-factor authentication. If that’s the case the session attribute will be added and the authentication code will be sent to the user’s email address.

namespace Acme\UserBundle\Security\TwoFactor\Email;

use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Acme\UserBundle\Entity\User;

class InteractiveLoginListener
{
    /**
     * @var \Acme\UserBundle\Security\TwoFactor\Email\Helper $helper
     */
    private $helper;

    /**
     * Construct a listener, which is handling successful authentication
     * @param \Acme\UserBundle\Security\TwoFactor\Email\Helper $helper
     */
    public function __construct(Helper $helper)
    {
        $this->helper = $helper;
    }

    /**
     * Listen for successful login events
     * @param \Symfony\Component\Security\Http\Event\InteractiveLoginEvent $event
     */
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        if (!$event->getAuthenticationToken() instanceof UsernamePasswordToken)
        {
            return;
        }

        //Check if user can do two-factor authentication
        $token = $event->getAuthenticationToken();
        $user = $token->getUser();
        if (!$user instanceof User)
        {
            return;
        }
        if (!$user->getTwoFactorAuthentication())
        {
            return;
        }

        //Set flag in the session
        $event->getRequest()->getSession()->set($this->helper->getSessionKey($token), null);

        //Generate and send a new security code
        $this->helper->generateAndSend($user);
    }

}

The next class will listen for any kind of requests. It checks if the user is still flagged with “authentication not complete”, then a form is displayed. When the request contains an authenticaton code, it is validated. If it’s wrong, a flash message is set and the form is displayed again. If it’s corrent the flag in the session is updated and the user will be forwarded to the dashboard.

namespace Acme\UserBundle\Security\TwoFactor\Email;

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router;

class RequestListener
{
    /**
     * @var \Acme\UserBundle\Security\TwoFactor\Email\Helper $helper
     */
    protected $helper;

    /**
     * @var \Symfony\Component\Security\Core\SecurityContextInterface  $securityContext
     */
    protected $securityContext;

    /**
     * @var \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface $templating
     */
    protected $templating;

    /**
     * @var \Symfony\Bundle\FrameworkBundle\Routing\Router $router
     */
    protected $router;

    /**
     * Construct the listener
     * @param \Acme\UserBundle\Security\TwoFactor\Email\Helper $helper
     * @param \Symfony\Component\Security\Core\SecurityContextInterface  $securityContext
     * @param \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface $templating
     * @param \Symfony\Bundle\FrameworkBundle\Routing\Router $router
     */
    public function __construct(Helper $helper, SecurityContextInterface $securityContext, EngineInterface $templating, Router $router)
    {
        $this->helper = $helper;
        $this->securityContext = $securityContext;
        $this->templating = $templating;
        $this->router = $router;
    }

    /**
     * Listen for request events
     * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
     */
    public function onCoreRequest(GetResponseEvent $event)
    {
        $token = $this->securityContext->getToken();
        if (!$token)
        {
            return;
        }
        if (!$token instanceof UsernamePasswordToken)
        {
            return;
        }

        $key = $this->helper->getSessionKey($this->securityContext->getToken());
        $request = $event->getRequest();
        $session = $event->getRequest()->getSession();
        $user = $this->securityContext->getToken()->getUser();

        //Check if user has to do two-factor authentication
        if (!$session->has($key))
        {
            return;
        }
        if ($session->get($key) === true)
        {
            return;
        }

        if ($request->getMethod() == 'POST')
        {
            //Check the authentication code
            if ($this->helper->checkCode($user, $request->get('_auth_code')) == true)
            {
                //Flag authentication complete
                $session->set($key, true);

                //Redirect to user's dashboard
                $redirect = new RedirectResponse($this->router->generate("user_dashboard"));
                $event->setResponse($redirect);
                return;
            }
            else
            {
                $session->getFlashBag()->set("error", "The verification code is not valid.");
            }
        }

        //Force authentication code dialog
        $response = $this->templating->renderResponse('AcmeUserBundle:TwoFactor:email.html.twig');
        $event->setResponse($response);
    }
}

This is the twig template used to ask for the authentication code:

{% for flashMessage in app.session.flashbag.get(“error”) %}

{{ flashMessage }}

{% endfor %}

Cancel


The logout link is there so users that can’t complete the second step have a way out. Otherwise they will be forever stuck with the authentication form (or at least until the session expires).

Now register everything as a service in the bundle’s services.xml (I prefer creating a security.xml for security-related services).



    
        Acme\UserBundle\Security\TwoFactor\Email\Helper
        Acme\UserBundle\Security\TwoFactor\Email\InteractiveLoginListener
        Acme\UserBundle\Security\TwoFactor\Email\RequestListener
    
    
        
            
            
        
        
            
            
        
        
            
            
            
            
            
        
    

And that’s it. Now every user with two-factor authentication enabled will get the authentication code dialog after a successful login. The secured area will only become accessible when the correct authentication code is entered.

Another post covering the integration of Google Authenticator will follow soon.
Update: Here it is.

If you have any alternative concepts how to integrate two-factor authentication or you know some improvements for my implementation, please let me know.

13 thoughts on “Two-Factor Authentication (2fa) in Symfony

  • Pingback: Google Authenticator in Symfony2 | SchebBlog

  • December 24, 2013 at 00:18
    Permalink

    This works great. I’ve been struggling to understand how to correctly use helpers and listeners. Reading your code line-by-line has shown me how to correctly implement and pass parameters as required.

    Now onto your next blog of the Google Two Factor Authentication.

    Reply
  • January 20, 2015 at 22:47
    Permalink

    Hello. You have mistakes in your code.
    1. You must use Symfony\Bundle\FrameworkBundle\Routing\Router in RequestListener
    2. You must give one more argument in services.xml for acme_user.twofactor.email.request_listener such as ‘@router’. Because in code you use $this->router
    3. You don`t have close form tag in template

    Reply
  • February 19, 2015 at 12:14
    Permalink

    Thanks for the post, really helpful. One thing though: Your second factor form isn’t protected against CSRF attacks. You should inject the form factory into the request listener an use the form builder to create and verify forms.

    Reply
  • July 6, 2015 at 10:01
    Permalink

    If you want to use css/javascripts in confirmation code form you need to modify RequestListener/onCoreRequest method.

    if (substr($request->attributes->get('_route'),0,9) == '_assetic_')
    {
    return;
    }

    Also, if you want profiler to work:

    if( in_array($request->attributes->get('_route'), array('_wdt','_profiler','_profiler_search_bar','_profiler_router')))
    {
    return;
    }

    Reply
  • Pingback: Install a sms two factor authentication in Symfony2 | Theodo, développement agile symfony

  • October 21, 2016 at 18:28
    Permalink

    Hi Christian,

    Thank you for your tutorial, which has been really helpful!
    I understand that when the user is logged, we redirect him to another form where he will enter the authentication code. I was wondering if there was an easy way to show him the second form in the same page, using Ajax for ex ?

    Reply
  • November 19, 2018 at 02:57
    Permalink

    Thanks a lot for the tutorial. Really useful!

    Reply
  • September 29, 2020 at 21:21
    Permalink

    Hi, thank you for this how-to, really nice! But i can’t get it to work. The service.xml seems incomplete, am i correct or am i missing something?

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.