Laravel Fortify : Implement 2FA in a way that won't let users lock themselves out
Tags: laravel, php, tutorial, webdev
Published: 2021-05-17 11:21:05

##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](https://play.google.com/store/apps/details?hl=en&id=com.google.android.apps.authenticator2) or [Duo](https://duo.com/), 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](https://github.com/laravel/fortify/issues/201), 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](https://github.com/nicolus/laravel-2fa) (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](https://laravel.com/docs/8.x/fortify).

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](https://github.com/nicolus/laravel-2fa/blob/master/database/migrations/2021_05_09_100736_add_two_factor_confirmed_column_to_users_table.php) that creates that column, the up() method will look like that :
```php
    Schema::table('users', function (Blueprint $table) {
        $table->boolean('two_factor_confirmed')
            ->after('two_factor_recovery_codes')
            ->default(false);
    });
```

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 :
```html
<!-- 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
```

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](https://github.com/nicolus/laravel-2fa/blob/master/routes/web.php#L25) :
```php
Route::post('/2fa-confirm', [TwoFactorAuthController::class, 'confirm'])->name('two-factor.confirm');
```

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](https://github.com/nicolus/laravel-2fa/blob/master/app/Http/Controllers/TwoFactorAuthController.php#L11) :
```php
public function confirm(Request $request)
{
    $confirmed = $request->user()->confirmTwoFactorAuth($request->code);

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

    return back();
}
```

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

[/app/Models/User](https://github.com/nicolus/laravel-2fa/blob/master/app/Models/User.php#L45) :
```php
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;
}
```

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](https://github.com/nicolus/laravel-2fa/blob/master/resources/views/home.blade.php) :
```html
<!-- 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
```

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](https://github.com/laravel/fortify/blob/1.x/src/Actions/RedirectIfTwoFactorAuthenticatable.php#L48) which has this code in the handle method :

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

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](https://github.com/nicolus/laravel-2fa/blob/master/app/Actions/Fortify/RedirectIfTwoFactorConfirmed.php) :
```php
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);
    }
}
```

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](https://github.com/nicolus/laravel-2fa/blob/master/app/Providers/FortifyServiceProvider.php#L48-L55) :
```php
Fortify::authenticateThrough(function(){
    return array_filter([
        config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,

    Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorConfirmed::class : null,
        AttemptToAuthenticate::class,
        PrepareAuthenticatedSession::class,
    ]);
});
```

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](https://github.com/laravel/fortify/blob/1.x/src/Actions/DisableTwoFactorAuthentication.php) Action :
```php
$user->forceFill([
    'two_factor_secret' => null,
    'two_factor_recovery_codes' => null,
])->save();
```

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](https://github.com/laravel/fortify/blob/1.x/src/Http/Controllers/TwoFactorAuthenticationController.php#L36-L43) (the very same that handles the DELETE request) :
```php
public function destroy(Request $request, DisableTwoFactorAuthentication $disable)
    {
        $disable($request->user());

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

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](https://github.com/nicolus/laravel-2fa/blob/master/app/Actions/Fortify/DisableTwoFactorAuthentication.php)
```php
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();
    }
}
```

And then tell the Service Container to bind this new class to the parent one by adding this in [app/Providers/FortifyServiceProvider](https://github.com/nicolus/laravel-2fa/blob/master/app/Providers/FortifyServiceProvider.php#L57-L59):
```php
$this->app->bind(Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class, function(){
    return new App\Actions\Fortify\DisableTwoFactorAuthentication();
});
```

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 !