password.confirm
middleware.$ laravel new authy-reauth && cd authy-reauth
reauth
and update the project’s .env
file to use by updating the DB_DATABASE
as in below.DB_DATABASE=reauth
.env.example
if it wasn’t automatically generated.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.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(); }); }
$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', ];
php artisan migrate
from the project folder.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
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
minimum-stability
to rc
and prefer-stable
to true
, as in the example below: "minimum-stability": "rc", "prefer-stable": false,
authy/php
with the command below:composer require authy/php
.env
file as shown below:AUTHY_SECRET=XXXXXXXXXXXXXXXXXXXX
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' ]); }
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; }
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.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.Note
model as well as the migration file for the notes table by running:$ php artisan make:model Note -m
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(); }); }
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) ]; }
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(); }
database/seeders/DatabaseSeeder.php
and replace the run
method with the code below:public function run() { $this->call([ NoteSeeder::class, ]); }
php artisan db:seed
and you will have 25 new notes added to your notes table.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'); } }
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.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');
authy.verify
middleware and we will implement that next.php artisan make:middleware AuthyVerify
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); } }
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, ... ]
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); } } }
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
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'); });
<?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');