Miguel Piedrafita

Miguel Piedrafita

Adding Two Factor Authentication to a Laravel app

I recently added 2FA (Two Factor Authentication) support to Sitesauce. While I did follow some existing tutorials (this Scotch tutorial was really helpful) I still took me some hours of debugging to get everything running, so I thought I'd write about my approach in case it's useful to anyone else.

I started by requiring some composer packages to make our life easier. The first one will take care of generating and validating the codes (and also includes some Laravel magic to make our lives easier) while the second is required to generate QR codes the user can scan with their 2FA app.

composer require pragmarx/google2fa-laravel bacon/bacon-qr-code

The tutorial I linked above then proceeds to add 2FA on registration, but I wanted to make it an optional thing so I added it to the settings page instead. I added a button that, when clicked, would make an AJAX request to fetch a new code and its QR instead of associating it with the user directly. This way, if the user exists mid-process, they won't be locked out of their account.

use PragmaRX\Google2FALaravel\Facade as TwoFactor;

/**
* Generate two factor authentication keys.
*/
public function generate()
{
	$key = TwoFactor::generateSecretKey();

	return [
    	'key' => $key,
    	'qr' => TwoFactor::getQRCodeInline(
			config('app.name'), // A name for the code in 2FA apps
			auth()->user()->email, // The user's email
			$key,
        ),
    ];
}

The code above returns an array with two items: the secret key that the user will use to generate codes (and your app will use to verify them) and a QR code that the user can scan to easily configure their 2FA application of choice with your app.

Once the user has configured 2FA I ask them for a code before persisting any changes. This is a really common pattern across sites, and also helps ensure users won't get locked out of their accounts by misconfiguring their code-generating application. Once the code is verified, I save the secret key to a row in the users table.

use PragmaRX\Google2FALaravel\Facade as TwoFactor;

/**
* Validate & configure two-factor authentication.
*
* @param  \Illuminate\Http\Request  $request
* @return \Illuminate\Http\RedirectResponse
*/
public function configure(Request $request)
{
	$request->validate([
		'key'  => ['required', 'string'],
        // PRO TIP: You can use closures to quickly create Laravel validation rules
		'code' => ['required', 'string', function ($attribute, $value, $fail) use ($request) {
			if (! TwoFactor::verifyKey($request->input('key'), $value)) {
				$fail('The code you provided is not valid.');
			}
		}],
	]);

	$request->user()->update([
		'twofactor' => $request->input('key'), // This contains the secret key we generated earlier
	]);

	return redirect()->back()->withSuccess('Two Factor Authentication has been successfully configured.');
}

Your users can now configure two-factor authentication but they can't disable it. Let's fix that.

/**
* Remove the specified resource from storage.
*
* @param  \Illuminate\Http\Request  $request
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Request $request)
{
    $request->user()->update([
    	'twofactor' => null,
    ]);

    return redirect()->back()->withSuccess('Two Factor Authentication has been successfully disabled.');
}

There's one piece left in the puzzle: enforcing 2FA on login if the user has configured it. For this, we can use the middleware bundled with the package we installed earlier:

// app/Http/Kernel.php

protected $routeMiddleware = [
	...
	'2fa' => \PragmaRX\Google2FALaravel\Middleware::class,
];
// routes/web.php

Route::get('dashboard', 'DashboardController@index')->middleware('2fa');

This middleware will search for a view file on /resources/views/google2fa/index.blade.php (although you can configure this), so make sure to create that.

For my specific case though, I made the following changes to get it working with Inertia.js.

// app/Providers/AppServiceProvider.php

public function register()
{
	$this->app->singleton(\PragmaRX\Google2FALaravel\Support\Authenticator::class, function ($app) {
		return $app->make(\App\TwoFactorAuthenticator::class);
	});
}
// app/TwoFactorAuthenticator.php

class TwoFactorAuthenticator extends \PragmaRX\Google2FALaravel\Support\Authenticator
{
    protected function makeHtmlResponse($statusCode)
    {
        $inertia = inertia($this->config('view')); // Return an Inertia view instead of a Blade view

        if ($statusCode !== 200) {
            session()->flash('errors', with($this->getErrorBagForStatusCode($statusCode), function($bag) {
                $bag->add('code', $bag->get('message')[0]);

                return (new \Illuminate\Support\ViewErrorBag)->put('default', $bag);
            })); // Add errors to the session, if any
        }

        return $inertia->toResponse(request())->setStatusCode($statusCode); // Return a regular Laravel response
    }
}

Congratulations! Your app now supports two-factor authentication. You should probably tweet it out so your users can start enjoying improved security while using your app.