Laravel Fortify already has first support for 2fa inbuilt which you would have noticed in the Laravel Jetstream implementation of Fortify. This is a great initiative from Taylor and the Laravel team.
The only downside to the implementation while being the most secure, is that it requires use of an Authenticator application e.g. Google Auth. While majority of tech-savvy users would likely have an Authenticator application installed, most non-technical users wouldn't and are therefore unlikely to enable 2fa.
Luckily, Laravel makes it easy to extend its functionalities to suit your own needs.
We’ll start a blank Laravel application with Jetstream already set up. I will be using Inertia stack but the steps are not specific to Inertia
laravel new 2fa-test --jet --stack inertia --github
cd 2fa-test
Next, we create a migration to add phone
column to users
table.
php artisan make:migration add_phone_column_to_users_table
<?php
//database/migrations/2022_08_21_213715_add_phone_column_to_users_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone')->nullable()->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['phone']);
});
}
};
Next we need to add phone
to the fillable properties in the User Model. For brevity (and throughout the post) I have omitted parts not relevant to the referenced change but you can see the repository here for the class in full.
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'phone',
];
}
We then need to update the fortify actions to save phone during registration and profile update. I have used very basic string validation but you should properly validate phone numbers e.g using Laravel Phone.
<?php
//app/Actions/Fortify/CreateNewUser.php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'phone' => ['nullable', 'string', 'min:10', 'max:25'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'phone' => $input['phone'] ?? '',
'password' => Hash::make($input['password']),
]);
}
}
<?php
//app/Actions/Fortify/UpdateUserProfileInformation.php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
public function update(mixed $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'phone' => ['nullable', 'string', 'min:10', 'max:25'],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'phone' => $input['phone'] ?? '',
])->save();
}
}
protected function updateVerifiedUser(mixed $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'phone' => $input['phone'] ?? '',
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
We then need to update the resource files to include the phone
field during registration and profile update.
Rather than break the existing flow, We will opt here to instead hook onto the 2fa dispatched events which you can find in the associated pr here i.e \Laravel\Fortify\Events\TwoFactorAuthenticationChallenged
and \Laravel\Fortify\Events\TwoFactorAuthenticationEnabled
.
We'll listen for these to the EventServiceProvider but first we need to generate a listener.
php artisan make:listener SendTwoFactorCodeListener
NB: I will use the same listener for demonstration purposes
<?php
namespace App\Listeners;
use App\Notifications\SendOTP;
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled;
class SendTwoFactorCodeListener
{
public function handle(
TwoFactorAuthenticationChallenged|TwoFactorAuthenticationEnabled $event
): void {
$event->user->notify(app(SendOTP::class));
}
}
We are resolving the notification from the container instead of newing it up to take advantage of dependency injection.
Don't worry we shall be creating the notification shortly.
Then register the listener to listen for the above events in the EventServiceProvider
<?php
namespace App\Providers;
use App\Listeners\SendTwoFactorCodeListener;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
TwoFactorAuthenticationChallenged::class => [
SendTwoFactorCodeListener::class,
],
TwoFactorAuthenticationEnabled::class => [
SendTwoFactorCodeListener::class,
],
];
}
Next we need to create an action class to handle generation of Time-based One-time Passwords. Looking closely at Fortify, we can see that it uses pragmarx/google2fa as a dependency to provide Google2FA engine and we'll be using the same to generate the otp for a given secret.
<?php
namespace App\Actions\TwoFactor;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
use PragmaRX\Google2FA\Google2FA;
class GenerateOTP
{
/**
* @throws IncompatibleWithGoogleAuthenticatorException
* @throws SecretKeyTooShortException
* @throws InvalidCharactersException
*/
public static function for(string $secret): string
{
return app(Google2FA::class)->getCurrentOtp($secret);
}
}
The default validity window for 2fa code is 1 minute which while ideal for Authentication based app based 2fa, we might need to update fortify.features.two-factor-authentication.window
config value to account for your preferred delivery method delays. I will use 3 but your mileage may vary.
//config/fortify.php
return [
//other option here
'features' => [
Features::registration(),
Features::resetPasswords(),
// Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
'window' => 3, // <-- uncomment and change this
]),
],
];
Finally we need to generate the notification to send out to the user.
php artisan make:notification SendOTP
For this notification, $notifiable shall be an instance of \App\Models\User
being notified and we shall get the user secret by decrypting the two_factor_secret
attribute on the user model. We may throw an exception or bail out of two_factor_secret
is null but I will that decision to your use-case.
<?php
namespace App\Notifications;
use App\Actions\TwoFactor\GenerateOTP;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
class SendOTP extends Notification implements ShouldQueue
{
use Queueable;
public function __construct()
{
//
}
public function via(User $notifiable): array
{
return ['mail'];
}
public function toMail(User $notifiable)
{
return (new MailMessage)
->line('Your security code is '.$this->getTwoFactorCode($notifiable))
->action('Notification Action', url('/'))
->line('Thank you for using our application!');
}
public function toArray(User $notifiable)
{
return [
//
];
}
/**
* @throws IncompatibleWithGoogleAuthenticatorException
* @throws SecretKeyTooShortException
* @throws InvalidCharactersException
*/
public function getTwoFactorCode(User $notifiable): ?string
{
if(!$notifiable->two_factor_secret){
return null;
}
return GenerateOTP::for(
decrypt($notifiable->two_factor_secret)
);
}
}
I will optionally cover below sending the otp via SMS using Africastalking as I really love their APIs but you can use your preferred provider in the next section.
Sending SMS notification
First we will require an SDK I developed that's specific to laravel
composer require samuelmwangiw/africastalking-laravel
php artisan vendor:publish --tag="africastalking-config"
Add the following to your .env.example
AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=
AFRICASTALKING_FROM=
Grab an API key from their sandbox environment.Optionally create an SMS alphanumeric or Short Code and populate both the API key and your chosen sender ID in the .env
.
AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=somereallylongandcomplexkeygoeshere
AFRICASTALKING_FROM=BILLION_DOLLAR_IDEA
Leave the username as sandbox
until when you'll be ready to launch in production.
Update the User Model to implement the SamuelMwangiW\Africastalking\Contracts\ReceivesSmsMessages
interface. This interface has a single method routeNotificationForAfricastalking
that returns the phone number value.
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notification;
use SamuelMwangiW\Africastalking\Contracts\ReceivesSmsMessages;
class User extends Authenticatable implements ReceivesSmsMessages
{
public function routeNotificationForAfricastalking(Notification $notification): string
{
return $this->phone;
}
}
Finally we need to update the notification to route to AfricastalkingChannel and a toAfricastalking
method that returns the message to be sent out.
<?php
namespace App\Notifications;
use App\Actions\TwoFactor\GenerateOTP;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
use SamuelMwangiW\Africastalking\Notifications\AfricastalkingChannel;
class SendOTP extends Notification implements ShouldQueue
{
use Queueable;
public function via(User $notifiable): array
{
return [AfricastalkingChannel::class];
}
public function toAfricastalking(User $notifiable): string
{
return "Hi {$notifiable->name}. Your login security code is {$this->getTwoFactorCode($notifiable)}";
}
/**
* @throws IncompatibleWithGoogleAuthenticatorException
* @throws SecretKeyTooShortException
* @throws InvalidCharactersException
*/
public function getTwoFactorCode(User $notifiable): ?string
{
if (!$notifiable->two_factor_secret) {
return null;
}
return GenerateOTP::for(
decrypt($notifiable->two_factor_secret)
);
}
}
Testing the workflow
Then scroll down to the Two Factor Authentication section and click Enable
You will be requested to confirm the password You should receive an Email notification and an SMS notification in the sandbox simulator
Enter the code received and your account should be 2fa enabled and a similar set of notifications will be sent out the next time you login.
I know the Post has a gazillion typos, hope you enjoyed despite the typos
Comments (2)