This content originally appeared on Twilio Blog and was authored by Michael Okoko
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:
- MySQL installed and ready for use with PHP
- Composer and NPM to install our application dependencies.
- The Laravel CLI (This article uses Laravel version 8.26.1)
- A Twilio account and an Authy API Key. You can follow this guide to set up your first Authy application.
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-stability
to rc
and prefer-stable
to 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.
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.
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 Laravel documentation on authentication and authorization.
- The PHP Quickstart guide on using Authy for Two-Factor Authentication.
- The OWASP Foundation Cheat Sheet on authentication and session management
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:
- Github: https://github.com/idoqo
- Twitter: https://twitter.com/rxrog/
This content originally appeared on Twilio Blog and was authored by Michael Okoko
Michael Okoko | Sciencx (2021-02-07T21:53:19+00:00) Secure Sensitive Laravel Routes With Two-factor Authentication Using Authy. Retrieved from https://www.scien.cx/2021/02/07/secure-sensitive-laravel-routes-with-two-factor-authentication-using-authy/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.