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:
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”.
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.
I have added this lines to
It is only a hot fix. The public route list would be obtained from a service, ACL, …
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.
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.
Pingback: Two-Factor Authentication in Symfony2 | SchebBlog