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:
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.
Pingback: Google Authenticator in Symfony2 | SchebBlog
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.
Glad I could help. Perhaps you’re interested in my new bundle providing out-of-the-box two-factor authentication.
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
Thanks for pointing that out. Fixed the mistakes.
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.
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;
}
You could also add a regex to the
exclude_pattern
configuration option.Pingback: Install a sms two factor authentication in Symfony2 | Theodo, développement agile symfony
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 ?
Thanks a lot for the tutorial. Really useful!
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?
Thsi blog post is 7 years old, things have changed. There’s a Symfony bundle now that does all of this stuff out of the box. Please have a look at https://github.com/scheb/2fa