Authorizing New Device based on IP Address, with Laravel Middleware

I recently saw few of the e-commerce & payment gateway sites using device authorization system based on IP address, browser etc. I was also working on a same for my client's web app recently so wanted to share a detailed post with the community people.

In this blog post, I will go in detail to cover following stuff.

  • Allow the user to enter login credentials, if the login credentials are valid, also verify if the user's device is authorized with the current IP address assigned to the user's device.
  • If the user's device is not authorized to access the protected pages, like the dashboard, the application will send an email to the recently logged in user's email to ask for authorizing the device before proceeding.
  • After sending the email, the page will redirect to wait for email authorization, and that will keep refreshing on certain time interval to check if the user is authorized, so it can redirect to the dashboard.
  • If the user is not active and did not authorize the device within next 15 min after email is sent, it will log out the user as a reason for a timeout with a certain message.

 

I will be using Laravel v5.5 to build this feature. Also, for user's device information, IP to location and to generate a unique token for every new request I will be using my own two PHP packages published on packagist as open source, and browser detection library by @cbschuld. So make sure you have a composer, PHP 7.0 >= installed in your environment.

Package Installation

You can install those packages via composer.

composer require sudiptpa/guid

composer require sudiptpa/ipstack


Now let's start with creating the foundation of the feature to implement with Laravel project.

app/routes/web.php

Route::group(['middleware' => ['authorize', 'auth']], function () {
    Route::get('/dashboard', [
        'name' => 'Dashboard',
        'as' => 'dashboard',
        'uses' => 'HomeController@dashboard',
    ]);
});

Route::group(['middleware' => ['auth']], function () {
    Route::get('/authorize/{token}', [
        'name' => 'Authorize Login',
        'as' => 'authorize.device',
        'uses' => 'Auth\AuthorizeController@verify',
    ]);

    Route::post('/authorize/resend', [
        'name' => 'Authorize',
        'as' => 'authorize.resend',
        'uses' => 'Auth\AuthorizeController@resend',
    ]);
});


The first grouped route shows, that the user cannot access the dashboard without logging in and, needs authorization. I will show the authorization middleware very soon below.

Similarly, the second grouped routes, show that only authenticated users can verify the device with associated IP address.

app/database/migrations

 

Now let's create a migration table to store the authorizes for all users.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateAuthorizesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('authorizes', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('user_id')->nullable();
            $table->boolean('authorized')->nullable();
            $table->string('token')->nullable();
            $table->string('ip_address')->nullable();
            $table->string('browser')->nullable();
            $table->string('os')->nullable();
            $table->string('location')->nullable();
            $table->tinyInteger('attempt')->default(0)->nullable();
            $table->timestamp('authorized_at')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

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


app/Authorize.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;

/**
 * Class Authorize
 * @package App
 */
class Authorize extends Model
{
    /**
     * @var string
     */
    protected $table = 'authorizes';

    /**
     * @var boolean
     */
    public $timestamps = true;

    /**
     * @var array
     */
    protected $dates = ['authorized_at', 'deleted_at'];

    /**
     * @var string
     */
    protected $fillable = [
        'user_id', 'authorized', 'token', 'ip_address', 'browser', 'os', 'location', 'attempt', 'authorized_at',
    ];

    /**
     * @param $query
     * @return mixed
     */
    public function scopeCurrentUser($query)
    {
        return $query->where('user_id', Auth::id());
    }

    /**
     * @param $date
     */
    public function setAuthorizedAtAttribute($date)
    {
        $this->attributes['authorized_at'] = Carbon::parse($date);
    }

    /**
     * @return mixed
     */
    public static function active()
    {
        return with(new self)
            ->where('ip_address', Request::ip())
            ->where('authorized', true)
            ->where('authorized_at', '<', Carbon::tomorrow())
            ->first();
    }

    /**
     * @return mixed
     */
    public function resetAttempt()
    {
        $this->update(['attempt' => 0]);

        return $this;
    }

    /**
     * @return mixed
     */
    public function noAttempt()
    {
        return $this->attempt < 1;
    }

    /**
     * @param $token
     */
    public static function validateToken($token = null)
    {
        $query = self::where([
            'token' => $token,
        ])->first();

        if (sizeof($query)) {
            $query->update([
                'authorized' => true,
                'authorized_at' => now(),
            ]);

            return self::active();
        }
    }

    /**
     * @return mixed
     */
    public static function make()
    {
        return self::firstOrCreate([
            'ip_address' => Request::ip(),
            'authorized' => false,
            'user_id' => Auth::id(),
        ]);
    }

    /**
     * @return mixed
     */
    public static function inactive()
    {
        $query = self::active();

        return $query ? null : true;
    }
}


In this model I have written few methods that actually work on authorization, you will see its usage below with middleware, controller.

In this blog post, I am only covering only the dashboard, login routes to be protected from the authorize middleware. I only forced the LoginController.php to apply on login routes only. Make sure you apply this middleware to other routes to be protected from unauthorized access to your application.

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/dashboard';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('authorize')->only('login');
        $this->middleware('guest')->except('logout');
    }


app/Http/Middleware/AuthorizeDevice.php

<?php

namespace App\Http\Middleware;

use App\Authorize;
use App\Mail\AuthorizeDevice as AuthorizeMail;
use Closure;
use Illuminate\Support\Facades\Mail;

/**
 * Class AuthorizeDevice
 * @package App\Http\Middleware
 */
class AuthorizeDevice
{
    /**
     * @var \App\Authorize
     */
    private $authorize;

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if (Authorize::inactive() && auth()->check()) {
            $this->authorize = Authorize::make();

            if ($this->authorize->noAttempt()) {
                Mail::to($request->user())
                    ->send(new AuthorizeMail($this->authorize));

                $this->authorize->increment('attempt');
            }

            if ($this->timeout()) {
                auth()->guard()->logout();

                $request->session()->invalidate();

                return redirect('/')->with([
                    'status' => 'You are logged out of system, please follow the link we sent before 15 minutes to authorize your device, the link will be valid with same IP for 24hrs.',
                ]);
            }

            return response()->view('auth.authorize');
        }

        return $next($request);
    }

    /**
     * Determines if the authorize attempt is timed out.
     *
     * @return bool
     */
    private function timeout()
    {
        $waiting = $this->authorize
            ->created_at
            ->addMinutes(15);

        if (now() >= $waiting) {
            return true;
        }

        return false;
    }
}


Now register the middleware within app/Http/Kernel.php

    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array
     */
    protected $routeMiddleware = [
        ...
        'authorize' => \App\Http\Middleware\AuthorizeDevice::class,
    ];


The most interesting part of this blog is the above middleware class that handles the incoming request into an application that enters the actual routes which are protected before the user's device is authorized.

Open up the Authorize.php model from GitHub to understand the full methods about how it is working.

The middleware is before middleware, follow the laravel official doc if you don't know about it.

Actually, it works with the authenticated user's when the device is not authorized, so it protects the user's trying to enter the application without authorization.

The middleware first, checks, if there is an authorization, exists in the database or creates a new with the current IP address for currently logged in user.

It sends the email requesting authorization to access the application, see below for the simple preview I made while writing this blog post.

The middleware is also handling the session timeout for the authorization to be made within next 15 min after the email has been sent, otherwise, it will log out the user automatically.

If you wish to manage the separate middleware for the timeout feature you could use another class, and register it accordingly like we did above.

Alright, now let's move to email sending code.

app/Mail/AuthorizeDevice.php

<?php

namespace App\Mail;

use App\Browser;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Sujip\Ipstack\Ipstack;

/**
 * Class AuthorizeDevice
 * @package App\Mail
 */
class AuthorizeDevice extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * @var mixed
     */
    protected $authorize;

    /**
     * Create a new message instance.
     *
     * @param $authorize
     *  @return void
     */
    public function __construct($authorize)
    {
        $this->authorize = $authorize;
        $this->browser = new Browser;
    }

    /**
     * @return mixed
     */
    public function setBrowser()
    {
        $this->authorize->browser = $this->browser->getBrowser();

        return $this;
    }

    /**
     * @return mixed
     */
    public function setToken()
    {
        $this->authorize->token = guid();

        return $this;
    }

    /**
     * @return mixed
     */
    public function setLocation()
    {
        $location = with(new Ipstack(
            $this->authorize->ip_address
        ))->formatted();

        $this->authorize->location = $location;

        return $this;
    }

    /**
     * @return mixed
     */
    public function setPlatform()
    {
        $this->authorize->os = $this->browser->getPlatform();

        return $this;
    }

    public function saveAuthorize()
    {
        $this->authorize->save();
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        $this
            ->setBrowser()
            ->setToken()
            ->setLocation()
            ->setPlatform()
            ->saveAuthorize();

        return $this
            ->view('emails.auth.authorize')
            ->with(['authorize' => $this->authorize]);
    }
}


The mail sending class prepares data like location, IP, token, browser, platform(os) and email sending view. In order to collect the user's current device information, I am using a third-party class written by @cbschuld as open source code in GitHub. I would like to say thanks for the nice package to him.

Also, to generate the token, I am using a GUID generator package that I wrote recently for my own use case and published as opensource as well. The guid() helper function will return global unique identifier every time a user requires a token to authorize the device.

The coolest feature in the Laravel 5.5 Illuminate\Mail\Mailable is mail preview to test the email view before sending it. I loved it. :)

See example below how I tested myself.

Route::get('/mailable', function () {
    $authorize = App\Authorize::find(1);

    return new App\Mail\AuthorizeDevice($authorize);
});


After sending the email requesting you to authorize the device, the preview I built was like below, you are free to apply CSS as per your use case.

null

The AuthorizationController.php was written to handle, resend authorize email, and validate the token from the email sent to the current user.

<?php

namespace App\Http\Controllers\Auth;

use App\Authorize;
use App\Http\Controllers\Controller;
use App\Mail\AuthorizeDevice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Redirect;

/**
 * Class AuthorizeController
 * @package App\Http\Controllers\Auth
 */
class AuthorizeController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    /**
     *  Validate the token for the Authorization.
     *
     * @param $token
     * @return \Illuminate\Http\Response
     */
    public function verify($token = null)
    {
        if (Authorize::validateToken($token)) {
            return Redirect::route('dashboard')->with([
                'status' => 'Awesome ! you are now authorized !',
            ]);
        }

        return Redirect::route('login')->with([
            'error' => "Oh snap ! the authorization token is either expired or invalid. Click on Email didn't arraive ? again",
        ]);
    }

    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function resend(Request $request)
    {
        if (Authorize::inactive() && auth()->check()) {
            $authorize = Authorize::make()
                ->resetAttempt();

            Mail::to($request->user())
                ->send(new AuthorizeDevice($authorize));

            $authorize->increment('attempt');

            return view('auth.authorize');
        }
    }
}


View the auth/authorize.blade.php from github to know how it was like.

 

View the complete code pushed to github while I was writing this blog post.

Conclusion

Thanks for reading this article up to the end. If you have any feedback or the article was really useful to you please leave your comments below. Feel free to share with friends if you like it.

Happy Coding!