How to Secure Laravel Apps with 2FA via SMS

Younes Rafie
Share

This article was peer reviewed by Christopher Thomas. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!


While everyone is concerned about their application’s security, few take it seriously and take the plunge. The first thing you’ll notice when learning about this is that two factor authentication (2FA) is the go-to solution as a first step.

Although there have been some serious problems with using text messages as a second factor, it’s definitely safer than a plain username and password combination, given that many users tend to use popular and easy to guess passwords for critical services such as payments, chat, emails, etc. In this article, we’re going to build two factor authentication into a Laravel application using Twilio SMS as the second factor.

Twilio and Laravel

What We’re Building

There’s a great chance that you’re already familiar with the 2FA flow:

  • User visits the login page.

  • He types in an email and a password.

    Login form

  • We send a verification code using the phone number.

    2FA verification code SMS

  • User must type in the received code.

    Type verification code

  • If the code is correct, we log them in. Otherwise, we give them another chance to try logging in.

    Dashboard

The final demo application is up on GitHub.

Installation

I assume you have your development environment already set up. If not, we recommend Homestead Improved for an easy start.

Go ahead and create a new Laravel project using the Laravel installer or via Composer.

laravel new demo

Or

composer create-project --prefer-dist laravel/laravel demo

Edit the .env file and add your database credentials.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=root
DB_PASSWORD=root

Scaffolding Authentication

Before creating our migrations, keep in mind that Laravel has a command to help us scaffold our authentication flow. It generates the following:

  • Login, register and reset password views and controllers.
  • The necessary routes.

Go ahead and run php artisan make:auth from the command line.

Creating Migrations

Update the users migration class and add a country_code and phone fields.

// database/migrations/2014_10_12_000000_create_users_table.php

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->string('country_code', 4)->nullable();
            $table->string('phone')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

Every user has a list of tokens (verification codes) that they generated. Run the php artisan make:model Token -m command to generate the model and migration file. The table schema will look like this:

// database/migrations/2016_12_14_105000_create_tokens_table.php

class CreateTokensTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tokens', function (Blueprint $table) {
            $table->increments('id');
            $table->string('code', 4);
            $table->integer('user_id')->unsigned();
            $table->boolean('used')->default(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('tokens');
    }
}

I limited the verification code to four digits, but you can make it harder to guess by increasing it. We will get back to this point later. Let’s run php artisan migrate to create our database.

Updating Models

The models are already there and should only be updated accordingly:

// app/User.php

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'country_code',
        'phone'
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * User tokens relation
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function tokens()
    {
        return $this->hasMany(Token::class);
    }

    /**
     * Return the country code and phone number concatenated
     *
     * @return string
     */
    public function getPhoneNumber()
    {
        return $this->country_code.$this->phone;
    }
}

There’s nothing special here, we just added the users -> tokens relation and added the getPhoneNumber helper method to get the full phone number of a user.

// app/Token.php

class Token extends Model
{
    const EXPIRATION_TIME = 15; // minutes

    protected $fillable = [
        'code',
        'user_id',
        'used'
    ];

    public function __construct(array $attributes = [])
    {
        if (! isset($attributes['code'])) {
            $attributes['code'] = $this->generateCode();
        }

        parent::__construct($attributes);
    }

    /**
     * Generate a six digits code
     *
     * @param int $codeLength
     * @return string
     */
    public function generateCode($codeLength = 4)
    {
        $min = pow(10, $codeLength);
        $max = $min * 10 - 1;
        $code = mt_rand($min, $max);

        return $code;
    }

    /**
     * User tokens relation
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * True if the token is not used nor expired
     *
     * @return bool
     */
    public function isValid()
    {
        return ! $this->isUsed() && ! $this->isExpired();
    }

    /**
     * Is the current token used
     *
     * @return bool
     */
    public function isUsed()
    {
        return $this->used;
    }

    /**
     * Is the current token expired
     *
     * @return bool
     */
    public function isExpired()
    {
        return $this->created_at->diffInMinutes(Carbon::now()) > static::EXPIRATION_TIME;
    }
}

Besides setting the relations methods and updating the fillable attributes, we’ve added:

  • a constructor to set the code property on creation.
  • a generateCode method to generate random digits depending on the code length parameter.
  • the isExpired method to see if the link has expired using the EXPIRATION_TIME constant.
  • the isValid method to see if the link is neither expired nor used.

Creating Views

The register view file should be updated to include a country code and a phone field.

// resources/views/auth/register.blade.php

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-heading">Register</div>
                <div class="panel-body">
                    @include("partials.errors")

                    <form class="form-horizontal" role="form" method="POST" action="{{ url('/register') }}">
                        // ...

                        <div class="form-group">
                            <label for="phone" class="col-md-4 control-label">Phone</label>

                            <div class="col-md-6">

                                <div class="input-group">
                                    <div class="input-group-addon">
                                        <select name="country_code" style="width: 150px;">
                                            <option value="+1">(+1) US</option>
                                            <option value="+212">(+212) Morocco</option>
                                        </select>
                                    </div>
                                    <input id="phone" type="text" class="form-control" name="phone" required>

                                    @if ($errors->has('country_code'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('country_code') }}</strong>
                                    </span>
                                    @endif
                                    @if ($errors->has('phone'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('phone') }}</strong>
                                    </span>
                                    @endif
                                </div>
                            </div>
                        </div>

                        // ...
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Then, we create a new view for the user to type the verification code in.

// resources/views/auth/code.blade.php

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="panel panel-default">
                    <div class="panel-heading">Login</div>
                    <div class="panel-body">
                        @include("partials.errors")

                        <form class="form-horizontal" role="form" method="POST" action="{{ url('/code') }}">
                            {{ csrf_field() }}

                            <div class="form-group">
                                <label for="code" class="col-md-4 control-label">Four digits code</label>

                                <div class="col-md-6">
                                    <input id="code" type="text" class="form-control" name="code" value="{{ old('code') }}" required autofocus>

                                    @if ($errors->has('code'))
                                        <span class="help-block">
                                        <strong>{{ $errors->first('code') }}</strong>
                                    </span>
                                    @endif
                                </div>
                            </div>

                            <div class="form-group">
                                <div class="col-md-8 col-md-offset-4">
                                    <button type="submit" class="btn btn-primary">
                                        Login
                                    </button>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

The errors.blade.php partial will print the list of validation errors.

// resources/views/errors.blade.php

@if (count($errors) > 0)
    <div class="alert alert-danger ">
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span></button>
        <ul>
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

Creating Controllers

Instead of creating new controllers, why not reuse the auth controllers? There isn’t much to add, after all!

The RegisterController@register method is called when the user posts the form, and if you open the file you’ll find that it calls a registered method after the user has been created.

// app/Http/Controllers/RegisterController.php

class RegisterController extends Controller
{

    // ...
    protected function registered(Request $request, $user)
    {
        $user->country_code = $request->country_code;
        $user->phone = $request->phone;
        $user->save();
    }
}

We also need to update the request validation and make the country code and phone fields required.

// app/Http/Controllers/RegisterController.php

class RegisterController extends Controller
{

    // ...
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name'         => 'required|max:255',
            'email'        => 'required|email|max:255|unique:users',
            'password'     => 'required|min:6|confirmed',
            'country_code' => 'required',
            'phone'        => 'required'
        ]);
    }
}

Now we need to update the LoginController and override the login method.

// app/Http/Controllers/LoginController.php

class LoginController extends Controller
{

    // ...
    public function login(Request $request)
    {
        $this->validateLogin($request);

        //retrieveByCredentials
        if ($user = app('auth')->getProvider()->retrieveByCredentials($request->only('email', 'password'))) {
            $token = Token::create([
                'user_id' => $user->id
            ]);

            if ($token->sendCode()) {
                session()->set("token_id", $token->id);
                session()->set("user_id", $user->id);
                session()->set("remember", $request->get('remember'));

                return redirect("code");
            }

            $token->delete();// delete token because it can't be sent
            return redirect('/login')->withErrors([
                "Unable to send verification code"
            ]);
        }

        return redirect()->back()
            ->withInputs()
            ->withErrors([
                $this->username() => Lang::get('auth.failed')
            ]);
    }
}

After validating the request, we try to retrieve a user using the email and password. If the user exists, we create a new token for this user, then we send the code, set the necessary session details, and redirect to the code page.

Ooh wait! We didn’t define a sendCode method inside the Token model?

Adding Twilio

Before sending the code to the user via SMS, we need to configure Twilio for the job. We need to create a new trial account.

After that, go to the Twilio Console page and copy your account ID and auth token. The last part is to create a new phone number for sending SMSes. Go to the Console’s Phone Numbers Page and create a new one.

TWILIO_SID=XXXXXXXXXXX
TWILIO_TOKEN=XXXXXXXXXXXX
TWILIO_NUMBER=+XXXXXXXXXX

Twilio has an official PHP package that we can use.

composer require twilio/sdk

To use the Twilio package, we’re going to create a new provider and bind it to the container:

pgp artisan make:provider TwilioProvider
// app/Providers/TwilioProvider.php

use Illuminate\Support\ServiceProvider;
use Twilio\Rest\Client;

class TwilioProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {}

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind('twilio', function() {
            return new Client(env('TWILIO_SID'), env('TWILIO_TOKEN'));
        });
    }
}

Now, we can finally go back to our sendCode method:

// app/Token.php

class Token extends Model
{
    //...
    public function sendCode()
    {
        if (! $this->user) {
            throw new \Exception("No user attached to this token.");
        }

        if (! $this->code) {
            $this->code = $this->generateCode();
        }

        try {
            app('twilio')->messages->create($this->user->getPhoneNumber(),
                ['from' => env('TWILIO_NUMBER'), 'body' => "Your verification code is {$this->code}"]);
        } catch (\Exception $ex) {
            return false; //enable to send SMS
        }

        return true;
    }
}

If the current token is not attached to a user, the function will throw an exception. Otherwise, it will try to send them an SMS.

Finally, the application is ready. We test things out by registering a new user and trying to log in. Here is a quick demo:

Testing application

Conclusion

This article was a quick introduction to integrating two factor authentication with Twilio with a Laravel application. You can also take this demo further by giving the users the ability to enable and disable two factor authentication, and you may also want to offer a call instead of an SMS!

Have you ever implemented 2FA for an application? What are the challenges you’ve faced? Was it a good experience for your users?

If you have any question or comments about 2FA or Laravel, you can post them below!