Add Two-Factor Email Verification to Laravel Auth
Founder of QuickAdminPanel
This is an example project where we will add unique verification sent via email, every time user logs in. At the end of the article, you will find link to free Github repository with full project code.
The basis of the project will be Laravel 6 adminpanel generated with our QuickAdminPanel, but you can start with default “empty” Laravel Auth, the steps will work the same.
Step 1. Two new fields in Users DB table
Here’s our new migration:
Schema::table('users', function (Blueprint $table) { $table->string('two_factor_code')->nullable(); $table->dateTime('two_factor_expires_at')->nullable(); });
Field two_factor_code will contain random 6-digit number, and two_factor_expires_at will contain expiration at – in our case, code will expire in 10 minutes.
We also add those fields to app/User.php properties – $fillable array, and $dates:
class User extends Authenticatable { protected $dates = [ 'updated_at', 'created_at', 'deleted_at', 'email_verified_at', 'two_factor_expires_at', ]; protected $fillable = [ 'name', 'email', 'password', 'created_at', 'updated_at', 'deleted_at', 'remember_token', 'email_verified_at', 'two_factor_code', 'two_factor_expires_at', ];
Step 2. Generate and send code on login
This is the method we need to add to app/Http/Controllers/Auth/LoginController.php:
protected function authenticated(Request $request, $user) { $user->generateTwoFactorCode(); $user->notify(new TwoFactorCode()); }
This way we override authenticated() method of core Laravel, and add custom logic of what should happen after user logs in.
First, we generate the code, and for that – we add a method in app/User.php:
public function generateTwoFactorCode() { $this->timestamps = false; $this->two_factor_code = rand(100000, 999999); $this->two_factor_expires_at = now()->addMinutes(10); $this->save(); }
Beside setting two-factor code and its expiration time, we also specify that this update should not touch updated_at column in users table – we’re doing $this->timestamps = false;.
Now, looking back at LoginController above, we call $user->notify() and use Laravel’s notification system, for that we need to create a Notification class, by php artisan make:notification TwoFactorCode, and then we fill in app/Notifications/TwoFactorCode.php:
class TwoFactorCode extends Notification { /** * Get the mail representation of the notification. * * @param mixed $notifiable * @return \Illuminate\Notifications\Messages\MailMessage */ public function toMail($notifiable) { return (new MailMessage) ->line('Your two factor code is '.$notifiable->two_factor_code) ->action('Verify Here', route('verify.index')) ->line('The code will expire in 10 minutes') ->line('If you have not tried to login, ignore this message.'); } }
This code will send email like this:
Two things to mention here:
- Method toMail() parameter $notifiable is automatically assigned as logged in User object, so we can access users.two_factor_code DB column by calling $notifiable->two_factor_code;
- We will create route(‘verify.index’) route, which will re-send the code, a bit later.
Step 3. Show verification form with Middleware
After user logs in, and after they get an email with verification code, they get redirected to this form:
In fact, they will see this form if they enter any URL, it will be shown until they enter a verification code.
To do that, we generate a Middleware: php artisan make:middleware TwoFactor
And fill in this into app/Http/Middleware/TwoFactor.php:
class TwoFactor { public function handle($request, Closure $next) { $user = auth()->user(); if(auth()->check() && $user->two_factor_code) { if($user->two_factor_expires_at->lt(now())) { $user->resetTwoFactorCode(); auth()->logout(); return redirect()->route('login') ->withMessage('The two factor code has expired. Please login again.'); } if(!$request->is('verify*')) { return redirect()->route('verify.index'); } } return $next($request); } }
If you’re not familiar how Middleware works, read official Laravel documentation. But basically, it’s a class that performs some actions to usually restrict from accessing some page or function.
So, in our case, we check if there is a two-factor code set. If it is, we check if it isn’t expired. If it has expired, we reset it and redirect back to login form. If it’s still active, we redirect back to verification form.
In other words, if users.two_factor_code is empty, then it’s verified and user can move further.
Here’s the code of app/User.php method resetTwoFactorCode():
public function resetTwoFactorCode() { $this->timestamps = false; $this->two_factor_code = null; $this->two_factor_expires_at = null; $this->save(); }
Next, we give our middleware class an “alias” name, inside app/Http/Kernel.php:
class Kernel extends HttpKernel { // ... protected $routeMiddleware = [ 'can' => \Illuminate\Auth\Middleware\Authorize::class, // ... more middlewares 'twofactor' => \App\Http\Middleware\TwoFactor::class, ]; }
Now, we need to assign this ‘twofactor’ Middleware to some routes. In our case of QuickAdminPanel-generated code, it’s a whole Route Group in routes/web.php:
Route::group([ 'prefix' => 'admin', 'as' => 'admin.', 'namespace' => 'Admin', 'middleware' => ['auth', 'twofactor'] ], function () { Route::resource('permissions', 'PermissionsController'); Route::resource('roles', 'RolesController'); Route::resource('users', 'UsersController'); });
Step 4. Verification page Controller/View
At this point, any request to any URL will redirect to code verification.
For that, we will have two extra public routes:
Route::get('verify/resend', 'Auth\TwoFactorController@resend')->name('verify.resend'); Route::resource('verify', 'Auth\TwoFactorController')->only(['index', 'store']);
Main logic will be inside app/Http/Controllers/Auth/TwoFactorController.php:
class TwoFactorController extends Controller { public function index() { return view('auth.twoFactor'); } public function store(Request $request) { $request->validate([ 'two_factor_code' => 'integer|required', ]); $user = auth()->user(); if($request->input('two_factor_code') == $user->two_factor_code) { $user->resetTwoFactorCode(); return redirect()->route('admin.home'); } return redirect()->back() ->withErrors(['two_factor_code' => 'The two factor code you have entered does not match']); } public function resend() { $user = auth()->user(); $user->generateTwoFactorCode(); $user->notify(new TwoFactorCode()); return redirect()->back()->withMessage('The two factor code has been sent again'); } }
Main form is inside of index() method, then it POSTS data to store() method to verify the code, and third method resend() is for re-generating and re-sending new code in another email.
Let’s take a look at verification form – in resources/views/auth/twoFactor.blade.php:
@if(session()->has('message')) <p class="alert alert-info"> {{ session()->get('message') }} </p> @endif <form method="POST" action="{{ route('verify.store') }}"> {{ csrf_field() }} <h1>Two Factor Verification</h1> <p class="text-muted"> You have received an email which contains two factor login code. If you haven't received it, press <a href="{{ route('verify.resend') }}">here</a>. </p> <div class="input-group mb-3"> <div class="input-group-prepend"> <span class="input-group-text"> <i class="fa fa-lock"></i> </span> </div> <input name="two_factor_code" type="text" class="form-control{{ $errors->has('two_factor_code') ? ' is-invalid' : '' }}" required autofocus placeholder="Two Factor Code"> @if($errors->has('two_factor_code')) <div class="invalid-feedback"> {{ $errors->first('two_factor_code') }} </div> @endif </div> <div class="row"> <div class="col-6"> <button type="submit" class="btn btn-primary px-4"> Verify </button> </div> </div> </form>
I intentionally skipped all the “parent” HTML template, cause it may depend on your design theme, but this is the main form Blade code. I think it’s all pretty self-explaining.
The only bit that probably needs explanation is that session()->has(‘message’) – where that message comes from? From Controller’s resend() method and its last line:
return redirect()->back()->withMessage('The two factor code has been sent again');
Not sure if you know, but Laravel redirect() method can be used with chained method ->withWhatever(‘text’) and that text is saved in session as session(‘whatever’) (lowercased).
Aaaaand, that’s it! We have our full logic to send two-factor code via email.
The only thing I haven’t touched on is very individual – WHEN do you want to send that code. In this example, we’re sending it every time, but that’s probably not very realistic real-life scenario. You would need to send the code whenever there’s a login from new IP address, for example, or from a different country, or every 5th time, or some other condition, so please build it yourself – just edit the Middleware and LoginController to tell Laravel when you want to send that code.
Link to full repository: https://github.com/LaravelDaily/Laravel-Two-Factor-Auth-Email