Implement Two-Factor Authentication With Symfony and Twilio's Authy App and API

March 23, 2021
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Implement Two-Factor Authentication With Symfony and Twilio's Authy App and API

The username and password are dead! Well, not really. But considering the times we live in, it’s dangerous to rely on them alone. Computers are getting faster and better at guessing our passwords. And there are numerous databases containing stolen passwords roaming the web. Consequently, you also need to use Two-factor Authentication (2FA) in order to keep your account safe.

In this article, I will show you how to implement Two-factor authentication in a Symfony application using the Authy app to add an extra level of authentication to the traditional login form.

Prerequisites

Let's get started

To get started, create a new Symfony project, named 2-fa-demo, and switch to the newly created project’s directory using the commands below.

symfony new 2-fa-demo
cd 2-fa-demo

Next, you need to install the libraries which the project will depend on. For this project we will use the following libraries:

  1. Doctrine: The Doctrine ORM will help with managing the application database
  2. Form: The Symfony Form component will be used for the registration form
  3. Maker: This will help us with creating controllers, entities and the likes.
  4. Security: The Security bundle will help with authentication and access control in the application
  5. Twig: Twig will be used to render our HTML templates
  6. Validator: This will be used by the Form component to validate user input

To install them, run the two commands below.

composer req doctrine security twig form validator
composer req --dev maker

Next, create a .env.local file from the .env file which Symfony generated during creation of the project. To do that, run the command below.

cp .env .env.local

This file is ignored by Git as it’s automatically added to the .gitignore  file which Symfony also generated. One of the reasons for this file is the accepted best practice of storing your credentials there to keep them safe.

Next, you need to update the DATABASE_URL parameter in .env.local to use an SQLite database, instead of the default choice of PostgreSQL. To do that, comment out the existing DATABASE_URL entry, and uncomment the SQLite option, which you can see below.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Note: The database will be created in ./var and be named data.db.

With those changes made, let's run the application to be sure everything is in order. To do that, run the command below.

symfony serve -d

By default, Symfony projects run on port 8000, so navigating to https://localhost:8000/ should show the default Symfony welcome page, which you can see in the image below.

The default Symfony welcome page

Create the user entity

The next thing that we need to do is to create a user entity. This class will hold all the properties and methods which the application will require as it handles user-related activity. Create it by running the command below.

symfony console make:user

The CLI will ask several questions. Accept the defaults by pressing the “Enter” key for each of them. When the command completes, you should see a message similar to the one below:

Console output from using the Symfony make command to create a new User entity

The created entity contains almost everything. However, we require the user’s phone number for the second level of authentication. Open src/Entity/User.php and add the code below.

/**
 * @ORM\Column()
 */
private string $countryCode;

/**
 * @ORM\Column()
 */
private string $phoneNumber;

public function getCountryCode(): string
{
    return $this->countryCode;
}

public function setCountryCode(string $countryCode): void
{
    $this->countryCode = $countryCode;
}

public function getPhoneNumber(): string
{
    return $this->phoneNumber;
}

public function setPhoneNumber($phoneNumber): void
{
    $this->phoneNumber = $phoneNumber;
}

Next, run the migrations to update the database schema by running the commands below.

symfony console make:migration
symfony console doctrine:migrations:migrate

Create the registration form

With the User entity in place, you next need to create a registration form. Do that using the command below.

symfony console make:registration-form

Accept the defaults for the questions asked by the command — except for the 3 below.

Key Symfony setup questions

Once the command completes, we will modify the structure of the form. This is because we want the following to be provided (in a valid format), before we can create a new user:

  1. An email address
  2. A country code
  3. A phone number
  4. A password.
  5. A check to confirm the user accepts the application’s terms and conditions.

To do that, open src/Form/RegistrationFormType.php and edit it to match the code below.

<?php

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;

class RegistrationFormType extends AbstractType 
{
    public function buildForm(FormBuilderInterface $builder, array $options) 
    {
        $builder
            ->add('email')
            ->add(
                'countryCode'
            )
            ->add('phoneNumber', NumberType::class)
            ->add(
                'agreeTerms',
                CheckboxType::class,
                [
                    'mapped'      => false,
                    'constraints' => [
                        new IsTrue(
                            [
                                'message' => 'You should agree to our terms.',
                            ]
                        ),
                    ],
                ]
            )
            ->add(
                'plainPassword',
                RepeatedType::class,
                [
                    // instead of being set onto the object directly,
                    // this is read and encoded in the controller
                    'mapped'  => false,
                    'type'  => PasswordType::class,
                    'invalid_message' => 'The password fields must match.',
                    'options'  => ['attr' => ['class' => 'password-field']],
                    'required'   => true,
                    'first_options' => ['label' => 'Password'],
                    'second_options'  => ['label' => 'Repeat Password'],
                ]
            );
    }

    public function configureOptions(OptionsResolver $resolver) 
    {
        $resolver->setDefaults(
            [
                'data_class' => User::class,
            ]
        );
    }
}

With the code changes made, navigate to the registration page (/register). Unfortunately, it doesn’t look very good yet, as can be seen in the screenshot below. 

What the default registration route in our Symfony app looks like

 

Let’s fix that. We’ll use Bootstrap to make it and all of our other views look more professional. To do that, open the Twig configuration located in  config/packages/twig.yaml and add the following in the [twig] element, underneath default_path:

form_themes: ['bootstrap_4_layout.html.twig']

Next, update the base template to add Bootstrap via CDN. Open the templates/base.html.twig file and replace its content with the following:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Welcome!{% endblock %}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css"
        rel="stylesheet"
        integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1"
        crossorigin="anonymous"
    >
    <link href="css/index.css" rel="stylesheet">
    {% block stylesheets %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<script
    src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW"
    crossorigin="anonymous">
</script>
{% block javascripts %}{% endblock %}
</body>
</html>

We referenced css/index.css in  the code above. So create a new directory, named css in the public directory, then create index.css inside the new cssdirectory. After that, add the following code to index.css:

.form-container {
    border-top-left-radius: .25rem;
    border-top-right-radius: .25rem;
    border-width: 1px;
    padding: 1.5rem;
    margin-left: 0;
    margin-right: 0;
}

Next, we need to update the registration Twig file. To do that, open templates/registration/register.html.twig  and replace the code in it with the code below.

{% extends 'base.html.twig' %}

{% block title %}Register{% endblock %}

{% block body %}
    {% for flashError in app.flashes('registration_error') %}
        <div class="alert alert-danger" role="alert">{{ flashError }}</div>
    {% endfor %}

    <h1>Register</h1>
    <div class="form-container">
        {{ form_start(registrationForm) }}
        <div class="mb-3">
            {{ form_label(registrationForm.email) }}
            {{ form_widget(registrationForm.email) }}
        </div>

        <div class="mb-3">
            {{ form_row(registrationForm.countryCode, {
                label: 'Country Code'
            }) }}
        </div>
        <div class="mb-3">
            {{ form_row(registrationForm.phoneNumber, {
                label: 'Phone Number'
            }) }}
        </div>

        <div class="mb-3">
            {{ form_row(registrationForm.plainPassword.first, {
                label: 'Password'
            }) }}
        </div>
        <div class="mb-3">
            {{ form_row(registrationForm.plainPassword.second, {
                label: 'Repeat Password'
            }) }}
        </div>
        <div class="mb-3 form-check">
            {{ form_row(registrationForm.agreeTerms) }}
        </div>
        <button type="submit" class="btn btn-primary">Register</button>
        <a href="/login">Login</a>
        {{ form_end(registrationForm) }}
    </div>
{% endblock %}

With those changes made, reload the register route to see the new look. It should look like the screenshot below.

What the registration form looks like when using Bootstrap CSS

Create the authenticated view

Next, let's create a user dashboard, a view that should only be seen by an authenticated user. It will be a small view showing the user's email address and phone number, as well as a logout button. To do that, first create a controller using the Maker command below:

symfony console make:controller DashboardController

This command creates a controller (src/Controller/DashboardController.php) and an accompanying view template (templates/dashboard/index.html.twig) for us. In src/Controller/DashboardController.php, replace the code and annotation for the index function with the code below.

/**
 * @Route("/", name="dashboard")
 */
public function index(): Response
{
    return $this->render(
        'dashboard/index.html.twig', 
        ['user' => $this->getUser()]
    );
}

Then, replace the content of the templates/dashboard/index.html.twig file with the code below

{% extends 'base.html.twig' %}

{% block title %}Hello!{% endblock %}

{% block body %}
<div class="form-container">
    <div class="card">
        <div class="card-header">
            Dashboard
        </div>
        <div class="card-body">
            <h5 class="card-title">Email - {{ user.email }}</h5>
            <p class="card-text">Phone Number - +{{ user.countryCode }} {{ user.phoneNumber }}</p>
            <a href="/logout" class="btn btn-primary">Logout</a>
        </div>
    </div>
</div>
{% endblock %}

Add the first layer of authentication

The first layer of authentication is the traditional login form. The user provides an email address and a password which are then checked by the application. We can quickly set this up using Symfony’s MakerBundle. To do so, run the command below.

symfony console make:auth

The CLI will ask several questions. Answer them as shown in the example below:

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > LoginAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > AuthenticationController

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 > yes

The command creates an Authenticator called LoginAuthenticator located in the src/Security directory. This class contains all the logic related to authentication including the route to redirect the user to on successful authentication. You can read more about this here.

In src/Security/LoginAuthenticator, update the onAuthenticationSuccess function to match the following

public function onAuthenticationSuccess(
    Request $request, 
    TokenInterface $token, 
    string $providerKey
) {
    if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
        return new RedirectResponse($targetPath);
    }

    return new RedirectResponse($this->urlGenerator->generate('dashboard'));
}

The onAuthenticationSuccess function is called when the user provides a valid email and password combination. When called, this function checks if the user was redirected to the login page from another route. If so, the user is returned to that route. If not, the user is redirected to the dashboard.

Next, style the login form by wrapping it in a div with a class name form-container. The code in the templates/security/login.html.twig file should match the code below

{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}
<div class="form-container">
<form method="post">
    {% if error %}
        <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
    {% endif %}

    {% if app.user %}
        <div class="mb-3">
            You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a>
        </div>
    {% endif %}

    <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
    <div class="mb-3">
    <label for="inputEmail">Email</label>
    <input type="email" value="{{ last_username }}" name="email" id="inputEmail" class="form-control" required autofocus>        
    </div>
            <div class="mb-3">
    <label for="inputPassword">Password</label>
    <input type="password" name="password" id="inputPassword" class="form-control" required>
        </div>
    <input type="hidden" name="_csrf_token"
           value="{{ csrf_token('authenticate') }}"
    >
    <button class="btn btn-lg btn-primary" type="submit">
        Sign in
    </button>
    <a href="/register">Register</a>
</form>
</div>
{% endblock %}

When the user successfully completes the registration form, the page should redirect to the login page. At the moment it redirects to the _preview_error route. To fix that, modify the register function in src/Controller/RegistrationController.php to match the code below

public function register(
    Request $request, 
    UserPasswordEncoderInterface $passwordEncoder
): Response
{
    $user = new User();
    $form = $this->createForm(RegistrationFormType::class, $user);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // encode the plain password
        $user->setPassword(
            $passwordEncoder->encodePassword(
                $user,
                $form->get('plainPassword')->getData()
            )
        );

        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($user);
        $entityManager->flush();
        // do anything else you need here, like send an email

        return $this->redirectToRoute('app_login');
    }

    return $this->render('registration/register.html.twig', [
        'registrationForm' => $form->createView(),
    ]);
}
The login route throwing an exception because a logged in user is not available

We're getting closer to completing the application, but we're not quite there yet as the index page will throw an exception. This is because the DashboardController expects a logged in user, yet the route is accessible without being authenticated. To fix that, open config/packages/security.yaml and add the code below to the bottom of the access_control key:

     - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
     - { path: ^/logout, roles: IS_AUTHENTICATED_ANONYMOUSLY }
     - { path: ^/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
     - { path: ^/, roles: ROLE_USER }

With the file updated, reload the index page. This time, you will be redirected to the login page.

Basic HTML Login Form

Make sure your application works by registering a new user and logging in. You should see the user's email address and phone number after a successful login, as in the screenshot below.

The view after successfully logging in

With everything in place, we’re now set to implement the second layer of authentication. Before we do that, however, you need to recreate the database. This avoids problems when we update the user entity. To do that, run the following commands:

./bin/console doctrine:database:drop --force
./bin/console doctrine:database:create
./bin/console doctrine:schema:update --force

Add the Two-Factor Authentication layer

As part of the second layer of authentication, we want the user to provide a Time-based One-Time Password (TOTP) generated by the Authy app. Our application will verify this with Twilio’s Authy API and only allow the user to view the dashboard if the provided TOTP is valid.  

The first thing we need to do is to get an App ID for our application, which will allow our application to interact with the Authy API. To do this, open your Twilio Console and create a new application. You can name your application 2-fa-demo.

Create a new Twilio Authy app

Click the “Create” button to view the “General Settings” for your new application. Then, copy the Production API Key.

Copy the production API key from a Twilio Authy app

In your .env.local file, add a new variable, named AUTHY_API_KEY, whose value is the production API key which you just copied.

AUTHY_API_KEY="INSERT_YOUR_PRODUCTION_API_KEY_HERE"

Next, create a new, key AUTHY_API_KEY, in config/services.yaml. Do this by adding the code below under parameters:

AUTHY_API_KEY: '%env(AUTHY_API_KEY)%'

To integrate with the Authy API we’ll use the PHP Authy helper library. To install it, run the command below.

composer require authy/php  

The workflow for our 2-factor authentication process is as follows:

  1. Register a user on Authy: We create a new user whenever the registration form is submitted. At the end of this stage, we will have the user’s Authy ID which we will use whenever we want to verify a provided TOTP from the user.
  2. Verify a one-time password: Whenever a user provides a valid email address and password, we will request an OTP which we will verify before redirecting the user to the dashboard, if it is valid.

Note: We do not have to worry about generating a one-time password because this will be handled by the Authy app. 

Next, we'll create a service to handle the 2 steps in our workflow, which we just identified. To do that, create a directory called Service in the src directory, and in that directory create a file called AuthyHelper.php. In the new file, add the following:

<?php

namespace App\Service;

use Authy\AuthyApi;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;

class AuthyHelper
{
    private AuthyApi $api;

    public function __construct(ParameterBagInterface $parameterBag) {
        $apiKey = $parameterBag->get('AUTHY_API_KEY');
        $this->api = new AuthyApi($apiKey);
    }
}

In this service, the ParameterBagInterface is injected as a constructor dependency. The ParameterBagInterface is used to obtain the AUTHY_API_KEY which is in turn used to initialize an AuthyApi object.

Register a user on Authy

Next, we need to add a function that creates a new user from the user’s email address, phone number, and country code.  To do that, add the following two functions to src/Service/AuthyHelper.php file.

public function registerUser(
    string $email,
    string $phoneNumber,
    string $countryCode
): int {
    $user = $this->api->registerUser(
        $email,
        $phoneNumber,
        $countryCode
    );

    if ($user->ok()) {
        return $user->id();
    }

    $this->throwRegistrationException($user->errors());
}

private function throwRegistrationException($errors): void
{
    $errorMessage = "";
    foreach ($errors as $field => $message) {
        $errorMessage .= "$field = $message\n";
    }
    throw new \App\Exception\RegistrationException($errorMessage);
}

The registerUser function takes the user’s email address, phone number, and country-code, and makes a registerUser request to the Authy API. If everything goes well, the function returns the user’s Authy ID. If not, the API returns an array of errors associated with the request.

If the Authy API returns errors, we use the throwRegistrationException function to convert the errors array into a string, a message that can be displayed accordingly.

As part of that process, we need to create a new Exception class called RegistrationException. Create a directory called Exception in src and in it create a file called RegistrationException.php. In the new file, add the following code:

<?php

namespace App\Exception;

class RegistrationException extends \Exception {
}

Verify a TOTPTo verify a provided OTP, we need to make a request to the Authy API with the user's Authy ID and the provided OTP. If everything checks out, we will receive an HTTP 200 OK response. If it doesn't, then the application should throw an exception with an appropriate error message.

Let's create an exception called VerificationException which will be thrown whenever an invalid OTP is provided. To do that, in the src/Exception directory, create a new file called VerificationException.php and add the following:

<?php

namespace App\Exception;

class VerificationException extends \Exception {
}

Next, in src/Service/AuthyHelper.php file, add the following functions:

public function verifyUserToken(
    string $userAuthyId,
    string $token
): void {
    try  {
        $verification = $this->api->verifyToken($userAuthyId, $token);
        if (!$verification->ok()) {
            $this->throwVerificationException();
        }
    } catch (AuthyFormatException $exception){
        $this->throwVerificationException();
    }
}
    
private function throwVerificationException(){
    throw new VerificationException("Invalid OTP provided");
}

We use the throwVerificationException function to throw a custom exception when an invalid OTP is provided. Also, if the user provides an OTP that is too short (or too long), an AuthyFormatException is thrown, we catch that and return our user friendly exception.

Note: Don’t forget the import statement for the AuthyFormatException class.

use Authy\AuthyFormatException;

With these, we have a service that can interact with the Authy API.

Update a User entity

In order to verify the OTP provided by the user, we need to provide the user’s Authy ID along with the supplied OTP. However, this isn’t, currently, being stored by our application in the database. To fix that, open src/Entity/User.php and add the code below.

/**
 * @ORM\Column()
 */
private string $authyId;

public function setAuthyId(string $authyId): void
{
    $this->authyId = $authyId;
}

public function getAuthyId(): string
{
    return $this->authyId;
}

The setAuthyId function will be used when we register the user with the Authy API, while the getAuthyId function will be used when a verification request needs to be made for a user. Update your database schema to add the authy_id column using the  command below.

./bin/console doctrine:schema:update --force

Create an Authy ID on registration

At the moment, our users are being saved in our database. However, we need to register the user with the Authy API and get the user’s Authy ID before saving the user to the database. In our application, registration and login are handled in the  RegistrationController and AuthenticationController respectively so our next set of modifications will be done there.

In the src/Controller/RegistrationController.php file, modify the register function to match the following:

public function register(
    Request $request,
    UserPasswordEncoderInterface $passwordEncoder,
    AuthyHelper $authy
): Response {
    $user = new User();
    $form = $this->createForm(RegistrationFormType::class, $user);
    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        // encode the plain password
        $user->setPassword(
            $passwordEncoder->encodePassword(
                $user,
                $form->get('plainPassword')->getData()
            )
        );
        try {
            //get the user's authy ID
            $authyId = $authy->registerUser(
                $user->getEmail(),
                $user->getPhoneNumber(),
                $user->getCountryCode()
            );
            $user->setAuthyId($authyId);

            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($user);
            $entityManager->flush();

            // do anything else you need here, like send an email
            return $this->redirectToRoute('app_login');

        } catch (\App\Exception\RegistrationException $exception) {
            $this->addFlash('registration_error', $exception->getMessage());
        }
    }

    return $this->render('registration/register.html.twig', [
        'registrationForm' => $form->createView(),
    ]);
}

Note: Don’t forget the import statement for the AuthyHelper class.

use App\Service\AuthyHelper;

With our modifications completed, the user will be registered with the Authy API when the form is submitted with valid data. The user entity is updated with the returned Authy ID and persisted to the database, before redirecting the user to the login page.

This process of registering the user with the Authy API is wrapped in a try-catch block. In the event that our AuthyHelper service throws a RegistrationException, we want to catch that and display a flash message containing the errors in a reader friendly format.

The TOTP verification page

Upon provision of a valid email and password combination during login, we need the user to provide the  Authy-generated TOTP, which you can see an example of in the screenshot below.

Retrieve a TOTP from the Authy app

To do this, we’ll implement a form which will collect the user’s TOTP and verify it with the Authy API. If the TOTP checks out, the user will be redirected to the dashboard. If not, an error message will be displayed and the user will have the chance to provide a valid OTP.

In the templates/security folder, create a file called verify.html.twig and add the code below to it.

{% extends 'base.html.twig' %}

{% block title %}Verify OTP{% endblock %}

{% block body %}
<div class="form-container">
<form method="post">
    {% if error %}
        <div class="alert alert-danger">{{ error }}</div>
    {% endif %}

    <h1 class="h3 mb-3 font-weight-normal">Please provide your OTP</h1>

    <div class="mb-3">
        <label for="token">OTP</label>
        <input 
            type="number" 
            name="token" 
            id="token" 
            class="form-control" 
            required 
            autofocus>        
  
      <input 
            type="hidden" 
            name="_csrf_token"
            value="{{ csrf_token('authenticate') }}">
    </div>

    <button class="btn btn-lg btn-primary" type="submit">
        Submit
    </button>
    <a href="/logout">Cancel</a>

</form>
</div>
{% endblock %}

Next, open src/Controller/AuthenticationController.php and add the following code.

/**
 * @Route("/verify", name="app_verify_otp")
 */
public function verifyOTP(Request $request, AuthyHelper $authy) : Response {
    $error = null;
    if ($request->getMethod() === 'POST') {
        $token = $request->request->get('token');
        $userAuthyId = $this->getUser()->getAuthyId();

        try {
            $authy->verifyUserToken($userAuthyId, $token);
            $this->get('session')->set('2fa-verified', true);
            return $this->redirectToRoute('dashboard');
        } catch (\App\Exception\VerificationException $exception) {
            $error = $exception->getMessage();
        }
    }

    return $this->render(
        'security/verify.html.twig',
        ['error' => $error]
    );
}

Note: Don’t forget the import statements, listed in the example below.

use App\Service\AuthyHelper;
use Symfony\Component\HttpFoundation\Request;

The OTP verification request is handled by the verifyOTP function. This function gets the submitted token and the Authy ID of the currently logged-in user. It then makes a verification request to the Authy API.

If a VerificationException is thrown, then the OTP verification page is re-rendered with the error message displayed. Otherwise, the user is redirected to the dashboard - but not before a new session variable, called 2fa-verified, is set to true. We use this session variable later on.

If you try logging in at this point, you are still redirected to the dashboard. To fix that, let’s refactor the authentication logic. At the moment, the onAuthenticationSuccess function in src/Security/LoginAuthenticator.php redirects the user to the dashboard.

However, we need to redirect the user to the OTP verification page we just created. This is because we need the user to provide an OTP before they can access the dashboard. To do that, modify the function to match the code below.

public function onAuthenticationSuccess(
    Request $request,
    TokenInterface $token,
    string $providerKey
) {
    // always redirect to page for verifying OTP 
    return new RedirectResponse(
        $this->urlGenerator->generate('app_verify_otp')
    );
}

Because we always want the user to be directed to the OTP verification page, we return the same response irrespective of where the user was redirected from.

Try logging in again. This time you’ll be redirected to the OTP verification page as expected.

The form for providing an OTP

If you type in the OTP generated from your Authy app and click Submit, you will be redirected to the dashboard. If the OTP is invalid, however, you will see an error message as shown below:

The provide OTP form when the OTP is invalid

At the moment, because Symfony recognizes the user after login (before OTP verification) it is possible to bypass the verification stage by editing the URL. Try removing the /verify in the URL and you should see your dashboard.

To achieve the expected result, we need to intercept the request before it is handled by the controller. and ensure that the user has passed the second level of authentication. We can do this by subscribing to the ControllerEvent.

According to the Symfony documentation, the ControllerEvent 

is dispatched after the controller to be executed has been resolved but before executing it.

If we subscribe to this event, we can determine if the user has passed the second level of authentication (2fa-passed is true) and redirect to the verification page if not.

To do that, create a new directory called Subscriber in the src directory. Next, in the src/Subscriber directory, create a new file called VerificationSubscriber.php and add the code below.

<?php
namespace App\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpFoundation\{Request, RedirectResponse};

class VerificationSubscriber implements EventSubscriberInterface {
    public static function getSubscribedEvents() {
        return [
            KernelEvents::CONTROLLER => 'onKernelController',
        ];
    }
}

By making the VerificationSubscriber conform to the EventSubscriberInterface, we can specify which event we want to listen to in the getSubscribedEvents function.

This function returns the event we want to listen to and the function to be called when the event is dispatched. In our case, we want to listen to the ControllerEvent. When it is dispatched, we want the onKernelController function to be called.

Before we write this function, let’s write a few functions that will be used by this function. First, we need a function to tell us if a request has passed the verification stage. In src/Subscriber/VerificationSubscriber.php, add the following function:

private function verificationPassed(Request $request): bool {
    return $request->getSession()->get('2fa-verified') === true;
}

This function checks if the 2fa-verified session variable is set to true. Next, we need a function to check if a given route requires the verification to have been passed. For routes related to registration, login, logout, and OTP verification, the user is not expected to have passed verification, hence these pages should load even if the 2fa-verified session variable is not set to true.

If the route does not correspond to any of the earlier mentioned routes, then the user is expected to have passed the verification. In the src/Subscriber/VerificationSubscriber.php file add the following function:

private function requiresVerification(?string $route): bool {
    $permittedRoutes = [
        'app_login', 'app_verify_otp',
        'app_logout', 'app_register', 
        '_wdt'
    ];

    return !in_array($route, $permittedRoutes);
}

Note: the _wdt route was added to the array of permitted routes. This prevents the application from redirecting to the OTP verification page in development mode as a result of the Web Debug Toolbar.

Next, we need a function that determines whether a request should be redirected to the OTP verification page. The request should be redirected if the route to be accessed requires verification and yet the 2fa-verified session variable is not set to true.

In the src/Subscriber/VerificationSubscriber.php file, add the following function:

private function shouldRedirectToVerificationPage(Request $request): bool {
    $requestRoute = $request->attributes->get('_route');
    return
        $this->requiresVerification($requestRoute) &&
        !$this->verificationPassed($request);
}

With these in place, we can finally implement the onKernelController function. Add the following function to src/Subscriber/VerificationSubscriber.php

public function onKernelController(ControllerEvent $event): void {
    if ($this->shouldRedirectToVerificationPage($event->getRequest())) {
        $event->setController(fn () => new RedirectResponse('/verify'));
    }
}

Note: Don’t forget the import statement for the ControllerEvent, Request, and RedirectResponse classes.

use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

The onKernelController function takes a ControllerEvent and checks if the user to be redirected to the verification page. If the user should be redirected, then the event controller is set to a PHP callable which returns a RedirectResponse to the OTP verification page.

Try bypassing the OTP verification page again. This time you will see that you are returned to the OTP verification page.

That's how you implement Two-Factor Authentication with Symfony and Twilio's Authy App and API

In this tutorial, we looked at an alternative form of 2-factor authentication by using the Authy App instead of an SMS or email. Using the Symfony framework, we were able to upgrade a traditional login form to require a Time-based One-Time-Password generated by the Authy App. We also looked at how we can use ControllerEvents to prevent users from bypassing the OTP verification stage.

You can review the final codebase on GitHub. Until next time, bye for now.  

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at a screen, he enjoys a cold beer and laughs with his friends.