Tangible Bytes

A Web Developer’s Blog

Laravel Fortify - making 2fa required

By default Fortify makes 2FA optional

This article details how to make it required - using email by default with TOTP optional.

This assumes the Breeze → Fortify migration is done: feature flag on, trait on User, two-factor columns migrated.

0 . Make sure domains match

I’m using a multisite setup and need to ensure the public and auth route handlers dont overlap

in routes/web.php I added

$adminDomain = preg_quote((string) config('app.admin_domain'), '/');
$publicSiteDomain = "(?!{$adminDomain}$)[a-z0-9.-]+";

Route::domain('{site:domain}')->where(['site' => $publicSiteDomain])->group(function () {

and in phpunit.xml I make sure the default domain is defined

<env name="APP_ENV" value="testing"/>
<env name="ADMIN_DOMAIN" value="admin.webinar.test"/>

Create the Email Notification

app/Notifications/EmailTwoFactorLoginNotification.php

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class EmailTwoFactorLoginNotification extends Notification
{
    use Queueable;

    public function __construct(
        protected string $url,
        protected ?string $ipAddress = null,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $message = (new MailMessage)
            ->subject('Confirm your login to '.config('app.name'))
            ->line('We received a login attempt for your account.')
            ->line('Click the button below to complete your sign in. The link expires in 15 minutes and can only be used once.');

        if ($this->ipAddress) {
            $message->line('Request IP address: '.$this->ipAddress);
        }

        return $message
            ->action('Complete sign in', $this->url)
            ->line('If you did not attempt to sign in, please tell an administrator.');
    }
}

1. Always challenge after a valid password

Override RedirectIfTwoFactorAuthenticatable

Create app/actions/Fortify/RedirectIfTwoFactorAuthenticatable.php

Note that this references an email action we will create later

namespace App\Actions\Fortify;
 
use App\Services\EmailTwoFactorAuthentication;
use Illuminate\Contracts\Auth\StatefulGuard;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable as BaseAction;
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
use Laravel\Fortify\LoginRateLimiter;
 
class RedirectIfTwoFactorAuthenticatable extends BaseAction
{
    public function __construct(
        StatefulGuard $guard,
        LoginRateLimiter $limiter,
        protected EmailTwoFactorAuthentication $emailTwoFactor,
    ) {
        parent::__construct($guard, $limiter);
    }
 
    public function handle($request, $next)
    {
        $user = $this->validateCredentials($request);
 
        return $this->twoFactorChallengeResponse($request, $user);
    }
 
    protected function twoFactorChallengeResponse($request, $user)
    {
        $request->session()->put([
            'login.id' => $user->getKey(),
            'login.remember' => $request->boolean('remember'),
        ]);
 
        if (! $user->usesTotp()) {
            $this->emailTwoFactor->sendLoginLink($user, $request->ip());
        }
 
        TwoFactorAuthenticationChallenged::dispatch($user);
 
        return $request->wantsJson()
            ? response()->json(['two_factor' => true])
            : redirect()->route('two-factor.login');
    }
}

2. Register the action

// FortifyServiceProvider::boot()
Fortify::redirectUserForTwoFactorAuthenticationUsing(
    \App\Actions\Fortify\RedirectIfTwoFactorAuthenticatable::class
);
 
Fortify::twoFactorChallengeView(function (Request $request) {
    $user = User::find($request->session()->get('login.id'));
 
    return Inertia::render('Auth/TwoFactorChallenge', [
        'usesTotp' => (bool) $user?->usesTotp(),
        'status' => $request->session()->get('status'),
    ]);
});


Fortify::twoFactorChallengeView(function (Request $request) {
       $user = User::find($request->session()->get('login.id'));

       return Inertia::render('Auth/TwoFactorChallenge', [
           'usesTotp' => (bool)$user?->usesTotp(),
           'status' => $request->session()->get('status'),
       ]);
});

3. Helper on User

public function usesTotp(): bool
{
    return ! is_null($this->two_factor_secret)
        && ! is_null($this->two_factor_confirmed_at);
}

Cache a hashed token, send a signed URL. The link is useless without the login.id session cookie from the same browser.

namespace App\Services;
 
use App\Models\User;
use App\Notifications\EmailTwoFactorLoginNotification;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;

 
class EmailTwoFactorAuthentication
{
    const LINK_LIFETIME_MINUTES = 15;
 
    public function sendLoginLink(User $user, ?string $ipAddress = null): void
    {
        $token = Str::random(64);
 
        Cache::put(
            'two_factor_email:'.$user->getKey(),
            hash('sha256', $token),
            now()->addMinutes(self::LINK_LIFETIME_MINUTES)
        );
 
        $url = URL::temporarySignedRoute(
            'two-factor.email.verify',
            now()->addMinutes(self::LINK_LIFETIME_MINUTES),
            ['user' => $user->getKey(), 'token' => $token]
        );
 
        $user->notify(new EmailTwoFactorLoginNotification($url, $ipAddress));
    }
 
    public function verify(User $user, string $token): bool
    {
        $key = 'two_factor_email:'.$user->getKey();
        $cached = Cache::get($key);
 
        if (! $cached || ! hash_equals($cached, hash('sha256', $token))) {
            return false;
        }
 
        Cache::forget($key);
 
        return true;
    }
}

Create routes/auth.php.


<?php

use App\Http\Controllers\Auth\EmailTwoFactorChallengeController;
use Illuminate\Support\Facades\Route;

/*

| Login, logout, password reset/confirm/update and TOTP two-factor routes
| are registered by Laravel Fortify (see config/fortify.php ). Only the email-link second factor
| routes live here. 
|
*/

Route::middleware('guest')->group(function () {
    Route::get('two-factor-email/{user}/{token}', [EmailTwoFactorChallengeController::class, 'verify'])
        ->middleware(['signed', 'throttle:two-factor'])
        ->name('two-factor.email.verify');

    Route::post('two-factor-email/resend', [EmailTwoFactorChallengeController::class, 'resend'])
        ->middleware('throttle:two-factor')
        ->name('two-factor.email.resend');
});

Route::middleware('guest')->group(function () {
    Route::get('two-factor-email/{user}/{token}', [EmailTwoFactorChallengeController::class, 'verify'])
        ->middleware(['signed', 'throttle:two-factor'])
        ->name('two-factor.email.verify');
 
    Route::post('two-factor-email/resend', [EmailTwoFactorChallengeController::class, 'resend'])
        ->middleware('throttle:two-factor')
        ->name('two-factor.email.resend');
});

app/Http/Controllers/EmailTwoFactorChallengeController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Providers\RouteServiceProvider;
use App\Services\EmailTwoFactorAuthentication;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;

class EmailTwoFactorChallengeController extends Controller
{
    public function __construct(
        protected EmailTwoFactorAuthentication $emailTwoFactor,
    )
    {
    }

    /**
     * Complete login via the emailed one-time link.
     */
    public function verify(Request $request, User $user, string $token): RedirectResponse
    {
        // The link only works in the browser that started the login,
        // so a stolen link is useless without the session cookie too.
        if ((int)$request->session()->get('login.id') !== $user->getKey()) {
            return redirect()->route('login')
                ->withErrors(['email' => 'This sign-in link was opened in a different browser. Please log in again and open the link on the same device.']);
        }

        if (!$this->emailTwoFactor->verify($user, $token)) {
            return redirect()->route('two-factor.login')
                ->withErrors(['link' => 'This sign-in link is invalid, expired, or has already been used.']);
        }

        event(new ValidTwoFactorAuthenticationCodeProvided($user));

        $remember = $request->session()->pull('login.remember', false);
        $request->session()->forget('login.id');

        Auth::guard('web')->login($user, $remember);

        $request->session()->regenerate();

        return redirect()->intended(RouteServiceProvider::HOME);
    }

    /**
     * Resend the one-time link to the challenged user.
     */
    public function resend(Request $request): RedirectResponse
    {
        $user = User::find($request->session()->get('login.id'));

        if (!$user) {
            return redirect()->route('login');
        }

        if (!$user->usesTotp()) {
            $this->emailTwoFactor->sendLoginLink($user, $request->ip());
        }

        return back()->with('status', 'two-factor-email-sent');
    }
}

Fire ValidTwoFactorAuthenticationCodeProvided so Fortify’s listeners (session flags, etc.) still run.

6. Challenge page

Show the TOTP form or “check your email” based on usesTotp. resources/js/Pages/Auth/TwoFactorChallenge.jsx

/* global route */

import React from 'react';
import Alert from 'react-bootstrap/Alert';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';

import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react';

export default function TwoFactorChallenge({ usesTotp, status, errors }) {
    const { data, setData, post, processing } = useForm({
        code: '',
        recovery_code: '',
    });

    const resendForm = useForm({});

    const submit = (e) => {
        e.preventDefault();
        post(route('two-factor.login'));
    };

    const resend = (e) => {
        e.preventDefault();
        resendForm.post(route('two-factor.email.resend'));
    };

    return (
        <GuestLayout>
            <Head title="Two-factor authentication" />
            <h2 className="mt-5">Two-factor authentication</h2>

            {status === 'two-factor-email-sent' &&
                <Alert variant="success">A new sign-in link has been sent to your email address.</Alert>
            }

            {errors.link &&
                <Alert variant="danger">{errors.link}</Alert>
            }

            {usesTotp ? (
                <Form onSubmit={submit} className="w-100">
                    <p>Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>

                    <p>
                        <Form.Group controlId="code">
                            <Form.Label>Authentication code</Form.Label>
                            <Form.Control
                                type="text"
                                inputMode="numeric"
                                autoComplete="one-time-code"
                                value={data.code}
                                isFocused={true}
                                onChange={(e) => setData('code', e.target.value)}
                            />
                            {errors.code &&
                                <Alert variant="danger">{errors.code}</Alert>
                            }
                        </Form.Group>
                    </p>

                    <p>
                        <Form.Group controlId="recovery_code">
                            <Form.Label>Recovery code</Form.Label>
                            <Form.Control
                                type="text"
                                value={data.recovery_code}
                                onChange={(e) => setData('recovery_code', e.target.value)}
                            />
                            {errors.recovery_code &&
                                <Alert variant="danger">{errors.recovery_code}</Alert>
                            }
                        </Form.Group>
                    </p>

                    <Button className="d-block mx-auto my-3" type="submit" disabled={processing}>
                        Continue
                    </Button>
                </Form>
            ) : (
                <>
                    <Alert variant="info">
                        We have emailed you a one-time sign-in link. Click the link in that email
                        (in this browser) to finish logging in.
                    </Alert>
                    <p>The link expires in 15 minutes and can only be used once.</p>
                    <Form onSubmit={resend}>
                        <Button variant="outline-primary" type="submit" disabled={resendForm.processing}>
                            Resend sign-in link
                        </Button>
                    </Form>
                </>
            )}
        </GuestLayout>
    );
}

TOTP POST still goes to Fortify’s two-factor.login. Only the email path is custom.

7. Assert it in tests

use Illuminate\Support\Facades\Notification;


public function test_valid_password_leads_to_two_factor_challenge_not_login(): void
{
    Notification::fake();
    $user = User::factory()->create();
 
    $response = $this->post('/', [
        'email' => $user->email,
        'password' => 'password',
    ]);
 
    $this->assertGuest();
    $response->assertRedirect(route('two-factor.login'));
    Notification::assertSentTo($user, EmailTwoFactorLoginNotification::class);
}
 
public function test_totp_users_are_challenged_without_email(): void
{
    Notification::fake();
    $user = User::factory()->create();
    $user->forceFill([
        'two_factor_secret' => encrypt('secret'),
        'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
        'two_factor_confirmed_at' => now(),
    ])->save();
 
    $this->post('/', ['email' => $user->email, 'password' => 'password']);
 
    $this->assertGuest();
    Notification::assertNothingSent();
}

A correct password must never authenticate. TOTP users must not get the email.

TOTP-only alternative (no email)

If you do not want the email fallback, still override the action to always challenge, and add a gate after login that forces /user/two-factor-authentication until two_factor_confirmed_at is set. Otherwise first-time users cannot complete the TOTP form.

Fortify still will not require 2FA for you. The action override is the requirement; the email link (or a setup gate) is how users who have no authenticator yet get through.