Secure Sensitive Laravel Routes With Two-factor Authentication Using Authy

Sometimes, you want your application to confirm user identities even when they are logged in. This is especially useful for sensitive routes and actions like deleting a user-owned resource, updating a delivery address, or completing a financial transac…

Sometimes, you want your application to confirm user identities even when they are logged in. This is especially useful for sensitive routes and actions like deleting a user-owned resource, updating a delivery address, or completing a financial transaction where you want to be sure that the user’s session hasn’t been hijacked. This process is called re-authentication and is supported by the Laravel framework out of the box with the password.confirm middleware.

In this tutorial, we will implement a new Laravel middleware that asks users to verify themselves before allowing them to access select routes. Our sample application is a notes application where we need to confirm a user’s identity before they can delete an existing note. The verification is done using a code sent to their Authy application, though you can replace that with a regular SMS if you so chose.

Jump directly to the Implement the Verification Middleware section below, to see how the middleware is implemented if you’re keen.

To follow along with this tutorial, you will need the following:

Get Started

To get started with our sample application, create a new Laravel application in your preferred location, and enter the project folder with the commands below:

$ laravel new authy-reauth && cd authy-reauth

NOTE: The commands above (and subsequent terminal commands assume a Linux or macOS system. Feel free to use your operating system’s equivalent if you are using Microsoft Windows).

Next, create a new MySQL database named reauth and update the project’s .env file to use by updating the DB_DATABASE as in below.

DB_DATABASE=reauth

You may need to create the env file manually by copying .env.example if it wasn’t automatically generated.

We will modify the auto-generated migrations file for the users table to include the fields needed by Authy to verify our users. These columns include country_code for the user’s country code, phone_number for the user’s phone number, authy_id to help Authy identify individual users, and is_verified to tell us if a user has Authy set up with their account.

To do that, open the user table migration file (database/migrations/2014_10_12_000000_create_users_table.php) and replace the up method with the code block below:

public function up()
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->string('password');
        $table->string('country_code');
        $table->string('phone_number');
        $table->string('authy_id');
        $table->boolean('is_verified')->default(false);
        $table->rememberToken();
        $table->timestamps();
    });
}

Next, make the new fields mass assignable by adding them to the $fillable array in the User model. Open the model file at app/Models/User.php and replace the $fillable variable with the following:

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

When the array’s been updated, apply the updated migrations by running php artisan migrate from the project folder.

Scaffold the Application UI

Next, we will bring in the laravel/ui package and use it as the base for our application interface. It also provides us with the logic and views needed to register and log a user in, which we will then modify for our use case. Set up the package by running the commands below:

$ composer require laravel/ui
$ php artisan ui vue --auth
$ npm install && npm run dev

Next, open the registration form template created from the artisan command above (resources/views/auth/register.blade.php) and add the fields for a phone number and country code by replacing its contents with the code below. Don't forget to add your country code to the country_code field, if it's not already listed.

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Register') }}</div>
                    <div class="card-body">
                        <form method="POST" action="{{ route('register') }}">
                            @csrf

                            <div class="form-group row">
                                <label for="name" class="col-md-4 col-form-label text-md-right">{{ __('Name') }}</label>
                                <div class="col-md-6">
                                    <input id="name" type="text" class="form-control @error('name') is-invalid @enderror" 
                                        name="name" value="{{ old('name') }}" required autocomplete="name" autofocus>
                                    @error('name')
                                        <span class="invalid-feedback" role="alert">
                                            <strong>{{ $message }}</strong>
                                        </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row">
                                <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>
                                <div class="col-md-6">
                                    <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" 
                                        name="email" value="{{ old('email') }}" required autocomplete="email">
                                    @error('email')
                                        <span class="invalid-feedback" role="alert">
                                            <strong>{{ $message }}</strong>
                                        </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row">
                                <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>
                                <div class="col-md-6">
                                    <input id="password" type="password" class="form-control @error('password') 
                                        is-invalid @enderror" name="password" required autocomplete="new-password">
                                    @error('password')
                                        <span class="invalid-feedback" role="alert">
                                            <strong>{{ $message }}</strong>
                                        </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row">
                                <label for="password_confirmation"
                                       class="col-md-4 col-form-label text-md-right">{{ __('Password Confirmation') }}</label>
                                <div class="col-md-6">
                                    <input id="password_confirmation" type="password"
                                           class="form-control @error('password_confirmation') is-invalid @enderror"
                                           name="password_confirmation"
                                           required autocomplete="new-password">
                                    @error('password_confirmation')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row">
                                <label for="country_code" class="col-md-4 col-form-label text-md-right">{{ __('Country Code') }}</label>
                                <div class="col-md-6">
                                    <select id="country_code" name="country_code" class="form-control @error('country_code') is-invalid @enderror">
                                        <option value="">Country Code</option>
                                        <option value="+234">Nigeria (+234)</option>
                                        <option value="+234">United States (+1)</option>
                                        <option value="+234">United Kingdom (+44)</option>
                                    </select>
                                    @error('country_code')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row">
                                <label for="phone_number" class="col-md-4 col-form-label text-md-right">{{ __('Phone Number') }}</label>
                                <div class="col-md-6">
                                    <input id="phone_number" type="text" name="phone_number" class="form-control @error('phone_number') 
                                        is-invalid @enderror" name="email" value="{{ old('email') }}" required>
                                    @error('phone_number')
                                    <span class="invalid-feedback" role="alert">
                                            <strong>{{ $message }}</strong>
                                        </span>
                                    @enderror
                                </div>
                            </div>

                            <div class="form-group row mb-0">
                                <div class="col-md-6 offset-md-4">
                                    <button type="submit" class="btn btn-primary">
                                        {{ __('Register') }}
                                    </button>
                                </div>
                            </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Register Users on Authy after Signup

To use Authy in our project, we need to install the authy php package. However, before we can do that, we need to update composer.json. This is because, at the time of writing, the latest stable version of the authy php package requires GuzzleHTTP 6, whereas the latest stable version of GuzzleHTTP is 7.2.0.

So in composer.json set minimum-stabilityto rcand prefer-stableto true, as in the example below:

    "minimum-stability": "rc",
    "prefer-stable": false,

Then, install authy/php with the command below:

composer require authy/php

Next, retrieve your Authy key from your Twilio console and add it to your .env file as shown below:

AUTHY_SECRET=XXXXXXXXXXXXXXXXXXXX

We will also update the user registration logic to validate user’s phone numbers and register new users with our Authy account. Open the RegisterController (app/Http/Controllers/Auth/RegisterController.php) and replace the validator method with the code below so that it also checks for country code and a phone number.

/**
 * Get a validator for an incoming registration request.
 *
 * @param  array  $data
 * @return \Illuminate\Contracts\Validation\Validator
 */
protected function validator(array $data)
{
    return Validator::make($data, [
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'string'],
        'country_code' => 'required',
        'phone_number' => 'required'
    ]);
}

Similarly, update the create method of the same RegisterController to register each new user with Authy and save the generated authy_id.

/**
 * Create a new user instance after a valid registration.
 *
 * @param  array  $data
 * @return \App\Models\User
 */
protected function create(array $data)
{
    $user = new User([
        'name' => $data['name'],
        'email' => $data['email'],
        'password' => Hash::make($data['password']),
        'country_code' => $data['country_code'],
        'phone_number' => $data['phone_number']
    ]);

    $authy = new \Authy\AuthyApi(getenv("AUTHY_SECRET"));
    $authyUser = $authy->registerUser(
        $user->email,
        $user->phone_number,
        $user->country_code,
        true
    );

    if ($authyUser->ok()) {
        $user->authy_id = $authyUser->id();
    } else {
        $errors = [];
        foreach ($authyUser->errors() as $field => $value) {
            array_push($errors, $field.": ". $value);
        }
        Log::info(json_encode($errors, JSON_PRETTY_PRINT));
    }
    $user->save();

    return $user;
}

Start the server with php artisan serve and navigate to http://localhost:8000/register and register a new user. You will get a notification from the Authy app. If you don’t have the app installed, you will get an SMS notifying you of your registration, and a link to install the app.

Screenshot of Authy SMS notification

Setting up the Notes Table

With user authentication in place now, we will set up the Note model next. For brevity, we won’t be implementing a form for taking new notes and editing an existing one, instead, we will add a couple of notes to the database using seeders.

Migrations, Factories, and Seeds

Create the new Note model as well as the migration file for the notes table by running:

$ php artisan make:model Note -m

The command above creates Note.php in the app\Models directory and a migration file (with a name similar to 2020_09_09_191257_create_notes_table) in the database/migrations folder. Open the migrations file and add the needed fields by replacing its up method with the code below:

public function up()
{
    Schema::create('notes', function (Blueprint $table) {
         $table->id();
         $table->string('title');
         $table->mediumText('body');
         $table->unsignedBigInteger('user_id');
         $table->timestamps();
    });
}

Apply the new migration by running php artisan migrate. Next up, we will generate notes with dummy data using factories. Create a new note factory with php artisan make:factory NoteFactory. Open the factory file (at database/factories/NoteFactory.php) generated by the command and replace the contents of the definition function with the code below:

public function definition()
{
    return [
        'title' => $this->faker->sentence(10, true),
        'body' => $this->faker->text(250),
        'user_id' => rand(1, 3)
    ];
}

The code uses the faker library to create a new note. The note title is ten words or less and the body is around 250 words. We also randomized the user_id value so that all the generated notes don’t belong to a single user. To use the new NoteFactory, create a new seeder file by running php artisan make:seeder NoteSeeder. Open the file created by the command ( database/seeds/NoteSeeder) and replace the run method with the code below:

public function run()
{
    \App\Models\Note::factory(25)->create();
}

Then, open database/seeders/DatabaseSeeder.php and replace the run method with the code below:

public function run()
{
    $this->call([
        NoteSeeder::class,
    ]);
}

With those changes made, run the database seeders with php artisan db:seed and you will have 25 new notes added to your notes table.

Set up Routes and Controllers

We will modify the existing HomeController and make it able to render one or all the notes owned by the logged-in user, as well as delete an existing note with the given note ID. Open the file (app/Http/Controllers/HomeController.php) and replace its content with the code below:

<?php

namespace App\Http\Controllers;

use App\Models\Note;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;

class HomeController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    /**
     * Show the application dashboard.
     *
     * @return Renderable
     */
    public function index()
    {
        $notes = Note::where('user_id', auth()->user()->id)
            ->orderBy('id', "desc")
            ->get();
        $data = [
            'notes' => $notes
        ];
        return view('home', $data);
    }

    public function viewNote($noteId) 
    {
        $note = Note::find($noteId);
        if ($note->user_id != auth()->id()) {
            abort(404);
        }
        return view('note', ['note' => $note]);
    }

    public function deleteNote(Request $request) 
    {
        $note = Note::findOrFail($request->input('note_id'));
        if (auth()->user()->id != $note->user_id) {
            abort(404);
        }
        $note->delete();
        return redirect('/notes');
    }
}

The index method fetches all the notes whose user_id is the same as the authenticated user’s ID. It re-uses the home template (resources/views/home.blade.php) created earlier by Laravel. Open the home template and replace its content with the code below, so that it shows a list of user-owned notes.

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                @foreach ($notes as $note)
                    <div class="row">
                        <div class="col-md-12">
                            <h2><a href="{{route('note.view', $note->id)}}">{{$note->title}}</a></h2>
                            <div>
                                <span class="float-left">{{$note->created_at}}</span>
                                <div class="float-right">
                                    <form action="{{route('note.delete')}}" method="post">
                                        @csrf
                                        <input type="hidden" name="note_id" value="{{$note->id}}" />
                                        <button type="submit" href="{{route('note.delete', $note->id)}}"
                                            class="btn btn-sm btn-outline-dark m-2">Delete
                                        </button>
                                    </form>
                                </div>
                            </div>
                        </div>
                    </div>
                @endforeach
            </div>
        </div>
    </div>
@endsection

viewNote renders a single note and returns a 404 page if the note doesn’t exist or it is not owned by the authenticated user. It renders a note template that doesn’t exist yet. So create a note.blade.php file in resources/views and add the code below to it.

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <h2>{{$note->title}}</h2>
                <div>
                    {{$note->body}}
                </div>
            </div>
        </div>
    </div>
@endsection

deleteNote deletes an existing note and doesn’t need a separate template file since we already added the delete button for each note in the index method.

We now need to make the controller methods accessible from the browser by adding them to the web routes file (at routes/web.php) as shown.

Route::get('/notes', 'HomeController@index');
Route::get('/note/{noteId}', 'HomeController@viewNote')->name('note.view');
Route::post('/notes/delete', 'HomeController@deleteNote')
    ->name('note.delete')
    ->middleware('authy.verify');

You will notice that the delete route uses an additional authy.verify middleware and we will implement that next.

Implement the Verification Middleware

If you are not familiar with middleware, they are classes that filter requests and perform operations like checking for authentication/authorization, logging, and rate-limiting, on those requests. Here, we will be creating a verification middleware that proceeds with the request if the user has been verified within the past 10 minutes. Otherwise,  it  redirects them to the verification page. Create the verification middleware using the artisan command below:

php artisan make:middleware AuthyVerify

Open the created file, app/Http/Middleware/AuthyVerify.php, and replace its content with the code block below.

<?php

namespace App\Http\Middleware;

use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;

class AuthyVerify
{
    /**
     * How long to let the elevated authentication last
     */
    const VERIFICATION_TIMEOUT = 10;

    /**
     * Handle an incoming request.
     *
     * @param  Request  $request
     * @param  Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($this->needsVerification()) {
            return redirect('/auth/verify');
        }
        return $next($request);
    }

    public function needsVerification() {
        $verifiedAt = Carbon::createFromTimestamp(session('verified_at', 0));
        $timePast = $verifiedAt->diffInMinutes();

        return (!session()->get("is_verified", false)) ||
            ($timePast > self::VERIFICATION_TIMEOUT);
    }
}

The code checks if the user has re-authenticated within the last set 10 minutes (set by the VERIFICATION_TIMEOUT constant) and only redirects them to the verification page at /auth/verify if that returns false. Next, list AuthyVerify as a route middleware by adding it to the $routeMiddlewares array in app/Http/Kernel.php, as in the code example below:

protected $routeMiddleware = [
    ...
    'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    'authy.verify' => \App\Http\Middleware\AuthyVerify::class,
    ...
]

Re-authenticate Users via Authy

Though our middleware is now ready, we still need to implement the controllers and views needed for it to be functional. Create a new AuthyController.php file in app/Http/Controllers/Auth and fill it with the code below:

<?php

namespace App\Http\Controllers\Auth;

use Authy\AuthyApi;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\MessageBag;

class AuthyController
{
    public function showVerifyForm(Request $request)
    {
        return view('auth.2fa');
    }

    public function verify(Request $request)
    {
        $request->validate([
            'token' => ['required', 'numeric', 'digits_between:6,10'],
        ]);

        $authy = new AuthyApi(getenv("AUTHY_SECRET"));
        $verification = $authy->verifyToken(
            auth()->user()->authy_id,
            $request->input("token")
        );

        try {
            if ($verification->ok()) {
                session()->put("is_verified", true);
                session()->put("verified_at", Carbon::now()->timestamp);
                return redirect()->intended();
            } else {
                Log::info(json_encode($verification->errors()));
                $errors = new MessageBag(['token' => ['Failed to verify token']]);
                return back()->withErrors($errors);
            }
        } catch (\Throwable $t) {
            Log::error(json_encode($t));
            $errors = new MessageBag(['token' => [$t->getMessage()]]);
            return back()->withErrors($errors);
        }
    }
}

The controller implements two methods - showVerifyForm to render the verification form where users can enter their token, and verify which confirms the token against the Authy API. When verification is successful, the verify method adds a verified_at key to the session which is then used by the AuthyVerify middleware to determine if a user should be asked to re-authenticate the next time. Next, create a 2fa.blade.php file in resources/views/auth and add the code below to it.

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header">{{ __('Enter your Authy Code') }}</div>
                    <div class="card-body">
                        <p>
                            {{ __('You are about to perform a sensitive operation, please verify your identity to continue.') }}
                        </p>
                        <form method="POST" action="{{ route('authy.verify') }}">
                            @csrf
                            <div class="form-group row">
                                <label for="token" class="col-md-2 col-form-label text-md-left">{{ __('Token') }}</label>
                                <div class="col-md-6">
                                    <input id="token" type="text" class="form-control @error('token') is-invalid @enderror" name="token" value="{{ old('token') }}" autofocus>
                                    @error('token')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                    @enderror
                                </div>
                            </div>
                            <button type="submit" class="btn btn-primary align-baseline">{{ __('Verify') }}</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

To register the verification routes, replace the Auth::routes(); line in routes/web.php with the code below. That way, all authentication-related routes have the /auth prefix.

Route::group(['prefix' => 'auth'], function() {
    Auth::routes();
    Route::get('verify', [App\Http\Controllers\Auth\AuthyController::class, 'showVerifyForm'])->name('authy.show-form');
    Route::post('verify', [App\Http\Controllers\Auth\AuthyController::class, 'verify'])->name('authy.verify');
});

Your final web routes file should look similar to the one below:

<?php

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

Route::group(['prefix' => 'auth'], function() {
    Auth::routes();
    Route::get('verify', [App\Http\Controllers\Auth\AuthyController::class, 'showVerifyForm'])->name('authy.show-form');
    Route::post('verify', [App\Http\Controllers\Auth\AuthyController::class, 'verify'])->name('authy.verify');
});

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
Route::get('/notes', [App\Http\Controllers\HomeController::class, 'index']);
Route::get('/note/{noteId}', [App\Http\Controllers\HomeController::class, 'viewNote'])->name('note.view');
Route::post('/notes/delete', [App\Http\Controllers\HomeController::class, 'deleteNote'])
    ->name('note.delete')
    ->middleware('authy.verify');

You can now try to delete any note from the notes list and should be met with the verification form below. The code will appear in the Authy app on your phone and you can proceed to delete a note after successful verification.

Authy Verification Form

Conclusion

Re-authentication helps us ensure that users are really who they say they are before letting them perform sensitive operations. It also provides an additional check for users so they are sure they really want to go ahead and carry out the operation. In this tutorial, we have seen how to implement a Laravel re-authentication flow by leveraging Authy.

Here are some other resources that could help you when working with Authy and authentication in Laravel:

The sample project is available on GitLab. Feel free to raise a new issue or reach out to me on Twitter if you have questions or encounter an issue.

Michael Okoko is a software engineer and computer science student at Obafemi Awolowo University, Nigeria. He loves open source and is mostly interested in Linux, Golang, PHP, and fantasy novels! You can reach him via:


Print Share Comment Cite Upload Translate
CITATION GOES HERE CITATION GOES HERE
Select a language: