Oct 23, 2022 by Thibault Debatty | 7545 views
https://cylab.be/blog/240/2-factor-authentication-for-laravel-using-totp
2-factor authentication is an important protection for a web application. In this blog post we see how Time based One Time Password (TOTP) authentication works, and how it can be implemented in a Laravel application...
Time based One Time Password (TOTP) authentication is a variation of HMAC based One Time Password (HOTP) where the source of uniqueness (the counter) is actually a time reference. So it roughly works as follows:
The web application creates an initial secret (a very long password), that the user must save in his TOTP app. I like to call this secret the seed, because it is conceptually similar to the initial seed of a pseudo random number generator.
When the user must authenticate on the web application, the TOTP app appends a time-based counter to the seed, and hashes this value to produce a short pin code (usually 6 digits). The web application performs the same computation and checks if the value provided by the user matches.
The shared secret is actually a very long string (typically 64 characters) and is hence impossible to memorize. Moreover, because of the hashing process the secret cannot be recovered from the generated pin codes. Finally, TOTP assumes that the secret cannot be stolen or copied from the app. With this assumption, the TOTP app behaves like a hardware pin code generator. Hence the user that provides correct pin codes shows that is in possession of the seed (saved on the TOTP app). This is the second factor: something you have.
TOTP has been largely popularized by Google and their authentication app "Google Authenticator". Hence still today, a lot of websites use the name "Google 2FA" to refer to TOTP. But TOTP is actually a standard described in RFC 6238. For example, the library we will use below is called Google2FA, while it actually implements standard TOTP. This also means that TOTP codes can be generated by any compatible app, like FreeOTP, Twilio Authy, Microsoft Authenticator and lots of others...
The initial shared secret (the seed) is a very long string. Hence most of the time users do not type the secret in their app. Instead, they scan a QR code (an image) that contains the encoded secret. This QR code also allows to encode additional information. Typically these are the address of the web application, and the username.
Moreover, if the initial secret is not correctly typed or saved in the app, the user may loose access to the web application. Hence most web applications require the user to type a valid OTP generated by the app to validate that the app is correctly configured, and enable 2FA for the user.
To authenticate users with 2FA, we should modify the login process of Laravel, to append OTP validation. However, this would be quite intrusive, and might lead to additional security issues. Another possibility is to add a middleware that analyzes all requests and redirects the user to the OTP form if needed, that is if:
To implement 2FA in a Laravel application, we will need:
To handle all TOTP related tasks we will use the Google2FA library from pragmarx:
composer require pragmarx/google2fa-qrcode
To generate the QR codes we will use the bacon QR code library:
composer require bacon/bacon-qr-code
Bacon itself uses the imagick extension to create the image:
sudo apt install php-imagick
To generate the initial QR code and enable 2FA, we will need a controller (ProfileController in my example) with 2 routes, 2 methods and 2 views.
So first let's create the routes in routes/web.php:
Route::get('/app/profile/2fa', 'ProfileController@twofa');
Route::post('/app/profile/2fa', 'ProfileController@twofaEnable');
Then if required create ProfileController.php, and add the 2 methods to show the QR code, and then check when the initial OTP is submitted:
<?php
namespace App\Http\Controllers;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use PragmaRX\Google2FAQRCode\Google2FA;
class ProfileController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* Show QR code to enable 2FA
*/
public function twofa()
{
$user = Auth::user();
$google2fa = new Google2FA();
// generate a secret
$secret = $google2fa->generateSecretKey();
// generate the QR code, indicating the address
// of the web application and the user name
// or email in this case
$qr_code = $google2fa->getQRCodeInline(
"cylab.be",
$user->email,
$secret
);
// store the current secret in the session
// will be used when we enable 2FA (see below)
session([ "2fa_secret" => $secret]);
return view('app.profile.2fa', [
"qr_code" => $qr_code]);
}
/**
* check the submitted OTP
* if correct, enable 2FA
*/
public function twofaEnable(Request $request)
{
$google2fa = new Google2FA();
// retrieve secret from the session
$secret = session("2fa_secret");
$user = Auth::user();
if ($google2fa->verify($request->input('otp'), $secret)) {
// store the secret in the user profile
// this will enable 2FA for this user
$user->twofa_secret = $secret;
$user->save();
// avoid double OTP check
session(["2fa_checked" => true]);
return redirect(action('ProfileController@twofa'));
}
throw ValidationException::withMessages([
'otp' => 'Incorrect value. Please try again...']);
}
}
Now we need a view resources/views/profile/2fa.blade.php to show the QR code and the field to enter the first OTP:
@extends('layouts.app')
@section('content')
<div class="card">
<div class="card-header">2-factors authentication</div>
<div class="card-body">
<p>
2-factors authentication is currently
<span class='badge bg-warning'>disabled</span>. To enable:
</p>
<ol class="list-left-align">
<li>Open your OTP app and <b>scan the following QR-code</b>
<p class="text-center">
<img src="{{ $qr_code }}">
</p>
</li>
<li>Generate a One Time Password (OTP) and enter the value below.
<form action="{{ action('ProfileController@twofaEnable') }}" method="POST"
class="form-inline text-center">
@csrf
<input name="otp" class="form-control mr-1{{ $errors->has('otp') ? ' is-invalid' : '' }}"
type="number" min="0" max="999999" step="1"
required autocomplete="off">
<button type="submit" class="form-control btn-sm btn-primary">Submit</button>
@if ($errors->has('otp'))
<span class="invalid-feedback text-left">
<strong>{{ $errors->first('otp') }}</strong>
</span>
@endif
</form>
</li>
</ol>
</div>
</div>
@endsection
As you may have noticed from the controller code above, the shared secret of a user is stored in the users table of the database. To create this column, we will need a migration:
php artisan make:migration --table users users_add_twofa_secret
With following code:
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('twofa_secret', 255)->nullable();
});
}
And run the migration:
php artisan migrate
Your users should not be able to display the QR code, and enable 2FA.
The goal of this middleware is to intercept all requests, check if the user should provide an OTP, and if yes, redirect the user to the OTP page.
To create the middleware:
php artisan make:middleware Verify2FA
and add the following content:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
class Verify2FA
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
// Not authenticated => no need to check
if (!Auth::check()) {
return $next($request);
}
// 2FA not enabled => no need to check
if (is_null(Auth::user()->twofa_secret)) {
return $next($request);
}
// 2FA is already checked
if (session("2fa_checked", false)) {
return $next($request);
}
// at this point user must provide a valid OTP
// but we must avoid an infinite loop
if (request()->is('login/otp')) {
return $next($request);
}
return redirect(action("Auth\OTPController@show"));
}
}
Finally, we must register the middleware in app/Http/Kernel.php:
protected $middlewareGroups = [
'web' => [
...
\App\Http\Middleware\Verify2FA::class
],
Finally, we need a controller to show the OTP form, and check the provided OTP.
To create the controller:
php artisan make:controller OTPController
And add the following code:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use PragmaRX\Google2FAQRCode\Google2FA;
class OTPController extends Controller
{
public function __construct()
{
$this->middleware('auth');
// limit to 60 trials per minute, to avoid brute force
$this->middleware('throttle:60,1');
}
public function show()
{
return view('auth.otp');
}
public function check(Request $request)
{
$google2fa = new Google2FA();
$secret = Auth::user()->twofa_secret;
if ($google2fa->verify($request->input('otp'), $secret)) {
session(["2fa_checked" => true]);
return redirect("/");
}
throw ValidationException::withMessages([
'otp' => 'Incorrect value. Please try again...'
]);
}
}
For these 2 methods, we must add 2 routes in routes/web.php:
Route::get('/login/otp', 'Auth\OTPController@show');
Route::post('/login/otp', 'Auth\OTPController@check');
And we also need a view resources/views/auth/otp.blade.php:
@extends('layouts.app')
@section('title', 'OTP')
@section('content')
<div class="container">
<form method="POST" action="{{ action('Auth\OTPController@check') }}">
@csrf
<div class="form-group row">
<label for="otp" class="col-sm-4 col-form-label text-md-right">
OTP
</label>
<div class="col-md-6">
<input id="otp"
type="number" min="0" max="999999" step="1"
class="form-control{{ $errors->has('otp') ? ' is-invalid' : '' }}"
autocomplete="off"
name="otp" value="" required autofocus>
@if ($errors->has('otp'))
<span class="invalid-feedback">
<strong>{{ $errors->first('otp') }}</strong>
</span>
@endif
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-8 offset-md-4">
<button type="submit" class="btn btn-primary">
Submit
</button>
</div>
</div>
</form>
</div>
@endsection
TOTP is a good addition for the security of your web application, but it is not bulletproof:
So TOTP actually protects against stolen or weak (guessable) passwords. It is only one layer of a properly protected web application...
This blog post is licensed under CC BY-SA 4.0