2-factor authentication for Laravel using TOTP

Oct 23, 2022 by Thibault Debatty | 6309 views

Laravel

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...

regularguy-eth-j7mGBT2hyM8-unsplash.jpg

Principles

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.png

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...

Practical considerations

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.

freeotp.png

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:

  • the user is authenticated (with his regular login and password);
  • and the user has enabled 2FA;
  • and the user has not yet provided a valid OTP.

Implementation in a nutshell

To implement 2FA in a Laravel application, we will need:

  • some libraries and extension to generate the initial seed and QR code, and to check the OTP provided by the user;
  • a controller with appropriate routes, methods and views to
    1. show the initial QR code;
    2. enter the generated OTP and enable 2fa;
  • a new database column to store the shared secret (the seed);
  • a middleware to check if OTP authentication is required, and if so redirect to the OTP validation page
  • a controller with routes, methods and views to:
    1. ask for the OTP;
    2. check the OTP and validate user authentication.

Installation

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

Generate the initial QR code and enable 2FA

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

Database migration

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.

laravel-2fa.png

Middleware

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:migration 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
    ],

Validate OTP

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

laravel-otp.png

Final words

TOTP is a good addition for the security of your web application, but it is not bulletproof:

  • unlike the password, the shared secret is not hashed in the database, so it will be revealed if the database gets leaked;
  • a website protected with TOTP is still vulnerable for man-in-the-middle attacks, as demonstrated with tools like Evilginx for example;
  • much of the protection offered by TOTP relies on the assumption that the seed cannot be stolen or copied from the TOTP app, hence an official trustful app should always be used;
  • the TOTP form can be vulnerable to a brute-force attack.

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

This website uses cookies. More information about the use of cookies is available in the cookies policy.
Accept