Skip to content
All posts
LaravelSecurityAI

Laravel Authentication OTP with Mobile Phone

April 10, 2022·Read on Medium·

Nowadays, there is a lot of application moving from traditional email/password to phone number/password as the main authentication. It is because the OTP method we could say it much more secure than email verification. Hence, the phone number has been the one of authentication ID to access sensitive information. In such situations, it’s vital for the system to verify that the phone numbers are valid and functional.

How to verify the validity of phone number?

Basically, the idea is the same behaviour like email verification but instead of using verification link, we use the OTP passcode to their phones and ask them to enter the passcode.

The flow is quite straight forward,

  1. User register with phone number and password
  2. System generate random 6 digit code and sent to user mobile user via SMS gateway
  3. User entered the code and phone number get verified

How to implement in Laravel

Currently, Laravel already provides a seamless email verification steps which can be implemented by few lines of codes rather than forcing you to re-implement this feature by hand for each application that you created. From that approach, we can simple mimic the code and make it as mobile phone number verification.

In order to send a sms to user’s phone, you need a SMS gateway. There are tons of SMS gateway services can be subscribe like Twillio, Vonage, etc.. Yes, you need to subscribe. SMS is not free.

We will use the default sms provider by Laravel framework which is Vonage. You might want to follow the prerequisites in Laravel documentation for the installation part and configuration of the sms settings.

This tutorial would be quite long but it quite simple.

Lets get started

For easy setup, i use JetStream + Fortify package to scaffold the authentication part.

Table Migration

For existing project, you can create a new migration file and alter the table to add new column for mobile phone.

php artisan make:migration alter_users_table_add_mobile_phone --table=users

Adjust the schema and add mobile phone column with nullable type.

Schema::table('users', function($table) {
$table->string('mobile_phone')->unique()->nullable();
$table->string('phone_verified_at')->nullable();

$table->integer('login_identifier')->default(1);
})

For new project, you can just adjust the default user migration file and add the column

Schema::create('users', function($table) {
....
$table->string('mobile_phone')->unique()->nullable();
$table->string('phone_verified_at')->nullable(); $table->integer('login_identifier')->default(1);
....
})

We mimic same column as how email verification in Laravel currently implemented and we add another extra column called “login_identifier” because to indicate which type of login — email or mobile phone. By default, 1 is for email and 2 is for mobile phone.

Next, we need a table that store generated passcode to be use for validation. We can create a model name PinToken

> php artisan make:model PinToken -m 

What we need in the PinToken table are

  • Who is requesting
  • When passcode is generated
  • When the passcode is expired
Schema::create('pin_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('referable');
$table->string('passcode')->index();
$table->dateTime('expired_at')->index();
$table->timestamps();
});

After migration success, open the model and edit like below

class PinToken extends Model
{
use HasFactory;

protected $fillable = [
'referable_id',
'referable_type',
'reference_type',
'reference_token',
'pin',
'expired_at',
];

protected $casts = [
'expired_at' => 'datetime'
];

public function referable()
{
return $this->morphTo();
}

public function isExpired()
{
return $this->expired_at->isPast();
}

public function extendExpiry($minutes = 2)
{
$this->update([
'expired_at' => now()->addMinutes($minutes)
]);
}
}

Now, we create a Trait to interact with PinToken model.

trait HasPinToken
{
public function pinTokens()
{
return $this->morphMany(PinToken::class, 'referable')->latest();
}

public function createPasscode($type = null)
{
return $this->pinTokens()->create([
'reference_token' => \Str::random(60),
'pin' => mt_rand(100000, 999999),
'expired_at' => now()->addMinutes(2),
'reference_type' => $type ?? 'verification-passcode'
]);
}
}

This trait now can be use inside User model.

Override Fortify Authentication

Behind the scene, Fortify automatically retrieve and authenticate the user based on the provided credentials and the authentication guard that is configured for your application. Thankfully, Fortify allows us to easily overwrite the authentication using the Fortify::authenticateUsing method.

Note: If you’re not using Fortify, you can read how the logic works and implement on your way

Overwrite callback

If we want to overwrite Fortify function, we need to do it at FortifyServiceProvider class inside boot method

Since we maintain the email login, we need to adjust the query to make mobile phone or email.

$user = \App\Models\User::query()
->where('email', $request->{Fortify::username()})
->orWhere('mobile_phone', $request->{Fortify::username()})
->first(); if (!\Hash::check($request->password, $user->password)) {
return false;
}

Then, since we have login_identifier column, we need to update the column what is current type of authentication used

tap($user)->update([
'login_identifier' => match ($request->{Fortify::username()}) {
$user->email => 1,
$user->mobile_phone => 2
}
])

Below are the full method changes,

FortifyServiceProvider.php View on GitHub
<?php
...
...
...
  
  
public function boot()
{
  ...
  ...

  Fortify::authenticateUsing(function (Request $request) {
        if (!$user = \App\Models\User::query()
            ->where(Fortify::username(), $request->{Fortify::username()})
            ->orWhere('mobile_phone', $request->{Fortify::username()})
            ->first()) {
            return false;
        }

        if (!\Hash::check($request->password, $user->password)) {
            return false;
        }

        return tap($user)->update([
            'login_identifier' => match ($request->{Fortify::username()}) {
                $user->email => 1,
                $user->mobile_phone => 2
            }
        ]);
    });

    ...
}

Add response singleton

Here is where we can mimic how Laravel handle singleton response. There are 2 singleton as we need for this to work — VerifyPhoneResponse and VerifyPhoneViewResponse. This should be handled in register method.

First, create an empty blade inside auth folder. Give a name as verify-phone.blade.php.

Then, create 2 contracts class named VerifyPhoneResponse and VerifyPhoneViewResponse.

interface VerifyPhoneResponse extends Responsable {}
interface VerifyPhoneViewResponse extends Responsable {}

Then, create a response class to handle response after verified

class VerifyPhoneResponse implements VerifyPhoneViewResponse
{
public function
toResponse($request)
{
return $request->wantsJson()
? new JsonResponse('', 204)
: redirect()->intended('/?verified=1');
}
}

Finally the result would be like this,

FortifyServiceProvider.php View on GitHub
<?php
...
public function register()
{
    $this->app->singleton(VerifyPhoneResponse::class, \App\Http\Responses\VerifyPhoneResponse::class);

    $this->app->singleton(VerifyPhoneViewResponse::class, function ($request, $token) {
        return view('auth.verify-phone', [
            'request' => $request,
            'token' => $token,
        ]);
    });
}
...

Create Traits, Contract and Notification class

Now create a contract class called MustVerifyPhone. Copy from MustVerifyEmail contract and adjust accordingly.

interface MustVerifyPhone
{
/**
* Determine if the user has verified their email address.
*
*
@return bool
*/
public function
hasVerifiedPhone();

/**
* Mark the given user's email as verified.
*
*
@return bool
*/
public function
markPhoneAsVerified();

/**
* Send the email verification notification.
*
*
@return void
*/
public function
sendPhoneVerificationNotification();

/**
* Get the email address that should be used for verification.
*
*
@return string
*/
public function
getPhoneForVerification();
}

Create a Trait class called MustVerifyPhone to handle the contract method

<?php

namespace
App\Models\Traits;

use App\Helpers\Utils;
use App\Models\PinToken;
use App\Notifications\VerifyPhoneNumber;

trait MustVerifyPhone
{
/**
* Determine if the user has verified their mobile phone.
*
*
@return bool
*/
public function
hasVerifiedPhone()
{
return ! is_null($this->phone_verified_at);
}

/**
* Mark the given user's mobile phone as verified.
*
*
@return bool
*/
public function
markPhoneAsVerified()
{
return $this->forceFill([
'phone_verified_at' => $this->freshTimestamp(),
])->save();
}

/**
* Send the mobile phone verification notification.
*
*
@return void
*/
public function
sendPhoneVerificationNotification()
{
$token = $this->pinTokens()->first();

if (!$token || $token->isExpired()) {
$this->notify(new VerifyPhoneNumber(($token = $this->createPasscode())->pin));
}

return $token;
}

/**
* Get the mobile phone that should be used for verification.
*
*
@return string
*/
public function
getPhoneForVerification()
{
return $this->mobile_phone;
} /**
* Route notifications for the Vonage channel.
*
*
@param \Illuminate\Notifications\Notification $notification
*
@return string
*/
public function
routeNotificationForVonage($notification)
{
return $this->getPhoneForVerification();
}
}

In order to send a SMS to the user, we need a notification function. We can simply run a command to create notification

php artisan make:notification VerifyPhoneNumber

Go to the VerifyPhoneNumber and edit the class

class VerifyPhoneNumber extends Notification
{
use Queueable;

private $passcode;

/**
* Create a new notification instance.
*
*
@return void
*/
public function __construct($passcode)
{
$this->passcode = $passcode;
}


/**
* Get the notification's delivery channels.
*
*
@param mixed $notifiable
*
@return array
*/
public function via($notifiable)
{
return ['vonage'];
}


/**
* Get the mail representation of the notification.
*
*
@param mixed $notifiable
*
@return VonageMessage
*/
public function toVonage($notifiable)
{
return (new VonageMessage())
->content(__("Your verification code is :code", [
'code' => $this->passcode
]));
}

}
Note: Please be inform that above notification class, you need to identify what is your SMS gateway you want to use and implement on your own notification channel

Create verification controller

Now, create a controller named VerifyPhoneController or any name you preferred. We need 3 methods: -

  1. Submit verification — where to validate the validity of the pin number. IF the verified user tried to submit, we will redirect to home page. The submission must have special string token (reference token) and the pin number sent to their mobile phone. We need the reference token to check the validity of the pin number must be match with the reference token. IF match, we need to delete it then mark as verified.
  2. Resend TAC passcode — where the user can request for passcode incase the SMS is not received in the certain amount of time. IF the verified user tried to request resend passcode, we will redirect to home page.
  3. Display verification page — where the page for user to key-in their passcode number sent to their mobile phone. IF the verified user tried to enter this page, we will redirect to home page again.

For the submission, we need to validate the incoming request. Create a request class by command

php artisan make:request VerifyPhoneRequest

The submission must have 2 value which is passcode and reference_token. Then add a method to verify the passcode either match with the reference token or already expired.

VerifyPhoneRequest.php View on GitHub
<?php

class VerifyPhoneRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'passcode' => ['required', 'size:6'],
            'reference_token' => ['required']
        ];
    }

    public function verifyReferenceToken()
    {
        if (!$token = request()->user()->pinTokens()->where('expired_at', '>', now())->where('reference_token', $this->reference_token)
            ->where('pin', $this->passcode)
            ->where('reference_type', 'verification-passcode')
            ->first()) {

            $this->validator->errors()->add('token', 'Invalid Token or already expired');

            throw new ValidationException($this->validator);
        }

        $token->delete();
    }
}

Use the VerifyPhoneRequest class to the verification method. We used request class because we want to make the controller looks clean. Below are the full snippet of code of the controller.

VerifyPhoneController.php View on GitHub
<?php 

class VerifyPhoneController extends Controller
{
    /**
     * Mark the authenticated user's email address as verified.
     *
     * @param VerifyPhoneRequest $request
     * @return VerifyPhoneResponse
     */
    public function verify(VerifyPhoneRequest $request)
    {
        $request->verifyReferenceToken();

        if ($request->user()->hasVerifiedPhone()) {
            return app(VerifyPhoneResponse::class);
        }

        if ($request->user()->markPhoneAsVerified()) {
            event(new Verified($request->user()));
        }

        return app(VerifyPhoneResponse::class);
    }

    /**
     * Send a new email verification notification.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return JsonResponse|\Illuminate\Http\RedirectResponse|\Illuminate\Http\Response
     */
    public function resubmit(Request $request)
    {
        if ($request->user()->hasVerifiedPhone()) {
            return $request->wantsJson()
                ? new JsonResponse('', 204)
                : redirect()->intended('/');
        }

        $request->user()->sendPhoneVerificationNotification();

        return $request->wantsJson()
            ? new JsonResponse('', 202)
            : back()->with('status', 'verification-code-sent');
    }

    /**
     * Display the email verification prompt.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse|VerifyEmailViewResponse
     */
    public function view(Request $request)
    {
        return $request->user()->hasVerifiedPhone()
            ? redirect()->intended('/')
            : app(VerifyPhoneViewResponse::class, [
                $request->user()->sendPhoneVerificationNotification()->reference_token
            ])
    }
}

Create verification routes

We will need 3 routes based on our controller:-

routes.php View on GitHub
<?php

Route::get('/phone/verify', [\App\Http\Controllers\VerifyPhoneController::class, 'view'])
    ->middleware([config('fortify.auth_middleware', 'auth').':'.config('fortify.guard')])
    ->name('phone.verification.notice');

Route::post('/phone/verify', [\App\Http\Controllers\VerifyPhoneController::class, 'verify'])
    ->middleware([config('fortify.auth_middleware', 'auth').':'.config('fortify.guard'), 'signed', 'throttle:'.config('fortify.limiters.verification', '6,1')]);

Route::post('/phone/verification-notification', [\App\Http\Controllers\VerifyPhoneController::class, 'store'])
    ->middleware([config('fortify.auth_middleware', 'auth').':'.config('fortify.guard'), 'throttle:'.config('fortify.limiters.verification', '6,1')])
    ->name('phone.verification.send');

Create verification blade page view

Note: Tailwind + AlpineJS

The idea is where we need to design the verification page like above. To make it short, i already designed for you. Below are the snippet

verify-phone.blade.php View on GitHub
<x-guest-layout>
    <x-jet-authentication-card>
        <x-slot name="logo">
            <x-jet-authentication-card-logo />
        </x-slot>

        <div class="mb-4 text-sm text-gray-600">
            {{ __('Could you verify your phone by entering the passcode we just sms to you? If you didn\'t receive the sms, we will gladly send you another.') }}
        </div>

        @if (session('status') == 'verification-code-sent')
            <div class="mb-4 font-medium text-sm text-green-600">
                {{ __('A new verification code has been sent to your mobile phone number provided during registration.') }}
            </div>
        @endif

        @if ($errors->has('token'))
            <div class="mb-4 font-medium text-sm text-red-600">
                {{ $errors->first('token') }}
            </div>
        @endif

        <div class="mt-4 items-center justify-between">
            <div class="block">
                <x-jet-label for="email" value="{{ __('Enter Passcode') }}" />
                <div class="flex items-center justify-center mt-4 mb-[50px]" id="pin_code_field" x-data="{
                        pinSize: 6,
                        isLoading: false,
                        passcode: '',
                        resetValue(i) {
                            this.passcode = '';
                            document.getElementById(`codeIndex_${i}`).value = '';
                        },
                        stepForward(i) {
                            this.passcode = '';

                            let elem = document.getElementById(`codeIndex_${i}`);
                            let elemNext = document.getElementById(`codeIndex_${i+1}`);

                            if (i != this.pinSize - 1 && elem.value) {
                                if (elem.value.length > 1) {
                                    elem.value = elem.value[elem.value.length - 1];
                                }

                                elemNext.focus();
                                elemNext.value = '';
                            }

                            this.checkPin();
                        },
                        stepBack(i) {
                            this.passcode = '';
                            if (i != 0) {
                                document.getElementById(`codeIndex_${i}`).value = '';
                                document.getElementById(`codeIndex_${i-1}`).focus();
                            }
                        },
                        checkPin() {
                            let code = '';
                            for (i = 0; i < this.pinSize; i++) {
                                if (document.getElementById(`codeIndex_${i}`).value) {
                                    code += document.getElementById(`codeIndex_${i}`).value;
                                }
                            }

                            if (code.length == this.pinSize) {
                                document.getElementById('pin_code_field').querySelector(':focus').blur();
                                this.validatePin(code);
                            }
                        },
                        validatePin(code) {
                            this.passcode = code;
                            document.getElementById('form-token').querySelector('input[name=passcode]').value = code;
                            this.isLoading = true;
                            document.getElementById('form-token').submit();
                        }
                    }" x-init="() => {
                        setTimeout(() => {
                            document.getElementById(`codeIndex_0`).focus();
                        }, 500);
                    }">
                    <form method="POST" action="{{ URL::temporarySignedRoute('phone.verification.notice', now()->addMinutes(2), ['reference_token' => $token]) }}" id="form-token">
                        @csrf
                        <x-honeypot />
                        <input type="hidden" name="passcode" x-model="passcode">
                    </form>

                    <template x-for="(l,index) in pinSize" :key="`codeIndex_${index}`">
                        <input :autofocus="index === 0" :id="`codeIndex_${index}`"
                               type="number"
                               inputmode="numeric" pattern="[0-9]*"
                               maxlength="1"
                               class="h-14 w-12 border mx-2 rounded-lg flex items-center text-center font-thin text-3xl appearance-none disabled:bg-gray-200 placeholder:text-gray-300/60"
                               max="9" min="0" @keyup="stepForward(index)" step="1"
                               @keydown.backspace="stepBack(index)" @focus="resetValue(index)" :disabled="isLoading" placeholder="-"/>
                    </template>
                </div>
            </div>
        </div>

        <div class="mt-4 flex items-center justify-between">
            <form method="POST" action="{{ route('phone.verification.send') }}">
                @csrf
                <x-honeypot />
                <div>
                    <button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900">
                        {{ __('Not received passcode? Click Here') }}
                    </button>
                </div>
            </form>

            <form method="POST" action="{{ route('logout') }}">
                @csrf

                <button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900">
                    {{ __('Log Out') }}
                </button>
            </form>
        </div>
    </x-jet-authentication-card>
</x-guest-layout>

You can replace the 6 boxes into text field if you want to. It designed just to make it cool 😎

Create a middleware

Now the finishing, to make sure the other route are protected from unverified user, we need to create a new middleware to check the status of the mobile phone.

With command

php artisan make:middleware EnsurePhoneIsVerified

The middleware is just like current used for email,

class EnsurePhoneIsVerified
{

public function
handle(Request $request, Closure $next)
{
if (!$request->user()) {
abort(403);
}

if ($request->user()->login_identifier == 2
&& $request->user() instanceof MustVerifyPhone
&& !$request->user()->hasVerifiedPhone()) {

return $request->expectsJson()
? abort(403, 'Your phone number is not verified.')
: Redirect::guest('/');
}

return $next($request);
}
}

After that, register the middleware in the Kernel.php inside $routeMiddleware

protected $routeMiddleware = [
...
...
'verified' => EnsurePhoneIsVerified::class,
...
];

You might notice that if you’re using jetstream, “verified” already used by another. How about we combine the middleware into one.

Let’s rename the middleware EnsurePhoneIsVerified into EnsureEmailOrPhoneIsVerified

class EnsurePhoneIsVerified
{

public function
handle(Request $request, Closure $next)
{
if (!$request->user()) {
abort(403);
} // IF current identifier is email, this will be triggered
if ($request->user()->login_identifier == 1
&& $request->user() instanceof MustVerifyEmail
&& !$request->user()->hasVerifiedEmail()) {

return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::guest('/');
}
// IF current identifier is phone, this will be triggered
if ($request->user()->login_identifier == 2
&& $request->user() instanceof MustVerifyPhone
&& !$request->user()->hasVerifiedPhone()) {

return $request->expectsJson()
? abort(403, 'Your phone number is not verified.')
: Redirect::guest('/');
}


return $next($request);
}
}

Ok we done enough now. You can now use the middleware along with “auth” middleware for example:-

Route::middleware(['auth', 'verified'])->group(function() {....})

At this point, you might be able to demo the mobile phone authentication. If you encounter any errors, may drop a comment or google it 😅. That’s all guys! Thanks for your time

References

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium