mbarbey/u2f-security-bundle

Symfony bundle allowing to use U2F security keys as two factor authentication

V1.0.1 2018-11-18 10:55 UTC

README

Work in progress. Bundle almost finished. Code is ready, now working on tests and readme.

U2F Security Bundle

This Symfony bundle aim to add a two factor security level to your Symfony project.

Build Status Scrutinizer Code Quality Code Coverage

Demo : https://github.com/mbarbey/u2f-security-bundle-demo

Overview

If you want to use U2F security keys as second level security, you have 3 options :

  1. You are a true warrior and make it all from scratch
  2. You use a third party library and create a wrapper around it
  3. You use this bundle :-)

This U2F Security Bundle is a wrapper around the samyoul/u2f-php-server library.

It move all the complexity to the wrapper and all you need is creating entities, making some calls from your controller, and displaying a beautiful form in a beautiful page. That all (for the basics).

Requirements

Before installing this bundle you need to have an already working login and secured area in the Symfony way (aka security bundle, firewall and user entity).

Important point, U2F keys only work on HTTPS requests. So you will need an SSL certificate, even for working on localhost. The good news is that you can use self-signed certificate without problem.

Installation and configuration

Globally, the installation process can be splitted into three parts :

  1. Composer installation and bundle configuration
  2. Creating entities and models
  3. Creating controller

Now let's start together !

First you need to install the bundle through composer !

composer require mbarbey/u2f-security-bundle

After installing the receipe, don't forget to edit the file config/packages/mbarbey_u2f_security.yaml to match your own routes.

  • The key authentication_route is required. It's the route where the users will be jailed until they successfully authenticate with their U2F security key. It must be the route where the U2F authentication will be performed.

  • The key whitelist_routes is an optional list on routes where the user can still visit after being logged and and without being authenticated with the two factor security. For example you can whitelist the login and logout routes. These given routes will be autmatically added to the following list of already whitelisted routes :

    • _wdt
    • _profiler_home
    • _profiler_search
    • _profiler_search_bar
    • _profiler_phpinfo
    • _profiler_search_results
    • _profiler_open_file
    • _profiler
    • _profiler_router
    • _profiler_exception
    • _profiler_exception_css

Great ! It was easy uh ? Now let's create some entities.

Entities and models

First, we will create a "key" entity which will store the data of the U2F keys. You have two options :

  1. Create a new entity and extends the class Mbarbey\U2fSecurityBundle\Model\Key\U2fKey.
  2. Create a new entity and implement the interface Mbarbey\U2fSecurityBundle\Model\Key\U2fKeyInterface. If you pick this choice, don't forget to set the variables $keyHandle, $publicKey, $certificate and $counter public. This is actually needed by the used library. You can look at the Mbarbey\U2fSecurityBundle\Model\Key\U2fKey class if you need some inspiration.

Well done, now let's work more with the entities.

You need to edit a little bit your exising user entity used by your firewall to be linked to the newly created entity for the U2F keys. Again, you have two options :

  1. Extends the class Mbarbey\U2fSecurityBundle\Model\User\U2fUser.
  2. Implement the interface Mbarbey\U2fSecurityBundle\Model\User\U2fUserInterface and add the missing functions (getU2fKeys, addU2fKey and removeU2fKey).

Excellent, you have created all the required entities. Now we will need two model which will store the forms data.

You will have to create an empty authentication model which will extends the Mbarbey\U2fSecurityBundle\Model\U2fAuthentication\U2fAuthentication class. If you need to, you can add additional data to this model which will store the challenge response generated by the U2F keys, on the authentication page dispayed juste after being logged in.

Next you will have to create an empty registration model which will extends the Mbarbey\U2fSecurityBundle\Model\U2fRegistration\U2fRegistration class. Again, if you need to, you can add additional data to this model which will store the identification of the U2F key which will be attached to a user. For example, you can give a name to the keys so the user can recognise them ;-)

Bravo, you have made 80% of the work. Now let's do some easier tasks.

Registration controller

We will first allow users to register security keys.

For this, you will need a controller (new or existing one) and a registration action which is only available after being logged in with a username and passowrd (classic).

In this action, you will need to do :

  1. Inject the service Mbarbey\U2fSecurityBundle\Service\U2fSecurity as argument of your action.
  2. Create a form for the registration and use the model you just created in the prevous part. For the form, the field response must be hidden.
  3. If your form is submitted and valid:
  4. You will need to create a new key with the entity you created too in the previous part and call the function validateRegistration from the service you just injected and pass the following arguments :
    • the current user (ex: $this->getUser())
    • the registration data filled by the form
    • the newly created key
  5. If there is an error, this function will throw an exception, so you will need a try catch to handle it and inform the user why his registration failed.
  6. If there is no error, you newly created key has been filled and is ready to be persisted.
  7. Else :
  8. You will need store the result of the function createRegistration and pass as argument your appId. The appId must always be the HTTP protocol and you domain name. For exemple : https://example.com. For a more dynamic system, you can use the function getSchemeAndHttpHost from your HTTP request. Here is an exemple of usage : $registrationData = $service->createRegistration($request->getSchemeAndHttpHost());. This data is the registration request which will be sent to the user. It contains two parts : the request and the signatures.
  9. Render a view with :
    • your form
    • the request part of your registration request (ex: $registrationData['request'])
    • the signatures par of your registration request (ex: $registrationData['signatures'])

Here is a full exemple for this controller action :

public function u2fRegistration(Request $request, U2fSecurity $service)
{
    $registration = new U2fRegistration();
    $form = $this->createForm(U2fRegistrationType::class, $registration);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        try {
            $key = new Key();
            $service->validateRegistration($this->getUser(), $registration, $key);

            $em = $this->getDoctrine()->getManager();
            $em->persist($key);
            $em->flush();

            return $this->redirectToRoute('user_keys_list');
        } catch (\Exception $e) {
            $this->addFlash('danger', $e->getMessage());
        }
    }

    $registrationData = $service->createRegistration($request->getSchemeAndHttpHost());

    return $this->render('user/key/register.html.twig', array(
        'jsRequest' => $registrationData['request'],
        'jsSignatures' => $registrationData['signatures'],
        'form' => $form->createView(),
    ));
}

For the front part, it's up to you. The two things you need are the form, and some JS. Here is the JS you must use. Feel free to edit it as you want.

<script src="{{ asset('bundles/mbarbeyu2fsecurity/u2f.js') }}"></script>
<script type="text/javascript">
    setTimeout(function() {
        // A magic JS function that talks to the USB device. This function will keep polling for the USB device until it finds one.
        u2f.register("{{ jsRequest.appId }}", [{version: "{{jsRequest.version}}", challenge: "{{ jsRequest.challenge }}"}], {{ jsSignatures|raw }}, function(data) {
            // Handle returning error data
            if(data.errorCode && data.errorCode != 0) {
                alert("registration failed with error: " + data.errorCode);
                // Or handle the error however you'd like. 
                return;
            }

            // On success process the data from USB device to send to the server
            var registration_response = data;

            // Get the form items so we can send data back to the server
            var form = document.getElementsByTagName('form')[0];
            var response = document.getElementById('{{ form.response.vars.id }}');

            // Fill and submit form.
            response.value = JSON.stringify(registration_response);
            form.submit();
        });
    }, 1000);
</script>

And tadaaaa ! You users can register their security keys and link it to their account !

But ! Registering keys is cool, but it will be better to be authenticated with it.

Authentication controller

Now let's to the same thing for the authentication. Keep in mind that this action must match with your authentication route you defined in the configuration of the bundle.

In this second action, you will need to do :

  1. Inject the service Mbarbey\U2fSecurityBundle\Service\U2fSecurity as argument of your action.
  2. Create a form for the authentication and use the model you just created in the prevous part. For the form, the field response must be hidden.
  3. If your form is submitted and valid:
  4. You need to call the functionvalidateAuthentication from the service and give the following arguments :
    • the user (ex: $this->getUser())
    • the authentication data filled by the form
  5. This function will either return the used key from the user and update it counter, or throw an exception so you will need a try catch to handle it and inform the user why his authentication failed.
  6. If there is no error, you can update/save the received key.
  7. Else :
  8. You will need to store the result of the function createAuthentication and pass as argument your appId and the user to ckeck. Here is an example of usage : $authenticationRequest = $service->createAuthentication($request->getSchemeAndHttpHost(), $this->getUser());. This data is the authentication request which will be sent to the user.
  9. Render a view with :
    • your form
    • the authentication request

Here is a full exemple for this controller action :

public function u2fAuthentication(Request $request, U2fSecurity $service)
{
    $authentication = new U2fAuthentication();
    $form = $this->createForm(U2fAuthenticationType::class, $authentication);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        try {
            $updatedKey = $service->validateAuthentication($this->getUser(), $authentication);

            $em = $this->getDoctrine()->getManager();
            $em->persist($updatedKey);
            $em->flush();

            return $this->redirectToRoute('user_list');
        } catch (\Exception $e) {
            $this->addFlash('danger', $e->getMessage());
        }
    }

    $authenticationRequest = $service->createAuthentication($request->getSchemeAndHttpHost(), $this->getUser());

    return $this->render('user/key/authenticate.html.twig', array(
        'authenticationRequest' => $authenticationRequest,
        'form' => $form->createView(),
    ));
}

For the front part, it's up to you. The two things you need are the form, and some JS. Here is the JS you must use. Feel free to edit it as you want.

<script src="{{ asset('bundles/mbarbeyu2fsecurity/u2f.js') }}"></script>
<script type="text/javascript">
    setTimeout(function() {
        // Magic JavaScript talking to your HID
        u2f.sign("{{ authenticationRequest.appId }}", "{{ authenticationRequest.challenge }}", {{ authenticationRequest.registeredKeys|raw }}, function(data) {

            // Handle returning error data
            if(data.errorCode && data.errorCode != 0) {
                alert("Authentication failed with error: " + data.errorCode);
                // Or handle the error however you'd like.

                return;
            }

            // On success process the data from USB device to send to the server
            var authentication_response = data;

            // Get the form items so we can send data back to the server
            var form = document.getElementsByTagName('form')[0];
            var response = document.getElementById('{{ form.response.vars.id }}');

            // Fill and submit form.
            response.value = JSON.stringify(authentication_response);
            form.submit();
        });
    }, 1000);
</script>

And, congratulation (play success music in the background). Now you users can register some security keys and when they log in, they will be redirected to the authentication page and will be jailed in it until they successfully authenticate with their security key.

Advanced use cases

In the U2F workflows, you can listen these 9 events :

Registration

  1. U2fPreRegistrationEvent

You can listen to the event U2fPreRegistrationEvent::getName() which is fired when you call the function canRegister of the service U2fSecurity from your controller.

In you listener, you can use any criteria to decide if the given user is allowed or not to register a security key. You can use the function abort of the event to deny to usage of a security key. In this case, the function canRegister called in your controller will return the U2fPreRegistrationEvent event. You can use it function isAborted to know if you should continue the process or not.

Here is an example of usage :

public function u2fRegistration(Request $request, U2fSecurity $service)
{
    /*
     * We check if there is any listener somewhere which have an objection for the current user registering a new key
     */
    $canRegister = $service->canRegister($this->getUser(), $request->getSchemeAndHttpHost());
    if ($canRegister->isAborted()) {
        $this->addFlash('warning', $canRegister->getReason());
        return $this->redirectToRoute('user_register_u2f_denied');
    }

    /*
     * The user is allowed to register a key :-)
     */
    $registration = new U2fRegistration();
    $form = $this->createForm(U2fRegistrationType::class, $registration);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        try {
            $key = new Key();
            $key->setName($registration->getName())->setUser($this->getUser());
            $service->validateRegistration($this->getUser(), $registration, $key);

            $em = $this->getDoctrine()->getManager();
            $em->persist($key);
            $em->flush();

            return $this->redirectToRoute('user_details', ['userId' => $this->getUser()->getId()]);
        } catch (\Exception $e) {
            $form->get('name')->addError(new FormError($e->getMessage()));
        }
    }

    $registrationData = $service->createRegistration($request->getSchemeAndHttpHost());

    return $this->render('security/u2fRegistration.html.twig', array(
        'jsRequest' => $registrationData['request'],
        'jsSignatures' => $registrationData['signatures'],
        'form' => $form->createView(),
    ));
}
  1. U2fRegistrationSuccessEvent

You can listen to the event U2fRegistrationSuccessEvent::getName() which is fired when a user successfully register a new security key. The event contains both the user and the newly registered key.

  1. U2fRegistrationFailureEvent

You can listen to the event U2fRegistrationFailureEvent::getName() which is fired when a user fail to register a new security key. The event contains both the user and the PHP exception which fired the failure.

  1. U2fPostRegistrationEvent

You can listen to the event U2fPostRegistrationEvent::getName() which is fired after every key registration, no matter if it was a success or a failure. The event contains the user, and can contains the newly registered key if the registration was a success.

Authentication

  1. U2fAuthenticationRequiredEvent

You can listen to the event U2fAuthenticationRequiredEvent::getName() which is fired just after a user correctly log in with it credentials and if the user has at least one security key registered. In you listener, you can use any criteria to decide if the given user must be moved in the U2F jail util he prove his identity or not. You can use the function abort of the event to prevent the usage of the U2F jail.

  1. U2fPreAuthenticationEvent

You can listen to the event U2fPreAuthenticationEvent::getName() which is fired when you call the function canAuthenticate of the service U2fSecurity from your controller.

In you listener, you can use any criteria to decide if the given user is allowed or not to use a security key as two-factor. You can use the function abort of the event to deny to usage of a security key. In this case, the function canAuthenticate called in your controller will return the U2fPreAuthenticationEvent event. You can use it function isAborted to know if you should continue the process or not. In the case of a "stop two-factor authentication", you can use the function stopRequestingAuthentication from the U2fSecurity service to remove the user from the U2F jail.

Here is an example of usage :


public function u2fAuthentication(Request $request, U2fSecurity $service)
{
    /*
     * We check if there is any listener somewhere which have an objection for the current user authenticating with a key
     */
    $canAuthenticate = $service->canAuthenticate($request->getSchemeAndHttpHost(), $this->getUser());
    if ($canAuthenticate->isAborted()) {
        $service->stopRequestingAuthentication();
        $this->addFlash('warning', 'Your connection was not secured by a security key');
        return $this->redirectToRoute('user_list');
    }

    /*
     * The user is allowed to use a key :-)
     */
    $authentication = new U2fAuthentication();
    $form = $this->createForm(U2fAuthenticationType::class, $authentication);

    $form->handleRequest($request);
    if ($form->isSubmitted()) {
        try {
            $updatedKey = $service->validateAuthentication($this->getUser(), $authentication);

            $em = $this->getDoctrine()->getManager();
            $em->persist($updatedKey);
            $em->flush();

            if ($request->getSession()->has(U2fSubscriber::U2F_SECURITY_KEY)) {
                $request->getSession()->remove(U2fSubscriber::U2F_SECURITY_KEY);
            }

            return $this->redirectToRoute('user_list');
        } catch (\Exception $e) {
            $this->addFlash('danger', 'Authentication failed');
        }
    }

    $authenticationRequest = $service->createAuthentication($request->getSchemeAndHttpHost(), $this->getUser());

    return $this->render('security/u2fAuthentication.html.twig', array(
        'authenticationRequest' => $authenticationRequest,
        'form' => $form->createView()
    ));
}
  1. U2fAuthenticationSuccessEvent

You can listen to the event U2fAuthenticationSuccessEvent::getName() which is fired when a user successfully authenticate with a security key. The event contains both the user and the used key.

  1. U2fAuthenticationFailureEvent

You can listen to the event U2fAuthenticationFailureEvent::getName() which is fired when a user fail to authenticatie with a security key. The event contains the user, the PHP exception which fired the failure and the counter of authentication failure. This counter is cleared when the user finally authenticate itself correctly.

  1. U2fPostAuthenticationEvent

You can listen to the event U2fPostAuthenticationEvent::getName() which is fired after every U2F authentication, no matter if it was a success or a failure. The event contains the user, a success flag, and can contains the user key if the authentication was a success.

You can look at the source code of the demo if you want to see these events in action.