Integrate Two-Factor Authentication into a Laravel Application with Laravel Breeze, Livewire, Alpine.js, and Twilio Verify

May 12, 2023
Written by
Anumadu Udodiri Moses
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Integrate Two-Factor Authentication into a Laravel Application

Real world applications require authentication, such as passwords, to secure them and restrict access to authenticated users only. Two Factor Authentication (2FA) goes even further by adding an extra layer of security to your regular authentication system, such as a code generated by an application or physical token such as a Yubikey.

In this article we will implement Two Factor Authentication in a Laravel application, by modifying the Laravel Breeze scaffolded authentication system to use Twilio Verify. We will also create middleware to enforce 2FA.

Prerequisites

To follow along with this tutorial you will need:

Create a Laravel project

Let's get started by creating a new Laravel project using Composer and changing into the newly generated project by running the commands below.

composer create-project laravel/laravel twilio_2fa
cd twilio_2fa

Install Laravel Breeze

Next, install Laravel Breeze using the command below.

composer require laravel/breeze --dev

Once the Breeze installation is complete, scaffold the authentication for the application using the following commands.

php artisan breeze:install
php artisan migrate
npm install
npm run dev

When prompted, answer the questions as follows:

Which stack would you like to install?
  blade ............................................................................................................... 0  
  react ............................................................................................................... 1  
  vue ................................................................................................................. 2  
  api ................................................................................................................. 3  
❯ blade

Would you like to install dark mode support? (yes/no) [no]
❯ no

Would you prefer Pest tests instead of PHPUnit? (yes/no) [no]
❯ no

Now, in a separate terminal, launch your project using the following command.

php artisan serve

With the application running, in your preferred browser, open http://localhost:8000, where it will look like the screenshot below.

The default Laravel route

The application may not be listening on port 8000, if there is a service already listening on that port.

Configure the required environment variables

The application needs access to the database. To set that up, in your .env file add a database name, username, and password by setting values for DB_DATABASE, DB_USERNAME and DB_PASSWORD, like in the following example.

DB_DATABASE=twilio_2fa
DB_USERNAME=root
DB_PASSWORD=

Now, run your migration to complete the authentication scaffolding.

php artisan migrate

Edit the user registration form

The registration flow needs to include a phone number field. So let's modify the current Laravel Breeze scaffolded user registration form that already contains fields for a username, email, and password, to include it.

In resources/views/register.blade.php, add the following code after the email field.

<!-- Phone number -->
<div class="mt-4">
    <x-input-label for="phone_number" :value="__('Phone number')" />
    <x-text-input id="phone_number" class="block mt-1 w-full" type="number" name="phone_number" :value="old('phone_number')" required  />
    <x-input-error :messages="$errors->get('phone_number')" class="mt-2" />
</div>

We also need to modify the existing migration file to save the user phone number. Open database/migrations/2014_10_12_000000_create_users_table.php and update the up() function to match the following code:

public function up(): void
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->timestamp('email_verified_at')->nullable();
        $table->string('phone_number');
        $table->boolean('phone_verified')->default(false);
        $table->string('password');
        $table->rememberToken();
        $table->timestamps();
    });
}

The changes add two new columns, phone_number which is nullable  ($table->string('phone_number')->nullable();) and phone_verified ($table->boolean('phone_verified')->default(false);). The phone_number field saves the provided phone number, while the phone_verified will be used later to implement our middleware to enforce the phone verification.

Now, we need to do some refactoring to validate and save the phone number. Modify app/Http/Controllers/Auth/RegisteredUserController.php file to match the code below. These changes add a validation for the phone field and a redirection to the phone validation screen.

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;

class RegisteredUserController extends Controller
{
    /**
     * Display the registration view.
     */
    public function create(): View
    {
        return view('auth.register');
    }

   /**
    * Handle an incoming registration request.
    *
    * @throws \Illuminate\Validation\ValidationException
    */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class],
            'phone_number' => ['required', 'numeric', 'min:10'],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

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

        event(new Registered($user));

        Auth::login($user);

        return redirect(route('verify.phone'));
    }
}

Next, edit the store() method in app/Http/Controllers/AuthenticatedSessionController.php to redirect users to the phone verification screen after login, like so.

public function store(LoginRequest $request): RedirectResponse
{
    $request->authenticate();
    $request->session()->regenerate();

    return redirect(route('verify.phone'));
}

We also need to add the phone_number to the mass assignable properties in the User model, like so.

protected $fillable = [
    'name',
    'email',
    'phone_number',
    'password',
];

After a successful registration or login, the user will now be redirected to the verify.phone route. So let's create that. Since we will be using Laravel Livewire for verification, let's first install it, by running the following command:

composer require livewire/livewire

If you're new to Livewire, the Livewire documentation is a good place to start. 

Next, we need to add the Livewire Blade directives to our layout file resources/views/guest.blade.php and resources/views/app.blade.php like so.

<html>
<head>
    ...
    @livewireStyles
</head>
<body>
    ...
    @livewireScripts
</body>
</html>

Next, create a Livewire controller and view using the Artisan command below.

php artisan make:livewire PhoneNumberVerify

The command will generate two files, app\livewire\PhoneNumberVerify.php and view\livewire\phone-number-verify.blade.php. The PhoneNumberVerify.php file contains a render() method that returns the view phone-number-verify.blade.

Create a route for the phone number verification in routes/web.php route file like so.

Route::prefix('/verify')->group(function () {
    Route::get('/phone', App\Http\Livewire\PhoneNumberVerify::class)->name('verify.phone');
});

Install the Twilio PHP Helper Library

We need to install the Twilio PHP Helper Library that lets us interact with Twilio's Verify API. We can install it using the following command.

composer require twilio/sdk

One more thing. You need to create a Twilio Verify service and retrieve its service SID.

To create a Twilio Verify service, you first need to sign up for a Twilio account if you haven't already. After signing in to your account, navigate to the Twilio Verify Console and click on the "Create Service" button.

From there, you can configure your service by giving it a name, choosing the verification methods you want to use (such as SMS, voice, or email), and setting the duration for which verification codes will remain valid.

A Twilio Verify Service"s Settings

The Twilio PHP Helper Library will need three credentials to work successfully, your Twilio Account SID and Auth Token, as well as the Verify SID which you just created.

You can find your Twilio Account SID and Auth Token in your Twilio Console once you login.

The Twilio Account Info panel

Add the following to .env file. Then, replace the placeholders with the respective values.

TWILIO_ACCOUNT_SID=<TWILIO_ACCOUNT_SID>
TWILIO_AUTH_TOKEN=<TWILIO_AUTH_TOKEN>
TWILIO_VERIFICATION_SID=<TWILIO_VERIFICATION_SID>

Next, let’s create two methods in our Livewire controller. One for sending the OTP (One Time Password) and the other for verification. Add the following to our Livewire controller views\livewire\PhoneNumberVerify.php.

<?php

namespace App\Http\Livewire;

use App\Models\User;
use Livewire\Component;
use Twilio\Rest\Client;
use Illuminate\Support\Facades\Auth;

class PhoneNumberVerify extends Component
{
    public $code = null;
    public $error;

    public function mount()
    {
        $this->sendCode();
    }

    public function sendCode()
    {
        try {
            $twilio = $this->connect();
            $verification = $twilio->verify
                ->v2
                ->services(getenv("TWILIO_VERIFICATION_SID"))
                ->verifications
                ->create("+country_code".str_replace('-', '', Auth::user()->phone_number), "sms");

            if ($verification->status === "pending") {
                session()->flash('message', 'OTP sent successfully');
            }
        } catch (\Exception $e) {
            $this->error = $e->getMessage();
        }
    }

    public function verifyCode()
    {
        $twilio = $this->connect();
        try {
            $check_code = $twilio->verify
                ->v2
                ->services(getenv('TWILIO_VERIFICATION_SID'))
                ->verificationChecks
                ->create(
                    [
                        "to" => "+country_code" . str_replace('-', '', Auth::user()->phone_number),
                        "code" => $this->code
                    ]
                );

            if ($check_code->valid === true) {
                User::where('id', Auth::user()->id)
                    ->update([
                        'phone_verified' => $check_code->valid
                    ]);
                return redirect(route('dashboard'));
            } else {
                session()->flash('error', 'Verification failed, Invalid code.');
            }
        } catch (\Exception $e) {
            $this->error = $e->getMessage();
            session()->flash('error', $this->error);
        }
    }

    public function connect()
    {
        $sid = getenv("TWILIO_ACCOUNT_SID");
        $token = getenv("TWILIO_AUTH_TOKEN");
        $twilio = new Client($sid, $token);
        return $twilio;
    }

    public function render()
    {
        return view('livewire.phone-number-verify');
    }
}

We created two methods: sendCode() and verifyCode(). As the names suggest, they send and verify the code generated by Twilio Verify. We also created a connect() method. This method instantiates the Twilio client and returns the connection.

In the mount() Livewire hook, we called the sendCode() method. This fires the method and sends the SMS once the page is ready. The phone number is retrieved from the user supplied phone number.

In the verifyCode() function, we verified the phone number by passing the verification code and the user phone number to the Twilio Verify endpoint. We also updated the phone_verified column in the User table which has a default value of false to true once verification is successful. Our middleware will check this value to enforce 2FA.

Next, let's flesh out our verification Blade file by adding the following to resources/views\livewire\phone-number-verify.blade.php.

<x-slot name="header">
    <h2 class="font-semibold text-xl text-gray-800 leading-tight">
        {{ __('Verify OTP Code') }}
    </h2>
</x-slot>
<div class="py-12">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
        <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <div class="p-6">
            @if (session()->has('error'))
            <x-auth-session-status 
                    class="mb-4 bg-red-500 text-white" 
                    :status="session('error')" />
            @elseif(session()->has('message'))
                <x-auth-session-status 
                        class="mb-4 bg-green-500 text-white" 
                        :status="session('message')" />
            @endif
            <div class="bg-gray-200 p-10">
                <form wire:submit.prevent="verifyCode">
                    <!-- Enter Verification code-->
                    <div>
                        <x-input-label for="code" :value="__('Enter code')" />
                        <x-text-input 
                                wire:model="code" 
                                type="number" 
                                class="block mt-1 w-full"  
                                required autofocus />
                        <x-input-error :messages="$errors->get('code')" class="mt-2" />
                    </div>
                    <div class="flex items-center justify-end mt-4"> 
                        <x-primary-button class="ml-3" type="submit">
                            {{ __('Verify code test') }}
                        </x-primary-button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

We created a form that calls the verifyCode() method on submit and binds the phone number to the Livewire public $code value. This verifies and redirects the user to the dashboard.

Migration

Next, let's run a fresh migration and test our code like so.

php artisan migrate:fresh

At this point, your registration form (http://localhost:8000/register) should look like the image below.

The Laravel registration form

And after registering a new user, you should be redirected to the phone number verify page, as in the screenshot below.

The form to enter the 2FA code

And, if verification was successful, you should be redirected to your application dashboard, like so.

Redirected to the user dashboard after verifying the code.

Hide error messages with Alpine.js

We have made great progress already. Now, let's spice things up a little using Alpine.js' hide and show directives.

At the top of resources/views/livewire/phone-number-verify.blade.php, you will find a Blade component for displaying session() messages.

<x-auth-session-status class="mb-4 bg-red-500 text-white" :status="session('error')" />

Right now, error messages do not disappear until the page is reloaded. Let's fix that. Open resources/views/livewire/auth-session-status.blade.php file and update it to match the following code.

@props(['status'])

@if ($status)
<div x-data="{ 'show':true }">
    <div x-show="show" {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600 p-4 ml-2 flex justify-between']) }}>
        {{ $status }}
        <div class="grid grid-cols-1 gap-4 place-items-center p-2 rounded-full bg-gray-500" @click="show = false">
            <button> X </button>
        </div>
    </div>
</div> 
@endif

show' is set to true letting errors be displayed, if any. On click of the x button the show is set to false` resulting in hiding of the error message component.

Add middleware to enforce verification

To wrap it all up, let’s create a middleware class to enforce 2FA.

If you're new to middleware in Laravel, check out this blog post to get started. For a more deeper dive, check the laravel official documentation here.

Create the core of the new middleware using the following command:

php artisan make:middleware PhoneVerify

Next, we need to register our middleware in app\Http\Kernel.php. Add the path to our middleware to the protected $middlewareAliases array like so:

...

protected $middlewareAliases = [
    ...
    'phone_verify' => \App\Http\Middleware\PhoneVerify::class,
];

...

Next, let's add the logic to enforce a redirect unless a user verifies their phone number, by updating PhoneVerify.php to match the following.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;

class PhoneVerify
{
   public function handle(Request $request, Closure $next): Response
   {
       if (Auth::user()->phone_verified === false) {
           return redirect(route('verify.phone'));
       }
       return $next($request);
   }
}

The handle() method redirects a user to the phone verify page unless the phone_verified property is set to true.

Next, let's add this middleware to the dashboard route in the routes/web.php file.

...

Route::get('/dashboard', function () {
   return view('dashboard');
})->middleware(['auth', 'verified', 'phone_verify'])->name('dashboard');
…

This route ensures that only users with verified phone numbers can access the dashboard.

We also need to set phone_verified to false when a user logs out, to enable our middleware function properly. Update the destroy() method in app\Http\Controllers\AuthenticatedSessionController.php, to match the following code.

public function destroy(Request $request): RedirectResponse
{
    \App\Models\User::where('id', Auth::user()->id)
        ->update([
            'phone_verified' => false
        ]);

    Auth::guard('web')->logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect('/');
}

So here you have it. A 2FA integration built on Laravel Breeze.

Test the application

To test your 2FA application you need to do the following:

  • Serve up your application using php artisan serve
  • Navigate to /login or ‘/register` route
  • Create an account or login to an already existing account
  • After a successful registration or login, you should be redirected to a 2FA page where you will be prompted to enter your 2FA code
  • At this point, code should be sent to your phone number
  • If the right code is entered, you should be redirected to the dashboard of the application. If the wrong code is entered, you will receive an error message telling you the code is incorrect
  • You should not be able to access the dashboard without verifying with the 2FA code.

Conclusion

Awesome, we have come to the end of this tutorial where we integrated Two Factor Authentication into Laravel Breeze using Twilio Verify. We modified the Laravel Breeze registration form to include a phone number, verified the phone number and saved it in our database. We also sent and verified OTP to the provided phone number using Laravel Livewire. Finally, we created a middleware to enforce Two Factor Authentication.

To get more hands-on, try to implement the resend feature then you can go build something great. Here is a link to the GitHub repo.

I also made a short demo video of the application. Do check it out here

Moses Anumadu is a software developer and online educator who loves to write clean, maintainable code. I create technical content for technical audiences. You can find me here.

"Security" by Steven Penton is licensed under CC BY 2.0.