How to implement Yubikey-based 2-Factor Authentication in Symfony

yubikey into usb port

I’ve been recently involved in a Symfony project where the login process had to support 2-factor authentication with Yubikeys for certain users of the application. This post describes the steps that I followed to implement this feature in Symfony.

Before diving into the details and code snippets, I’ll describe the two main requirements of the task:

  • Not every user in the system has a Yubikey, so 2-factor authentication (2FA) is not enforced sitewide.
  • The fact that a user has a Yubikey and is required to authenticate with it, is private information, which means a partial authentication has to be performed before asking the user to perform the Yubikey authentication.
  • Basic authentication is already implemented in the application against an LDAP instance, via the IMAG LdapBundle.

With that in mind, the approach I took is as follows:

  1. Create a controller to render the page where the user will be asked to pass 2-factor authentication.
  2. Declare a listener that implements the Symfony EventSubscriberInterface, to hook into the authentication process at the point where the user has passed the basic authentication.
  3. Within the listener, make the necessary checks to find out if the user has to pass 2-Factor authentication. If so, set a session variable to indicate it, and redirect the user to the controller created in step 1, where the flag will be removed if the user successfully authenticates with the yubikey.
  4. Create another listener to hook into every request to the system. This will check the value of the flag set in step 3 and make sure that partially-authenticated users who still need to go through 2FA, can only access the controller from step 1.

Let’s go through each step:

1. Yubikey validation controller and basic form

First, we declare our controller in the routing.yml file:

And create the controller for that path:

As it can be seen, we rely on the form to perform the Yubikey validation. If the form is valid, the controller unsets the ‘validate_yubikey‘ flag from the user session (setting it to null), and redirects him to other page (homepage, dashboard, etc…). Here is the twig template I used for the validation page (simply renders some instructions and the form):

Finally, create the form to ask the user to enter his OTP (One-Time Password) using his Yubikey. When the user submits the form, we’ll use a library to send an API request with that OTP to a Yubikey validation server, which will let us know if the OTP is valid. The PHP library we used is the Enygma Yubikey, written by Chris Cornutt.

Please, note the following line (line 28 from the snippet above):

That’s not a good practice because it makes the code dependent on that particular library, and, should you want to write tests for your form, it’ll make it more difficult to test. I’ve kept it short for simplicity, but normally you’d create a “YubikeyValidatorFactory” class and declare it as a service in your bundle. Within that factory class, you would instantiate the “YubikeyValidator” object from the yubikey library, passing the required parameters from a config file.

That’s everything for the ControllerForm, and Yubikey validation. Let’s move onto the last two steps.

2. Authentication listener

The authentication listener is pretty easy. Start by declaring it in the services.yml file of your bundle:

In this case, as mentioned at the beginning of the post, we’re authenticating users against an LDAP instance, and that’s why the listener of the example is called LdapAuthenticationListener, and the event we’re subscribing to is the LdapEvents::PRE_BIND one. Let’s look at it:

Note the AcmeBundleAppController bit. We use a custom class to keep the Dependency Injection Container handy in some places for this project, although the right thing to do would be to simply insert the request service in our custom listener.

This code will most likely change in your case, if you’re authenticating users against a local database, for example. The principle is the same, though: in your listener class, implement the EventSubscriberInterface, to hook into the authentication process. Then, check the user data to find out if yubikey authentication has to be performed, and set the ‘validate_yubikey‘ flag if appropriate. You might find this other blog post useful in order to create your own authentication listener.

3. Request listener

We’re almost done. We Just need to make sure that users don’t try to trick the system by requesting a different url after they’ve been partially authenticated and redirected to the yubikey validation page. We can do that by creating a request listener. Again, in our services.yml file:

And the request listener:

Again, ignore the AcmeBundleAppController call. You should simply inject the route service as a parameter to the request listener.

Wrapping up

A simpler approach that allows you to do all of this easily and without most of the controller and listener files, would be to add an additional textfield to the default login form, and perform the whole validation in the LdapAuthenticationListener. My colleague @matason wrote the original version of this code with that approach, and it worked pretty well. If you plan to enforce yubikey validation for all your users, that simpler approach is what you’re after.

I hope this serves to give you a clear set of steps to implement 2-factor authentication in your symfony projects. Are you doing it already in any other way? It’d be great to hear about it in the comments =).

Leave a Reply

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