DEV Community

loading...
Cover image for Laravel Fortify : Implement 2FA in a way that won't let users lock themselves out

Laravel Fortify : Implement 2FA in a way that won't let users lock themselves out

nicolus profile image Nicolas Bailly ・8 min read

The problem with Fortify and Two Factor Authentication

Laravel Fortify is pretty awesome. It handles most of the heavy lifting of authentication for you, but unlike Laravel Breeze (which publishes Controllers and views in your application) or Laravel Jetstream (which actually uses Fortify behind the scenes), it lets you in charge of creating your own views so you don't have to use tailwind CSS or inertia.js.

One of the things it handles and was very appealing to me is Two Factor Authentication (2FA) with Time-Based One Time Passwords (TOTP) : The thing that asks you to scan a QR code in an application like Google Authenticator or Duo, and then gives you a new 6 character password every 30 seconds that you have to enter to log-in.

That's really nice because it's an essential feature for any application that need decent security, and I wouldn't know where to start if I had to implement it from scratch. But as of now it has one major limitation : If you implement it by following the documentation to the letter, there's a good chance that your users will end up locked out of your app when they try to enable it. The issue is described in detail here, but it boils down to the fact that Fortify won't ask for the user to enter a code to confirm that they successfully installed the app and scanned the QR code, so if they activate 2FA and then fail to add your site to Google Authenticator (or their computer crashes or something), they wont be able to log into your site ever again.

Thankfully, Laravel is flexible enough that with a little work we can implement our own confirmation system while still leveraging all the goodies that Fortify gives us.

The solution

The solution we'll be implementing is pretty common : Once a user activates 2FA, instead of simply enabling it, displaying the QR code once and calling it a day, we'll show the QR code, ask them to enter a TOTP generated with Google Authenticator to confirm they did everything correctly, and only then activate 2FA.

You can find a working example of what we'll do on this github repository (keep in mind that it's just an example, you'd probably do things a bit differently in a real world app).

Before we'll start I'll assume that you already installed Fortify and implemented 2FA following the official documentation.

So let's dig in !

Step 1 : Add a new 'two_factor_confirmed' field

Fortify comes with a migration that adds the two_factor_secret and two_factor_recovery_codes columns to the users table. Whenever a user activates 2FA (by POSTing to the /user/two-factor-authentication endpoint) these fields get populated, and that's how Laravel knows it needs to ask for a TOTP when the user tries to login.

Since we want the user to confirm that they have setup everything correctly, we need a new column to store that confirmation. To that end, we'll create a new migration that creates that column, the up() method will look like that :

    Schema::table('users', function (Blueprint $table) {
        $table->boolean('two_factor_confirmed')
            ->after('two_factor_recovery_codes')
            ->default(false);
    });
Enter fullscreen mode Exit fullscreen mode

Now we probably have a view that displays either a button to enable 2FA, or the QR code when the user gets back to it after enabling 2FA. It's likely a variation of something like this :

<!-- 2FA enabled, we display the QR code : -->        
@if(auth()->user()->two_factor_secret)
    {!! auth()->user()->twoFactorQrCodeSvg() !!}
<!-- 2FA not enabled, we show an 'enable' button  : -->
@else
    <form action="/user/two-factor-authentication" method="post">
        @csrf
        <button type="submit">Activate 2FA</button>
    </form>
@endif
Enter fullscreen mode Exit fullscreen mode

At this point, we should be able to modify the two_factor_confirmed column in the database and it will change what we display on the page.

There are still two major issues though :

  1. We don't have a way to update our new column
  2. Laravel itself doesn't care about it, and will keep asking the user for a TOTP as soon as the two_factor_secret column is filled.

Step 2 : Update the column when the User confirms 2FA

First we'll need a route on which we'll POST a TOTP to confirm that the user is able to get one. So let's add it to our routes/web.php file, along with a corresponding TwoFactorAuthController

/routes/web.php :

Route::post('/2fa-confirm', [TwoFactorAuthController::class, 'confirm'])->name('two-factor.confirm');
Enter fullscreen mode Exit fullscreen mode

Now we could handle the verification and update directly in the Controller, but I think it makes more sense to let the User model do it, so the confirm() method of our Controller will only tell the User to check the TOTP provided in the code parameter, and then redirect back (with an error if the code was not valid).

/app/Http/Controllers/TwoFactorAuthController :

public function confirm(Request $request)
{
    $confirmed = $request->user()->confirmTwoFactorAuth($request->code);

    if (!$confirmed) {
        return back()->withErrors('Invalid Two Factor Authentication code');
    }

    return back();
}
Enter fullscreen mode Exit fullscreen mode

And finally we add the confirmTwoFactorAuth() method in our User model.

/app/Models/User :

public function confirmTwoFactorAuth($code)
{
    $codeIsValid = app(TwoFactorAuthenticationProvider::class)
        ->verify(decrypt($this->two_factor_secret), $code);

     if ($codeIsValid) {
        $this->two_factor_confirmed = true;
        $this->save();

        return true;
    }

    return false;
}
Enter fullscreen mode Exit fullscreen mode

Now we can update our main view to either :

  • Display the "Activate 2FA" button if it's not enabled at all
  • Display the QR Code and a form that POSTs to the route we just created if it's enabled but not confirmed
  • Display a button that will disable 2FA by sending a DELETE request.

/resources/views/home.blade.php :

<!-- 2FA confirmed, we show a 'disable' button to disable it : -->        
@if(auth()->user()->two_factor_confirmed)
    <form action="/user/two-factor-authentication" method="post">
        @csrf
        @method('delete')
        <button type="submit">Disable 2FA</button>
    </form>
<!-- 2FA enabled but not yet confirmed, we show the QRcode and ask for confirmation : -->
@elseif(auth()->user()->two_factor_secret)
    <p>Validate 2FA by scanning the floowing QRcode and entering the TOTP</p>
    {!! auth()->user()->twoFactorQrCodeSvg() !!}
    <form action="{{route('two-factor.confirm')}}" method="post">
        @csrf
        <input name="code" required/>
        <button type="submit">Validate 2FA</button>
    </form>
</div>
<!-- 2FA not enabled at all, we show an 'enable' button  : -->
@else
    <form action="/user/two-factor-authentication" method="post">
        @csrf
        <button type="submit">Activate 2FA</button>
    </form>
@endif
Enter fullscreen mode Exit fullscreen mode

It should work well enough, so now the only problem remaining is that Fortify doesn't know about the two_factor_confirmed column and will keep asking for a TOTP.


Step 3 : check if the user has confirmed 2FA before asking for a TOTP

We can see how Fortify determines if it needs to ask for a TOTP in its RedirectIfTwoFactorAuthenticatable which has this code in the handle method :

if (optional($user)->two_factor_secret &&
    in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
    return $this->twoFactorChallengeResponse($request, $user);
}
Enter fullscreen mode Exit fullscreen mode

It seems if $user->two_factor_secret is not null the user will be redirected. So the solution is pretty easy : We just have to change this to say if (optional($user)->two_factor_confirmed instead, right ?

Well yeah, except this file is in Fortify itself (in the vendor directory) so we're definitely not going to modify it directly.

Our best bet would be to create our own version of RedirectIfTwoFactorAuthenticatable and slightly change its behavior. So let's create a RedirectIfTwoFactorConfirmed class that extends the original one.

app/Actions/Fortify/RedirectIfTwoFactorConfirmed :

namespace App\Actions\Fortify;

use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;

class RedirectIfTwoFactorConfirmed extends RedirectIfTwoFactorAuthenticatable
{
    public function handle($request, $next)
    {
        $user = $this->validateCredentials($request);

        if (optional($user)->two_factor_confirmed &&
            in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
            return $this->twoFactorChallengeResponse($request, $user);
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks good, now to teach Fortify that it needs to use our Action now, we need to customize the Authentication Pipeline by adding this in /app/Providers/FortifyServiceProvider :

Fortify::authenticateThrough(function(){
    return array_filter([
        config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,

    Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorConfirmed::class : null,
        AttemptToAuthenticate::class,
        PrepareAuthenticatedSession::class,
    ]);
});
Enter fullscreen mode Exit fullscreen mode

Again it's identical to the default Pipeline, except we replaced RedirectIfTwoFactorAuthenticatable with RedirectIfTwoFactorConfirmed.

And that's it, our users now need to confirm that they can get a valid TOTP before 2FA is actually enabled and they can't lock themselves out of our app !

Or can they ?...

Well there's still an issue with our workflow : what happens when we hit that 'Disable 2FA' button and we send a DELETE request to the Fortify Controller ? Fortify will set the two_factor_secret field to null, but now that we've modified the Authentication Pipeline to check for two_factor_confirmed instead, we've introduced a new problem, and we'll keep asking for a TOTP even after the user has disabled 2FA.


Step 4 : set 'two_factor_confirmed' to false when we disable 2FA

I we go back to Fortify's source code, we can see that it sets both two_factor_secret and two_factor_recovery_codes to null in the DisableTwoFactorAuthentication Action :

$user->forceFill([
    'two_factor_secret' => null,
    'two_factor_recovery_codes' => null,
])->save();
Enter fullscreen mode Exit fullscreen mode

So we'll just need to add our column there, but once again we certainly don't want to start modifying our vendor directory. And this time there isn't a façade that allows us to change the Pipeline for that.

Luckily, we can rely on the Service Container to help us inject our own version of this Action.

Let's look at how it's used in Fortify. It all happens in the TwoFactorAuthenticationController (the very same that handles the DELETE request) :

public function destroy(Request $request, DisableTwoFactorAuthentication $disable)
    {
        $disable($request->user());

        return $request->wantsJson()
                    ? new JsonResponse('', 200)
                    : back()->with('status', 'two-factor-authentication-disabled');
    }
Enter fullscreen mode Exit fullscreen mode

There's our action, and it looks like instead of being hardcoded inside of the function, it's resolved through dependency injection and then called right away. That's awesome news because it means we just have to tell the Service Container "whenever someone asks for DisableTwoFactorAuthentication, just use our own version instead".

So first let's create our own version (it's very important that it extends the original or else the Service Container won't be able to resolve it) :

/app/Actions/Fortify/DisableTwoFactorAuthentication

namespace App\Actions\Fortify;

class DisableTwoFactorAuthentication extends \Laravel\Fortify\Actions\DisableTwoFactorAuthentication
{
    public function __invoke($user)
    {
        $user->forceFill([
            'two_factor_secret' => null,
            'two_factor_recovery_codes' => null,
            'two_factor_confirmed' => 0,
        ])->save();
    }
}
Enter fullscreen mode Exit fullscreen mode

And then tell the Service Container to bind this new class to the parent one by adding this in app/Providers/FortifyServiceProvider:

$this->app->bind(Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class, function(){
    return new App\Actions\Fortify\DisableTwoFactorAuthentication();
});
Enter fullscreen mode Exit fullscreen mode

Aaaaaaaaaaaand that's it ! We should finally have a fully working 2FA implementation that won't let your users lock themselves out.


Wrapping things up

Looking at the wall of text and code above I realize it looks like a lot to take in, but it's really not that complicated, what we did was :

  • Add a new 'confirmed' column with a migration
  • Add a new route and controller to set that column to true
  • Extend the RedirectIfTwoFactorAuthenticatable action with our own that checks for that column instead of the 'two_factor_secret' one
  • Override the Authentication Pipeline to use our own action
  • Extend the DisableTwoFactorAuthentication Action with our own that disables the 'confirmed' column
  • Use the service container to bind our own action to DisableTwoFactorAuthentication.

Any question ? anything you would have done differently ? Post in the comments !

Discussion (5)

Collapse
seivad profile image
Mick Davies

I had to update the Disable 2FA to this (Laravel 8)

$this->app->singleton(
\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class,
\App\Actions\Fortify\DisableTwoFactorAuthentication::class
);

Collapse
ufeg02 profile image
Ebe

In my case the confirmed field won't reset to 0 when I delete the 2FA. Any idea how to debug?

Collapse
nicolus profile image
Nicolas Bailly Author • Edited

Hi !
I take it you've followed my example and created a DisableTwoFactorAuthentication action in App\Actions\Fortify, and then bound it to the original one in the Service provider ?

First thing is to check if you do go into that action when you disable 2FA, either use xdebug with a break point or put a good old dd() in there :

    public function __invoke($user)
    {
        dd('deleting 2FA !');
        $user->forceFill([
            'two_factor_secret' => null,
            'two_factor_recovery_codes' => null,
            'two_factor_confirmed' => 0,
        ])->save();
    }
Enter fullscreen mode Exit fullscreen mode

If you can still disable 2FA and it doesn't die on you then you're not using it and the binding doesn't work. Maybe try to run laravel optimize:clear and composer dumpautoload, I'm honestly not sure if some of the bindings are cached somehow ?

I'll double check if my code actually works, I may have forgotten something.

Collapse
ufeg02 profile image
Ebe

Hmmm, the dd function is not triggered...

Thread Thread
nicolus profile image
Nicolas Bailly Author

All right, so the binding is not working, I'm not sure why :-/

If you can upload your code somewhere I can have a look and see if I can find something.

Forem Open with the Forem app