Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
August 22, 2022 12:48 am GMT

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.

Well 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 --githubcd 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.phpuse 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.

<?phpnamespace 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.phpnamespace 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.phpnamespace 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

<?phpnamespace 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

<?phpnamespace 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.

<?phpnamespace 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.phpreturn [//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.

<?phpnamespace 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-laravelphp artisan vendor:publish --tag="africastalking-config"

Add the following to your .env.example

AFRICASTALKING_USERNAME=sandboxAFRICASTALKING_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.

API Key under settings
SMS Menu

AFRICASTALKING_USERNAME=sandboxAFRICASTALKING_API_KEY=somereallylongandcomplexkeygoeshereAFRICASTALKING_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.

<?phpnamespace 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.

<?phpnamespace 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

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


Original Link: https://dev.to/samuelmwangiw/time-based-laravel-otp-login-25nh

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To