DEV Community

Samuel Mwangi
Samuel Mwangi

Posted on

Time-based Laravel OTP Login

@techtoolindia recently did a great tutorial on the topic and this is my opinionated approach to achieve the same.

The main difference between my approach and theirs is that they save the code in the database while I rely on the same logic as Laravel Fortify to generate a Time-based One-time Password (TOTP) algorithm specified in RFC 6238. This is therefore not an option if your application does not use Laravel Fortify. I do not claim to offer the best solution and leaves the judgement to you the reader.

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
Enter fullscreen mode Exit fullscreen mode

Next, we create a migration to add phone column to users table.

php artisan make:migration add_phone_column_to_users_table
Enter fullscreen mode Exit fullscreen mode
<?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']);
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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']),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode
<?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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        ],
    ];
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ]),
    ],
];
Enter fullscreen mode Exit fullscreen mode

Finally we need to generate the notification to send out to the user.

php artisan make:notification SendOTP
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Add the following to your .env.example

AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=
AFRICASTALKING_FROM=
Enter fullscreen mode Exit fullscreen mode

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.

API Key under settings
SMS Menu

AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=somereallylongandcomplexkeygoeshere
AFRICASTALKING_FROM=BILLION_DOLLAR_IDEA
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

Testing the workflow

Registration Screen
Profile Update screen

Then scroll down to the Two Factor Authentication section and click Enable
Enable 2fa screen

You will be requested to confirm the password
Simulator SMS Screen
You should receive an Email notification and an SMS notification in the sandbox simulator
Email Notification Screen

Email Notification Screen
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.

Login 2fa Code verification

I know the Post has a gazillion typos, hope you enjoyed despite the typos

Oldest comments (0)