Authenticate Laravel Users Using JWTs and Twilio's WhatsApp Business API

February 23, 2021
Written by
Anumadu Udodiri Moses
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Authenticate Laravel Users Using JWTs and Twilio's WhatsApp Business API

Web applications need a way of identifying users in order to serve dynamic data back to users. The process of collecting and storing data when a user registers, validating the data and recognizing a unique user when the user logs in is the authentication flow.

As mankind continues to evolve, cybercrimes continue to increase by the day. The security of user’s data and their privacy becomes more important than ever. In the quest to achieve this, security measures such as the popular 2-step verification (2FA verification) are put in place.

Implementing 2FA (Two-Factor Authentication) verification in modern web applications can be tricky. Nonetheless, do not worry as the Twilio API for WhatsApp has plenty of documentation and support to make this possible for developers.

This tutorial is intended to teach you about the Twilio API for WhatsApp and custom token-based authentication in Laravel using JSON Web Tokens (JWTs). By the end of the tutorial you will have developed:

  • A simple Laravel application/API with a complete user authentication flow
  • A 2FA authentication layer that sends verification codes to users using WhatsApp
  • Tests for the code/API endpoints using Postman

Prerequisites

To follow along with this tutorial you should have the following:

  • A Twilio account
  • Composer globally installed
  • Basic knowledge of PHP and the Laravel framework

Create a new Laravel project

Let's get started building our Laravel application by using this Composer command:

$ composer create-project --prefer-dist laravel/laravel twilioLaravel

If you have the Laravel installer installed, you can use the following command:

$ laravel new twilioLaravel

Regardless of the command you used, a new Laravel project will have been created in twilioLaravel. Switch to that folder by running cd twilioLaravel. Then, install the tymon-jwt package for Laravel by using the following Composer command:

$ composer require tymon/jwt-auth:dev-develop --prefer-source

NOTE: At the time of publication, there is a problem with the production version of the package. Therefore, we installed the development version.

Now we have to make a few important changes to get jwt-auth to work properly with our application. Add the following declaration to the Package Service Providers… section of the providers array, in your config/app.php file:

 

...]
    Tymon\JWTAuth\Providers\LaravelServiceProvider::class

Now add the following Facades to the aliases array in config/app.php:

...]
    'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class

With those changes made, let’s publish the config file for JWT with the following Artisan command:

$ php artisan vendor:publish \
    --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

Now set the jwt-auth secret using the following Artisan command:

$ php artisan jwt:secret

At this point, we can serve our project and view it in our browser. This will allow us to verify that the project setup worked correctly. Serve it using the following command:

$ php artisan serve

Our server should start up on port 8000 and return the following message:

Starting Laravel development server:  http://127.0.0.1:8000

If we open http://127.0.0.1:8000 in a browser, we should see the default Laravel home page, as in the example below.

The Laravel home page (in version 8)

Set up the database and run migrations

It's time to set up our database and run migrations in our Laravel application. But before you can do that, you have to set your database credentials in .env. We are using a MySQL database. Feel free to give your database a different name from mine, if you’d like.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=twiliowhatsappapi
DB_USERNAME=root
DB_PASSWORD=*******

We will need an additional column added to the user table to contain the one-time password. In our migrations folder, add the following declaration in database/migrations/2014_10_12_000000_create_users_table.php file.

$table->string('otp')->nullable();

You can now run your migration using the following Artisan command:

$ php artisan migrate

Create the controllers and models

Now we have our application up and running. We need to make a few changes to both the User model and the authentication controllers to get tymon-jwt up and running. Let's get started with the User model. Modify app/Models/User.php  as shown in the following code sample:

<?php

namespace App;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

The getJWTidentifier() and getJWTCustomClaims() methods were added to the User model (user.php) to enable jwt to work properly. With those changes made, let us proceed by creating an authentication controller in Laravel using the following command:

$ php artisan make:controller UserController

Next, edit the newly created UserController as shown in the following code sample below. Feel free to copy and paste this to your controller, located at app/Http/Controllers/UserController.php.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;    
use Tymon\JWTAuth\Facades\JWTAuth;
use Auth;
use Tymon\JWTAuth\Exceptions\JWTException;

class UserController extends Controller
{
    public function authenticate(Request $request)
    {
        $credentials = $request->only('email', 'password');

        try {
            if (! $token = JWTAuth::attempt($credentials)) {
                return response()->json(['error' => 'invalid_credentials'], 400);
            }
        } catch (JWTException $e) {
            return response()->json(['error' => 'could_not_create_token'], 500);
        }

        return response()->json(compact('token'));
    }

    public function register(Request $request)
    {
        $validator = Validator::make(
            $request->all(), 
            [
                'name' => 'required|string|max:255',
                'email' => 'required|string|email|max:255|unique:users',
                'password' => 'required|string|min:6|confirmed',
            ]
        );

        if ($validator->fails()) {
            return response()->json($validator->errors()->toJson(), 400);
        }

        $user = User::create([
            'name' => $request->get('name'),
            'email' => $request->get('email'),
            'password' => Hash::make($request->get('password')),
        ]);

        $token = JWTAuth::fromUser($user);

        return response()->json(compact('user','token'),201);
    }

    public function getAuthenticatedUser()
    {
        try {
            if (! $user = JWTAuth::parseToken()->authenticate()) {
                return response()->json(['user_not_found'], 404);
            }
        } catch (TokenExpiredException $e) {
            return response()->json(['token_expired'], $e->getStatusCode());
        } catch (TokenInvalidException $e) {
            return response()->json(['token_invalid'], $e->getStatusCode());
        } catch (JWTException $e) {
            return response()->json(['token_absent'], $e->getStatusCode());
        }

        return response()->json(compact('user'));
    }
}

UserController now contains three methods: authenticate, register, and getAuthenticatedUser. The authenticate method logs the user in and generates a JWT token for the session. The register method, as the name implies, validates and creates a new user account. The getAuthenticatedUser method fetches the data of the logged-in user.

Set up routes for the process

We next need routes to test our application endpoints. To create our routes, open routes/api.php and modify the content as seen here:

Route::post('register', 'App\Http\Controllers\UserController@register');
Route::post('login', 'App\Http\Controllers\UserController@authenticate');

The routes we defined will give our application the ability to register users at http://127.0.0.1:8001/api/register and authenticate users to login at http://127.0.0.1:8001/api/login.

User registration using Postman

At this point, we should be able to test our application using an API testing tool such as Postman. Let’s use the app to register a new user. Open Postman and create a new POST request to http://127.0.0.1:8001/api/register. Select the Body tab, then the form-data option, and then provide the following parameters:

- name

- email

- password (it must be at least 6 characters long)

- password_confirmation

After adding the four parameters, click Send to submit the request. User registration should return a JSON response containing the user’s details and an auth token, like the image below:

Test and endpoint with Postman

User login using Postman

Let's continue our testing by logging in the newly created user using Postman. Create a new POST request to http://127.0.0.1:8001/api/login and supply the following parameters:

- email

- password

Test a login request with Postman

A successful login will return the user’s token as shown in the previous image.

Twilio API for WhatsApp

I’m glad you made it this far as the project is halfway done. We have successfully implemented the Tymon JWT authentication library and created two endpoints for user login and registration. Now it's time to implement 2FA verification into our application using the Twilio API for WhatsApp.

WhatsApp Sandbox setup

You need a verified phone number to interact with the WhatsApp API. This verification process can take days, but in the meantime the WhatsApp sandbox provides an avenue for developers to test their code while awaiting number verification.

To set up the Twilio WhatsApp Sandbox:

- Log in to your Twilio console (Create an account if you do not have one)

- Open Twilio Sandbox for WhatsApp

- Send the displayed code to the WhatsApp Sandbox number provided

In most cases, this number is often the same. The code has a unique format of "join-{unique word}".

Setup your mobile number with the Twilio WhatsApp Sandbox

If the code was successfully received by the Sandbox, you should see a reply like the following:

Successfully setup a mobile number with the Twilio WhatsApp Sandbox

We next need to update our .env file to add all relevant Twilio credentials. Add the following values to your .env:

TWILIO_SID="AC8*******************8d"
TWILIO_AUTH_TOKEN="51******************50"
TWILIO_WHATSAPP_NUMBER= "+14155238886" 

The TWILIO_WHATSAPP_NUMBER is the Sandbox number. Other details above can be found in your Twilio Console settings, as the following image shows.

Retrieve Twilio API credentials

Send a 2FA verification code to WhatsApp

We now need to install the Twilio PHP SDK  for Laravel. Composer will allow us to install it within our project. To do so, run the following command in the project directory:

$ composer require twilio/sdk

In order to keep things a little more organized, let’s create a Trait in our Laravel application that sends out WhatsApp messages. In the app directory of your application, create a folder called Traits. Then, create a new file in the directory and name it WhatsAppMessageTrait.php. You can call your file something else if you would like.

Let us update our newly created Trait file to send WhatsApp messages.

<?php
    
namespace App\Traits;
use GuzzleHttp\Exception\RequestException;
use Twilio\Rest\Client;
    
trait WhatsAppMessageTrait {
    /**
     * Send 2FA verification WhatsApp message
     * @param string $recipient
     * @param string $code
     */
    public function sendOtp($recipient, $code){
        $sid    = getenv("TWILIO_SID"); 
        $token  = getenv("TWILIO_AUTH_TOKEN"); 
        $twilio = new Client($sid, $token); 
        $from = getenv("TWILIO_WHATSAPP_NUMBER");
            
        $message = $twilio
            ->messages 
            ->create("whatsapp:".$recipient, // to 
                [ 
                    "from" => "whatsapp:".$from,       
                    "body" => "Your OTP verification code is ". $code 
                ] 
            ); 
            
        return true;
    }
        
    public function generateCode(){
        return mt_rand();
    }      
}

The sendOtp() method fires off the message using data from the .env file and those provided within the $message variable which holds the message. Now we can use our WhatsAppMessageTrait in our UserController for our 2FA verification. To do that, modify the authenticate method in the UserController so that we can receive a one-time verification code in our sandbox to enable us to log in, by adding the following declaration to the header:

use \App\Traits\WhatsAppMessageTrait;  

Then, replace the contents of the authenticate method with the following code in the UserController class:

public function authenticate(Request $request)
{
    $credentials = $request->only('email', 'password');
    try {
        if (! $token = JWTAuth::attempt($credentials)) {
            return response()->json(
                ['error' => 'invalid_credentials'],
                400
            );
        }
        try {
            $code = $this->generateCode();
            $this->saveOtpCode($code);
            $this->sendOtp(env('APP_USER_PHONE_NUM'), $code);
        } catch (JWTException $e) {
            return response()->json(['error sending opt', 500]);
        }
    } catch (JWTException $e) {
        return response()->json(['error' => 'could_not_create_token'], 500);
    }
    return response()->json(
        [
            "message" => "OTP sent to WhatsApp successfully. Verify to continue.",
            "token" => $token
        ],
        200
    );
}

In my case here, notice that the recipient phone number is coming from the .env file. Modify your .env file and add the phone number as an environment variable like mine below.

APP_NAME="Laravel"
APP_USER_PHONE_NUM="(+1) XXX-XXX-XXXX"

In a real world application, the phone number would most likely be received through an input form. Feel free to tweak this to suit your exact needs. The sendOtp method is coming from our trait. The saveOtpCode method is a protected method that saves the generated OTP code in the user table. The generateCode method can be found in our trait. We can add the saveOtpCode method by modifying our UserController and adding the following:

protected function saveOtpCode($code)
{
    $user = Auth::user();
    $user->otp = $code;
    $user->save();
}

The Verify method

We need another method and route to verify the code sent and received. Create verifyOtp method in UserController using the following code:

protected function verifyOtp(Request $request) {
    $savedCode = User::where('email', $request->email)->first();
    if ($savedCode->otp === $request->code) {
        //todo get authenticated user here 
        return response()->json("Account Verified", 200); 
    }
    return response()->json(
        ['message'=> 'Invalid OTP Provided'], 
        500
    );
}

Also, add the following to the API route to routes/api.php:

Route::post('verifyOtp', 'App\Http\Controllers\UserController@verifyOtp');

Testing

Let us proceed to test our application using Postman once more. Re-submit the login request that you previously submitted.

Retrieve an OTP using Postman

Confirm that the OTP is received in WhatsApp.

OTP sent to WhatsApp

To verify the OTP code and complete the authentication flow, we need to call the verifyOtp route. We can test this in Postman too by submitting the OTP verification code and the user’s email address in a form. In my case my OTP verification code is 956477095. The image below shows the verification process using Postman.

Test OTP using Postman

Conclusion

I am glad you made it this far. By now, you should:

  • Have a firm understanding of how tymon/jwt-auth works in Laravel
  • Have set up the WhatsApp Sandbox for Twilio
  • Be able to consume the Twilio WhatsApp API for 2FA authentication
  • Have created a token-based authentication system using Laravel, JWT, and the Twilio API for WhatsApp
  • Have tested the app with Postman

Here’s a link to the complete project on GitHub.

Bio:

My name is Anumadu Udodiri Moses. I am a software developer and a technical content creator in the PHP and JavaScrip ecosystem. I run a gaming community called Tekiii. You can reach out to me on LinkedIn.