Google Authenticator in Symfony

If you want to add two-factor (2fa) authentication with Google Authenticator 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.


This is the follow up to previous post about two-factor authentication in Symfony2. As promised I also want to show you how to integrate Google Authenticator into your project. If you haven’t read my first post, I’d suggest doing it now, because it explains the principle more in detail. The following example code is widely identical to SonataUserBundle‘s integration.

To get started, you’ll have to install the Sonata Google Authenticator package. If you’re using composer (I guess so), you can simply execute:

php composer.phar require sonata-project/google-authenticator dev-master

Your User entity will need a new attribute to store the secret for Google Authenticator. I’ll name it googleAuthenticatorCode in this example. Also add getters and setters for it.

/**
 * @var string $googleAuthenticatorCode Stores the secret code
 * @ORM\Column(type="string", length=16, nullable=true)
 */
private $googleAuthenticatorCode = null;

Once again you have to create a Helper class, which will handle interaction with the Google Authenticator service:

namespace Acme\UserBundle\Security\TwoFactor\Google;

use Google\Authenticator\GoogleAuthenticator as BaseGoogleAuthenticator;
use Acme\UserBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

class Helper
{
    /**
     * @var string $server
     */
    protected $server;

    /**
     * @var \Google\Authenticator\GoogleAuthenticator $authenticator
     */
    protected $authenticator;

    /**
     * Construct the helper service for Google Authenticator
     * @param string $server
     * @param \Google\Authenticator\GoogleAuthenticator $authenticator
     */
    public function __construct($server, BaseGoogleAuthenticator $authenticator)
    {
        $this->server = $server;
        $this->authenticator = $authenticator;
    }

    /**
     * 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 $this->authenticator->checkCode($user->getGoogleAuthenticatorCode(), $code);
    }

    /**
     * Generate the URL of a QR code, which can be scanned by Google Authenticator app
     * @param \Acme\UserBundle\Entity\User $user
     * @return string
     */
    public function getUrl(User $user)
    {
        return $this->authenticator->getUrl($user->getUsername(), $this->server, $user->getGoogleAuthenticatorCode());
    }

    /**
     * Generate a new secret for Google Authenticator
     * @return string
     */
    public function generateSecret()
    {
        return $this->authenticator->generateSecret();
    }

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

}

The InteractiveLoginListener will listen for authentication events. If the user has Google Authenticator enabled, it defines an attribute in the session to flag the user with “authentication not completed”.

namespace Acme\UserBundle\Security\TwoFactor\Google;

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\Google\Helper $helper
     */
    private $helper;
    
    /**
     * @param \Acme\UserBundle\Security\TwoFactor\Google\Helper $helper
     */
    public function __construct(Helper $helper)
    {
        $this->helper = $helper;
    }
    
    /**
     * Listen for successful login events
     * @param \Symfony\Component\Security\Http\Event\InteractiveLoginEvent $event
     * @return
     */
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        if (!$event->getAuthenticationToken() instanceof UsernamePasswordToken)
        {
            return;
        }
        
        //Check if user can do two-factor authentication
        $ip = $event->getRequest()->getClientIp();
        $token = $event->getAuthenticationToken();
        $user = $token->getUser();
        if (!$user instanceof User)
        {
            return;
        }
        if (!$user->getGoogleAuthenticatorCode())
        {
            return;
        }
        
        //Set flag in the session
        $event->getRequest()->getSession()->set($this->helper->getSessionKey($token), null);
    }
    
}

The RequestListener will listen for any kind of requests. As long as the user session is flagged with “authentication not complete” it will allways show the authentication code dialog. If the request contains a auth code, it checks against the Google Authentication helper class.

namespace Acme\UserBundle\Security\TwoFactor\Google;

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

class RequestListener
{
    
    /**
     * @var \Acme\UserBundle\Security\TwoFactor\Google\Helper $helper
     */
    protected $helper;
    
    /**
     * @var \Symfony\Component\Security\Core\SecurityContextInterface $securityContext
     */
    protected $securityContext;
    
    /**
     * @var \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface $templating
     */
    protected $templating;
    
    /**
     * @param \Acme\UserBundle\Security\TwoFactor\Google\Helper    $helper
     * @param \Symfony\Component\Security\Core\SecurityContextInterface  $securityContext
     * @param \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface $templating
     */
    public function __construct(Helper $helper, SecurityContextInterface $securityContext, EngineInterface $templating)
    {
        $this->helper = $helper;
        $this->securityContext = $securityContext;
        $this->templating = $templating;
    }
    
    /**
     * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
     * @return
     */
    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:google.html.twig');
        $event->setResponse($response);
    }
    
}

The TWIG template is the same as before:

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

{{ flashMessage }}

{% endfor %}

Cancel

Now finally register all the services:



    
        Google\Authenticator\GoogleAuthenticator
        Acme\UserBundle\Security\TwoFactor\Google\Helper
        Acme\UserBundle\Security\TwoFactor\Google\InteractiveLoginListener
        Acme\UserBundle\Security\TwoFactor\Google\RequestListener
    
    
        
        
            My Server
            
        
        
            
            
        
        
            
            
            
            
        
    

If you need to generate a secret for Google Authenticator, use the generateSecret() method of the Helper service and store it in the User entity. Then you can fetch the URL of a QR code by calling the getUrl method. If you show it to the user, they can easily add the account to the Google Authenticator app by scanning it from screen.

The My Server in the service configuration is just a label, which will be used in the Google Authenticator app. The account will show up as “username@My Server”.

4 thoughts on “Google Authenticator in Symfony

  • February 17, 2014 at 18:41
    Permalink

    Hi Christian. Your code has been very useful for me. Thank you very much.
    I have a problem with RequestListener->onCoreRequest method.

    After the user has submitted login login he is redirected to verification code form even if he load a totally public url. Then he is in ambious state. He is not logged (that is right) but he even can access public urls. He can not access any page until he finishs completely the login. I think It would be more friendly if onCoreRequest method only redirect to verification code form if the current page being request required use to be logged.

    What do you think?
    How can I implement that behaivor? any idea?

    Thanks.

    Reply
  • February 17, 2014 at 19:46
    Permalink

    I have added this lines to

    		$publicRoutes = array(
    			"public_route_1",
    			"public_route_2",
    			"public_route_3",
    		);
    		if (in_array($event->getRequest()->get('_route'), $publicRoutes)) {
    			return;
    		}		
            
            //Force authentication code dialog
            $response = $this->templating->renderResponse('AcmeUserBundle:TwoFactor:google.html.twig');
            $event->setResponse($response);	
    			return;
    		}
    

    It is only a hot fix. The public route list would be obtained from a service, ACL, …

    Reply
  • February 17, 2014 at 20:31
    Permalink

    I would recommend to block the whole page, because after logging in users are already fully authenticated in Symfony’s security layer. The second step is accually not necessary to become authenticated. It is just an artifical barrier, that we’re building on top of the Symfony security layer.

    That means, if you allow those users access to public pages, they will view those pages as that user, even if they haven’t finished the second step. So if you’re using user data on public pages in any kind of way (e.g. logging, display the authenticated user name), you shouldn’t do this.

    I my projects I’ve forced users to do the second step in order to get access to the page. But I offered a way to cancel the login process. This can be done by adding a link to your logout URL to the authentication form.

    Cancel

    When the logout URL is being called, the user entity will be removed from the session, they will get an unauthenticated session and therefore they will be able to access the page again. This will make sure that only users that completed the whole login process can view the page.

    Reply
  • Pingback: Two-Factor Authentication in Symfony2 | SchebBlog

Leave a Reply to Jose Celano Cancel 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.