Massive data breaches and sophisticated social engineering attacks in recent months suggest that guessing a user’s password (or retrieving it from a database on the dark web) has become easier than people think. Along with the fact that most people tend to reuse passwords (for ease of remembrance), securing your application by requiring just a username and password is no longer enough. This has led to the rise of Two Factor Authentication (2FA) - an added layer of security where the user has to provide some extra information (such as a One Time Token or One Time Password) before they can gain access to their account.

In this tutorial, I will show you how to use Twilio’s Verify API to implement Two Factor Authentication for a CakePHP application. At the login stage, the application will send a token as an SMS to the user’s phone number. Only after providing this token to the application will the user be allowed into the secure area where sensitive transactions of the user’s company are warehoused.

Prerequisites

A reasonable knowledge of object-oriented programming with PHP will help you get the best out of this tutorial. Throughout the article, I will do my best to explain the CakePHP concepts I raise and provide links for further reading. Please feel free to pause and go through them if you’re lost at any point -  I promise this tutorial will still be here when you come back.

You also need to ensure that you have Composer installed globally on your computer, and a Twilio account.

What We’ll Build

In this tutorial, we’ll build an application with a restricted page showing the list of users in the application. This page will only be available to authenticated users. New users are allowed to sign up by providing their email address, phone number, and password. To do this, they provide their email address and password. If they are validated, a One Time Token (OTT) will be sent to the user via SMS. The user will provide the OTT which will be validated before the user will be allowed into the secure area of the application.

Getting Started

To get started create a new CakePHP project using Composer with the command below:

$ composer create-project --prefer-dist cakephp/app 2fa-secure-app

NOTE: You will be asked if you want to select folder permissions. Type "yes" when asked

This will create a new CakePHP project with all its dependencies installed in a folder named 2fa-secure-app.

Run the Application

Move into the project folder and run the application with these commands:

// Move into the project folder
$ cd 2fa-secure-app

// Run the application
$ bin/cake server

Navigate to http://localhost:8765/ from your browser to view the welcome page similar to the screenshot below:

CakePHP welcome screen

As you can see, almost everything is in place apart from our database connection so let’s fix that. In your terminal press the CTRL + C keys to stop the application and we’ll continue with the rest of our setup.

In the config/app_local.php file, modify the database parameters which can be found in the Datasources.default array.

'username' => '', // Your database username
'password' => '', // Your database password
'database' => '', // Your database name

Rerun your application again and you’ll see that we have been able to establish a connection with the database as indicated below:

Welcome to CakePHP 4.1.6 Strawberry

Database Migration

Once our application scaffolding is complete, set up the database tables using the Bake console to create and run database migrations. We’ll create a migration for the users table.

$ bin/cake bake migration CreateUsers email:string:unique phone_number country_code password created modified

This creates a migration file for a table called users with the following columns:

  • email: This column is a string corresponding to the user’s email address. This column is unique to allow the system to keep track of registered users.
  • phone_number: This column is a string corresponding to the user’s phone number.
  • country_code: This is the country calling code for the user’s phone number.
  • password: This is the hashed version of the user’s password. This will be used in our first layer of authentication
  • created: This corresponds to the timestamp for when the user was created
  • modified: This corresponds to the timestamp for when the user was last modified.

Run the migrations with the following command:

$ bin/cake migrations migrate

Your columns will be created on the database.

We can proceed to create a Controller, Entity, Table, and template for users using the Bake CLI. To do this, run the following command:

$ bin/cake bake all users 

Fire up your server with the command below and see what we have so far:

$ bin/cake server

Navigate to http://localhost:8765/users from your browser to view the list of users. There should be no users at the moment.

List of users

We need to make one more change before we can start adding new users. Open the templates/Users/add.php file. This is the file that generates the view for http://localhost:8765/users/add, also known as the form to add a new user.

In the fieldset element, you’ll see four echo statements for the email, phone number, password, and country_code columns. Replace the statement for country_code with the following:

echo $this->Form->label('Country Code');
echo $this->Form->select('country_code',[
    '234' => 'Nigeria' 
//You may add other country codes here. 
]);

NOTE: Twilio needs to be able to send an SMS for this to work, so include your country code in the list of countries and make sure to create a user with that country code.

This replaces the default input field provided with a dropdown that allows the user to select the country dialing code for the provided phone number.

NOTE: Don’t forget to make the same change in the templates/Users/edit.php file. 

Click on the New User button and you will see a page similar to the one depicted in the image below:

Add User Page

Before we start adding users, let’s make one more modification. We want the user’s phone number to be in the format +{country_code}{phone_number}. Before saving the phone number, we need to check and remove the leading 0 so that our preferred format will be valid. To do this, open the src/Model/Entity/User.php file and add the following function:

protected function _setPhoneNumber(string $phoneNumber): string{
    if ($phoneNumber[0] === '0'){
        //remove the leading 0 from the phone number
        return substr($phoneNumber,1);
    }
    return $phoneNumber;
}

Let’s also add a function which gives us the phone number in our preferred format. Add the following function in the src/Model/Entity/User.php file:

public function getPhoneNumber(): string {
    return "+{$this->country_code}{$this->phone_number}";
}

With those functions added, add a new user. Everything works nicely but looking at the list of users, we can see that the user’s password is saved in plain text. This is actually a very bad practice so let’s fix that.

For authentication and password hashing, we’ll use the CakePHP Authentication Plugin. Install that using the composer command below:

$ composer require cakephp/authentication:^2.0

Once the installation is completed, we’ll hash the user’s password just before saving it to the database. Open the src/Model/Entity/User.php file and add the following function:

protected function _setPassword(string $password) : ?string
{
    if (strlen($password) > 0) {
        return (new DefaultPasswordHasher())->hash($password);
    }
}

NOTE: Don’t forget to add the following use  statement for the DefaultPasswordHasher, if your text editor or IDE doesn’t do it automatically. 

use Authentication\PasswordHasher\DefaultPasswordHasher;

Edit the previously saved user and set a different password. When the list of users is reloaded, you’ll see that the user’s password is being hashed.

Next, we’ll set up the first layer of authentication - the validation of a provided username and password combination.

First layer of authentication

For the first layer of authentication, the user has to provide a valid email address and password combination. We’ll start by modifying our src/Application.php class. In this class, we’ll apply an authentication middleware and also add an AuthenticationService which will determine how authentication is handled. To start with, we’ll make the Application class implement the Authentication\AuthenticationServiceProviderInterface.

class Application extends BaseApplication
    implements Authentication\AuthenticationServiceProviderInterface
{

This will cause an error as we need the getAuthenticationService function declared. Fix that by adding the following:

public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
{
    $authenticationService = new AuthenticationService([
        'unauthenticatedRedirect' => '/users/login',
        'queryParam' => 'redirect',
    ]);

    // Load identifiers, ensure we check email and password fields
    $authenticationService->loadIdentifier('Authentication.Password', [
        'fields' => [
            'username' => 'email',
            'password' => 'password',
        ]
    ]);

    // Load the authenticators, you want session first
    $authenticationService->loadAuthenticator('Authentication.Session');

    // Configure form data check to pick email and password
    $authenticationService->loadAuthenticator('Authentication.Form', [
        'fields' => [
            'username' => 'email',
            'password' => 'password',
        ],
        'loginUrl' => '/users/login',
    ]);

    return $authenticationService;
}

This service does a couple of things. The first thing it does is create an AuthenticationService object. We pass the configuration for the service as an array with two entries:

  • unauthenticatedRedirect: This lets the service know the URL to redirect to in case the user is not authenticated.
  • queryParam: This will allow the AuthenticationService to redirect to the previously visited URL once the user is authenticated successfully.

The next thing we do is load an identifier that will be used to identify the user based on the email and password they provide. It will also authenticate the user by comparing the saved hash with the hashed version of the password provided by the user.

Afterward we load two authenticators: Authentication.Service and Authentication.Form The Authentication.Service authenticator checks the session for user data or credentials while the Authentication.Form authenticator checks the submitted form from loginURL to get the user’s email and password for authentication.

With the code in place, we can add a middleware to our application to ensure that every request is from an authenticated user. We do this in the middleware function, by adding the following code to the function, between adding RoutingMiddleware and BodyParserMiddleware:

->add(new AuthenticationMiddleware($this))

NOTE: Don’t forget to add the required import statements at the top of the src/Application.php file.

use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
use Psr\Http\Message\ServerRequestInterface;

Next, open the src/Controller/AppController.php file and add the following to the initialize function:

$this->loadComponent('Authentication.Authentication');

The next thing to do is create a login action in the UserController. To do that, open the src/Controller/UsersController.php file and add the following function:

public function login()
{
    $this->request->allowMethod(['get', 'post']);
    $result = $this->Authentication->getResult();

    // regardless of POST or GET, redirect if user is logged in
    if ($result->isValid()) {
        // redirect to /users after login success
        $redirect = $this->request->getQuery('redirect', [
            'controller' => 'Users',
            'action' => 'index',
        ]);

        return $this->redirect($redirect);
    }

    // display error if user submitted and authentication failed
    if ($this->request->is('post') && !$result->isValid()) {
        $this->Flash->error(__('Invalid username or password'));
    }
}

The login function only allows GET and POST requests. It checks the result of the authentication process and redirects to the list of users if the user was authenticated successfully. If not, it displays an error message.

We also need to let the system know that an unauthenticated user should be allowed to access the login and register page. To do that, we override the beforeFilter function in the UserController by adding the following to the src/Controller/UsersController.php file:

public function beforeFilter(EventInterface $event)
{
    parent::beforeFilter($event);
    $this->Authentication->addUnauthenticatedActions(['login', 'add']);
}

Add the import statement to the top of the file:

use Cake\Event\EventInterface;

Let’s also add a logout action. To do that, add the following to the src/Controller/UsersController.php file:

// in src/Controller/UsersController.php
public function logout()
{
    $result = $this->Authentication->getResult();

    // regardless of POST or GET, redirect if user is logged in
    if ($result->isValid()) {
        $this->Authentication->logout();
        return $this->redirect(['controller' => 'Users', 'action' => 'login']);
    }
} 

We can also add a logout button to our index view. Open the templates/Users/index.php file and locate the following line:

<?= $this->Html->link(__('New User'), ['action' => 'add'], ['class' => 'button float-right ']) ?>

Underneath that line add the following:

<?= $this->Html->link(__('Logout'), ['action' => 'logout'], ['class' => 'button']) ?>

Next, we create a template for the login page. Create a file called login.php in the templates/Users directory with the touch command below:

$ touch templates/Users/login.php

Add the following to the templates/Users/login.php file:

<div class="users form">
    <?= $this->Flash->render() ?>
    <h3>Login</h3>
    <?= $this->Form->create() ?>
    <fieldset>
        <legend><?= __('Please enter your email address and password') ?></legend>
        <?= $this->Form->control('email', ['required' => true]) ?>
        <?= $this->Form->control('password', ['required' => true]) ?>
    </fieldset>
    <?= $this->Form->submit(__('Login')); ?>
    <?= $this->Form->end() ?>

    <?= $this->Html->link("Add User", ['action' => 'add']) ?>
</div>

Go to http://localhost:8765/users and notice that the login page is displayed. Go ahead and log in to see the list of users again. Clicking the logout button will return you to the login screen.

With this in place let’s add the second layer of authentication.

Second Layer of Authentication

The second layer of authentication requires the user to provide a One Time Token that will be sent via SMS. To do this, we’ll take advantage of the Twilio Verify API. Using the API, our application will make a request for Twilio to send an SMS with the token to the user.

The application will then display a form asking for the token. When the user provides a token, the application forwards it to Twilio for verification. If it’s valid, the user is redirected to the secured area of the app. If not, an error message is displayed.

To get started, you need to have a Twilio service set up. Navigate to the Twilio Services dashboard. For now, we’re only using SMS, so we can disable calls and emails in our service settings.

Give your service a friendly name such as "2-fa-cake-php". Once you’ve created the service, you’re going to need the SERVICE SID. You can find it on the General Settings page for your newly created service.

General settings page

NOTE: You’ll also need your ACCOUNT SID and AUTH TOKEN for your application. You can find them on the Twilio dashboard.

Once you have your SERVICE SID,  ACCOUNT SID, and AUTH TOKEN, head back to your CakePHP application.

To interact with Twilio, we’ll take advantage of the Twilio PHP SDK. Install it using the following command:

$ composer require twilio/sdk

Next, we’ll create some environment variables to hold our Twilio keys, which will be stored in the .env file in the config directory. Before we create it, open the config/.env.example file and add the following entries to it:

export TWILIO_ACCOUNT_SID = "INSERT_YOUR_TWILIO_ACCOUNT_SID"
export TWILIO_AUTH_TOKEN = "INSERT_YOUR_TWILIO_AUTH_TOKEN"
export TWILIO_SERVICE_SID= "INSERT_YOUR_TWILIO_SERVICE_SID"

NOTE: Don’t save your credentials in the .env.example file. This file will not be ignored by your version control system and could expose your private credentials.

Create a copy of the .env.example file named .env.

$ cp config/.env.example config/.env 

In your .env file, replace the TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SERVICE_SID keys with the corresponding Twilio credentials.

NOTE: Don’t forget to remove  .env  from your version control system, for example, by adding it to .gitignore when using Git.

Before our application can load the .env variables, we need to modify our bootstrap.php file. To do this, open config/bootstrap.php file and uncomment the following lines:

if (!env('APP_NAME') && file_exists(CONFIG . '.env')) {
    $dotenv = new \josegonzalez\Dotenv\Loader([CONFIG . '.env']);
    $dotenv->parse()
        ->putenv()
        ->toEnv()
        ->toServer();
}

Now, we’ll create a service that allows us to handle SMS verification. Create a directory named Service in the src folder, then create a file called TwilioSMSVerificationService.php inside the newly created src folder.

Our service will have three private variables:  the user’s phone number, our Twilio service ID, and a Client object from the Twilio SDK. To do this, add the following code to the new src/Service/TwilioSMSVerificationService.php file:

<?php

namespace App\Service;

use Twilio\Rest\Client;

class TwilioSMSVerificationService
{
    private string $phoneNumber;
    private string $serviceSid;
    private Client $twilio;
}

Next, we need a constructor that takes the user’s phone number as an argument. Add the following to src/Service/TwilioSMSVerificationService.php:

public function __construct(string $phoneNumber)
{
    $this->phoneNumber = $phoneNumber;
    $sid = env('TWILIO_ACCOUNT_SID');
    $token = env('TWILIO_AUTH_TOKEN');
    $this->serviceSid = (string)env('TWILIO_SERVICE_SID');
    $this->twilio = new Client($sid, $token);
}

Apart from initializing the $phoneNumber and $serviceSid variables, we created a Client object that will be used for making requests to the Verify API.

Next, we need a function to send a verification token to a phone number. To do that, add the following to the src/Service/TwilioSMSVerificationService.php file:

public function sendVerificationToken()
{
    $this
        ->twilio
        ->verify
        ->v2
        ->services($this->serviceSid)
        ->verifications
        ->create($this->phoneNumber, 'sms');
}

We also need a function to verify the token provided by the user. This function makes a verification check to the Twilio Verify API and returns a boolean corresponding to whether the token is valid or not. It does this by checking if the value of the verification check status is approved. To do this, add the following to the src/Service/TwilioSMSVerificationService.php file:

public function isValidToken(string $token): bool
{
    $verificationResult =
        $this
            ->twilio
            ->verify
            ->v2
            ->services($this->serviceSid)
            ->verificationChecks
            ->create($token,
                ['to' => $this->phoneNumber]
            );
    return $verificationResult->status === 'approved';
}

With the service completed, we can create a controller to handle the second level of authentication. Use the following command to create a new controller:

 $ bin/cake bake controller Verification --no-actions

NOTE: The --no-actions argument tells the bake CLI we need an empty controller

A new file called VerificationController.php will be created in the src/Controller directory. Open the src/Controller/VerificationController.php file and add the following:

public function index()
{
    $this->request->allowMethod(['get', 'post']);

    $phoneNumber = $this
        ->Authentication
        ->getIdentity()
        ->getOriginalData()
        ->getPhoneNumber();

    $verificationService = new TwilioSMSVerificationService($phoneNumber);

    $requestMethod = $this->request->getMethod();

    if ($requestMethod === 'GET') {
        $verificationService->sendVerificationToken();
        $this->Flash->success(__('Please provide the token sent by SMS'));
    }

    if ($requestMethod === 'POST') {
        $token = $this->request->getData('token');
        if ($verificationService->isValidToken($token)) {
            $cookie = new Cookie('2-fa-passed', '1', new Time('+ 30 minutes'));
            $this->response = $this->response->withCookie($cookie);
            return $this->redirect(['controller' => 'Users', 'action' => 'index']);
        }
        $this->Flash->error(__('Invalid Token Provided. Please, try again.'));
    }
}

NOTE: Don’t forget to include the required import statements at the top of the file.

use App\Service\TwilioSMSVerificationService;
use Cake\Http\Cookie\Cookie;
use Cake\I18n\Time;

Our VerificationController is doing the following:

  1. Specify that we only want to accept GET and POST requests.  
  2. Get the phone number for the authenticated user.
  3. Create a new VerificationService using the authenticated user’s phone number
  4. If the request is a GET request, send a request for the token to be sent to the user and display a successful flash message.
  5. If the request is a POST request, get the token provided by the user and check if it is valid. If the token is valid, create a cookie named "2-fa-passed" with a value 1 and redirect the user to the list of users. Otherwise display an error flash message.

Next, create a template using the command below that will be rendered when the /verification route is hit.

$  mkdir templates/Verification && touch templates/Verification/index.php

Open the templates/Verification/index.php file and add the following code to it:

<?php
?>
<div class="users form">
    <?= $this->Flash->render() ?>
    <h3>Provide One Time Token</h3>
    <?= $this->Form->create() ?>
    <fieldset>
        <legend><?= __('Please provide the token that has been sent to your mobile number') ?></legend>
        <?= $this->Form->control('token', ['required' => true]) ?>
    </fieldset>
    <?= $this->Form->submit(__('Verify')); ?>
    <?= $this->Form->end() ?>
</div>

Next, we need to modify the logic for our login process. Currently, on successful login, we redirect to the list of users. We need to change this so that the user is redirected to the SMS verification page first. To do that, modify the UsersController::login() function to match the following:

public function login()
{
    $this->request->allowMethod(['get', 'post']);
    $result = $this->Authentication->getResult();

    // regardless of POST or GET, redirect if user is logged in
    if ($result->isValid()) {
        // redirect to /verification after login success
        $redirect = $this->request->getQuery('redirect', [
            'controller' => 'Verification',
            'action' => 'index',
        ]);

        return $this->redirect($redirect);
    }

    // display error if user submitted and authentication failed
    if ($this->request->is('post') && !$result->isValid()) {
        $this->Flash->error(__('Invalid username or password'));
    }
}

We also need to modify the UsersController::logout() function to match the following:

public function logout()
{
    $result = $this->Authentication->getResult();
    // regardless of POST or GET, redirect if user is logged in
    if ($result->isValid()) {
        $this->Authentication->logout();
        //this will force the user to verify again if they want to log in again
        $cookie = new Cookie('2-fa-passed', '0', new Time('+ 30 minutes'));
        $this->response = $this->response->withCookie($cookie);
        return $this->redirect(['controller' => 'Users', 'action' => 'login']);
    }
}

Here we set the 2-fa-passed cookie to 0 so that if the user tried to log in again, the application would still require verification from the user.

NOTE: Don’t forget to include the required import statements at the top of the file.

use Cake\Http\Cookie\Cookie;
use Cake\I18n\Time;

With this in place, we can test our implementation. Fire up your server and try loading the list of users again by going to http://localhost:8765/users. If you’re not redirected to the login page, then logout and login again. This time you’ll be redirected to the token verification page and an SMS will be sent to your phone number. Type in the token sent via SMS and you will be redirected to the list of users.

Secure the link between both layers

Everything works nicely but there’s one small problem. Try logging out and logging in again. When you’re redirected to the token verification page, try loading the list of users at http://localhost:8765/users instead of typing in the token.

You’ll notice that the list of users is loaded. This is because, at the moment, our application only checks that the user is authenticated via the login form. We need an extra check to ensure that the 2-fa-passed cookie is set to 1 as well. If the 2-fa-passed cookie isn’t set to 1, our application should redirect the user back to the token verification page.

To do that, we’ll create a custom middleware using the following command:

$ bin/cake bake middleware SMSVerification

This creates a class called SMSVerificationMiddleware.php in the src/Middleware directory. Update the src/Middleware/SMSVerificationMiddleware.php file to match the following:

<?php
declare(strict_types=1);

namespace App\Middleware;

use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class SMSVerificationMiddleware implements MiddlewareInterface
{
    private array $permittedRoutes = [
        '/verification',
        '/users/login',
        '/'
    ];

    /**
     * Process method.
     *
     * @param ServerRequestInterface $request The request.
     * @param RequestHandlerInterface $handler The request handler.
     * @return ResponseInterface A response.
     */
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface
    {
        $path = $request->getUri()->getPath();
        $cookieParams = $request->getCookieParams();
        if ($this->shouldRedirectToVerificationController($path, $cookieParams)) {
            return new RedirectResponse('/verification');
        }
        return $handler->handle($request);
    }

    private function shouldRedirectToVerificationController(
        string $path,
        array $cookieParams
    ): bool
    {
        $hasValidVerificationCookie = isset($cookieParams['2-fa-passed']) ?
            $cookieParams['2-fa-passed'] === '1' : false;
        $isAllowedRoute = in_array($path, $this->permittedRoutes);
        return !($isAllowedRoute || $hasValidVerificationCookie);
    }
}

Our middleware needs to implement the process function in order to fully comply with the contract of the MiddlewareInterface. This function contains the logic we want our middleware to execute - in our case, to ensure that the user has provided a valid One Time Token. If the user has not, then we redirect the user back to the token verification page. Otherwise, we allow the next middleware to handle the request.

To determine whether or not to redirect, we call the shouldRedirectToVerificationController function. This function takes the path of the request and the cookies in the request header and checks for two things:

  1. $hasValidVerificationCookie: If the request has a valid verification cookie i.e. a cookie named 2-fa-passed which is set to 1.
  1. $isAllowedRoute: If the user is trying to access a permitted route. A permitted route in this case is a route that can be accessed even though the user has not provided a token. In this case, they are the login page, the verification page itself, and the index page.

If any of the above two conditions are met, we do not need to redirect, hence the shouldRedirectToVerificationController returns the negated result of a logical OR operation between $hasValidVerificationCookie and $isAllowedRoute.

Next, we need to register the SMSVerificationMiddleware. To do this, add the following to the middleware  function in src/Application.php, after Authentication:

->add(new SMSVerificationMiddleware())

NOTE: Don’t forget to include the required import statements at the top of the file.

// Add the following import
use App\Middleware\SMSVerificationMiddleware;

Try bypassing the verification stage again and you’ll see that you’re redirected to the token verification page - and the verification sent to your phone number again.

Conclusion

In this article, we implemented a two-factor authentication mechanism for a CakePHP application. Using the CakePHP Authentication plugin, we were able to verify the email and password combination for a registered user. We added another layer of security by taking advantage of Twilio’s Verify API to send a One Time Token to the registered user’s phone number. By doing this, we prevent the hassle of handling token generation and dispatch,  instead relying on the infrastructure provided by Twilio. This also allowed us to take advantage of the security features provided by the API with regard to token generation and dispatch.

The entire codebase for this tutorial is available on GitHub. Feel free to explore further. Happy coding!

Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest to solve day-to-day problems encountered by users, he ventured into programming and has since directed his problem-solving skills at building software for both web and mobile. A full-stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and content on several blogs on the internet. Being tech-savvy, his hobbies include trying out new programming languages and frameworks.