Time-based Laravel OTP Login
Tags: laravel, beginners, programming, php
Published: 2022-08-22 00:48:00

@techtoolindia recently did a great [tutorial on the topic](https://dev.to/techtoolindia/laravel-otp-login-tutorial-5ek1) 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](https://github.com/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](https://github.com/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

```bash
laravel new 2fa-test --jet --stack inertia --github
cd 2fa-test
```

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

```bash
php artisan make:migration add_phone_column_to_users_table
```

```php
<?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](https://github.com/samuelMwangiW/2fa-test) for the class in full.

```php
<?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](https://github.com/Propaganistas/Laravel-Phone).

```php
<?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
<?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](https://github.com/laravel/fortify/pull/338/files) 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. 

```bash
php artisan make:listener SendTwoFactorCodeListener
```
>NB: I will use the same listener for demonstration purposes

```php
<?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
<?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](https://github.com/laravel/fortify/blob/ebc9045ef7bd2a2a37d54dde9c5df7d1b9d48780/src/TwoFactorAuthenticationProvider.php#L25-L36) to provide Google2FA engine and we'll be using the same to generate the otp for a given secret.

```php
<?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.

```php
//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.
```bash
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
<?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](https://developers.africastalking.com/) 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
```bash
composer require samuelmwangiw/africastalking-laravel
php artisan vendor:publish --tag="africastalking-config"
```

Add the following to your `.env.example`
```ini
AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=
AFRICASTALKING_FROM=
```

Grab an API key from [their sandbox environment](https://account.africastalking.com/apps/sandbox).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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fb6elwsn6v11d830tpru.png)
![SMS Menu](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ctklz46sxk25yo5d167h.png)

```ini
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
<?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
<?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
![Registration Screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wld0nqk9d04gi2irkorz.png)
![Profile Update screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0shxsyuw63npb2c9am6m.png)

Then scroll down to the Two Factor Authentication section and click Enable
![Enable 2fa screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vw1e2i27769ufmmvzud9.png)
 
 You will be requested to confirm the password
![Simulator SMS Screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mv08k79u2qo5r72q08ec.png)
You should receive an Email notification and an SMS notification in the sandbox simulator
![Email Notification Screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gu1bihhsmn5woxhsofmh.png)
  
 
![Email Notification Screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q1qa6aubxpdw7ilcb28d.png)
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gkx2iv3wbg965vvduqgg.png)
 

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