Multi-Table Authentication with Laravel and JWT

Table of Contents

Its been one of the common use case scenario to authenticate users from different types. Imagine you are creating an app the requires two tables for users and staffs. With Laravel, you can define multiple authentication provider and will solve your problem in a matter of minutes.

Let me show you how.

Level 1: Install Laravel and Setup Basic User Tables

First we need to install Laravel. You can find my current dev environment here: How to Install Laravel Valet and Setup a new server

> laravel new multi-auth
> cd multi-auth

Next, let's set up database connection and environment configuration. By default Laravel will create this for you. Just verify that you are using the correct database configuration so you can connect to your mysql datasource. If you are using the default mysql username your env file should be like this

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

Take note that we changed the database name to multi_auth_db Now we need to make sure that users and staff tables are existing. When installing Laravel, it automatically creates a migration file for users so we only need to setup staffs migration file. We can easily see create this using the Laravel artisan command:

> php artisan make:migration create_staff_table

Update the migration staff table migration file by opening create_staff_table.php in database/migrations. Inside the up function, type the lines below to build the table columns.

Schema::create('staff', function (Blueprint $table) {
    $table->id();
    $table->string("first_name");
    $table->string("last_name");
    $table->string("email");
    $table->string("password");
    $table->timestamps();
});

Now run the command below back in your terminal to migrate the tables in your database.

> php artisan migrate

Level 2: Install Laravel JWT Package

For our JSON Web Token Management, we will install a library from tymon

https://jwt-auth.readthedocs.io/en/develop/laravel-installation/

> composer require tymon/jwt-auth
> php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
> php artisan jwt:secret

Basically the commands above do the following setup:

  • Install the package using composer,
  • Publish the configuration file which is located in your config/jwt.php
  • Generate a secret token. This secret token will be added to your .env file.

Open your .env file and see the following line. Of course, your key will be different from this one.

JWT_SECRET=...CglEto...qCZaofU9sYxCy9i2ztFJcf3L...

User Configuration

Update your User model to implement the JWTSubject contract. This make sure that your user model have the getJWTIdentifier() which return a unique key from the user instance and getJWTCustomClaims() for defining custom claims.

<?php

namespace App;

...

use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
     ...

    /**
     * @inheritDoc
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * @inheritDoc
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}

Make sure that you do the same to the Staff model.

We need to tell Laravel to have two guards, one is for normal user and another one for the staff. Both of this guards will use jwt as the driver but the provider will be coming from two different tables. To do this, let's update auth.php


    ...
    
    'defaults' => [
            'guard' => 'api', // set the default guard to 'api'
            'passwords' => 'users',
        ],
    
    'guards' => [
            'web' => [
                'driver' => 'session',
                'provider' => 'users',
            ],
    
            'api' => [
                'driver' => 'jwt', // set to jwt token
                'provider' => 'users',
                'hash' => false,
            ],
    
            'staff-api' => [ // add another guard for the staff
                'driver' => 'jwt',
                'provider' => 'staff', // set the provider
                'hash' => false,
            ],
      ],
    'providers' => [
            'users' => [
                'driver' => 'eloquent',
                'model' => App\User::class,
            ],
            'staff' => [ // Add new staff provider
                'driver' => 'eloquent',
                'model' => App\Staff::class,
            ],
    
    ]
    
    ...

Level 3: TDD with Authentication

The final step will be to setup our login screen using the TDD approach. If you're not familiar TDD means Test Driven Development. Basically this means that we implement features in our app beginning with test case.

In summary here are the things that we need to do:

  1. Create UserAuthenticationTest that will test the user
  2. Setup the api endpoint with our controller
  3. Implement Authentication

Create a UserAuthenticationTest using Laravel's make:test command

php artisan make:test UserAuthenticationTest

Before we touch the business logic in authentication, we need to setup our test first. Let's update our UserAuthenticationTest file. We will override the setup function to include proper request headers and create a mock user using model factory. By default, the test environment will use sqlite and keep data in the memory.

<?php 
namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Response;
use Tests\TestCase;

class UserAuthenticationTest extends TestCase
{

    use RefreshDatabase;

    /**
     * @var \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model
     */
    private $user;

    protected function setUp(): void
    {
        parent::setUp();
        $this->withHeader('X-Requested-With', 'XMLHttpRequest');
        $this->withHeader('Accept', 'application/json');

        $this->user = factory(User::class)->create([
            'email' => 'test@example.com',
            'password' => bcrypt("123456"),
        ]);
    }

}

Next we need to add a test that will authenticate the user with the given credentials. Add this below the setup function.

....

/**
 * @test
 * */
public function it_should_return_jwt_key_from_login()
{
    $this->withoutExceptionHandling();
    $this->post('api/auth/login', ['email' => $this->user->email, 'password' => '123456'])
        ->assertStatus(Response::HTTP_OK)
        ->assertJsonStructure(['token', 'message']);

    $user = auth('api')->user();

    $this->assertEquals($user->id, $this->user->id);
}

....

Next let's run phpunit from the command line

> ./vendor/bin/phpunit 

And boom it throws an error. Don't worry that's part of TDD. So

PHPUnit 8.5.2 by Sebastian Bergmann and contributors.

..E                                                                 3 / 3 (100%)

Time: 661 ms, Memory: 24.00 MB

There was 1 error:

1) Tests\Feature\UserAuthenticationTest::it_should_return_jwt_key_from_login
Symfony\Component\HttpKernel\Exception\NotFoundHttpException: POST http://localhost/api/auth/login

/Users/kristher/Code/Laravel/multi-auth/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/InteractsWithExceptionHandling.php:126
/Users/kristher/Code/Laravel/multi-auth/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php:415
/Users/kristher/Code/Laravel/multi-auth/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php:113
/Users/kristher/Code/Laravel/multi-auth/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php:468
/Users/kristher/Code/Laravel/multi-auth/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php:286
/Users/kristher/Code/Laravel/multi-auth/tests/Feature/UserAuthenticationTest.php:38

ERRORS!
Tests: 3, Assertions: 2, Errors: 1.

Now we need to update api.php

Route::post('auth/login', "AuthController@store");

Then of course we need to create a controller

php artisan make:controller AuthController

Let's run ./vendor/bin/phpunit again and you can see that it wants us to create a store method in our AuthController. So let's add one.

namespace App\Http\Controllers;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Response;

class AuthController extends Controller
{

    public function store()
    {
        if ($token = auth()->attempt(request()->only('email', 'password'))) {
            return response()->json([
                'token' => $token,
                'message' => "Login successful"
            ]);
        }

        }
}

You can run phpunit again, and if everything is went smooth, then the test should pass. This is how TDD works, you create the test, run the test, then do necessary adjustments. This means that if our app is backed by tests, we can minimize breaking changes introduced by new lines codes.

So let's move forward. Of course we need to add the following tests:

  1. When credentials are incorrect
  2. Show the details of the authenticated user
  3. And logout the user

The steps will be similar, so to cut the long story short, here are the final files

tests/Feature/UserAuthenticationTest.php

<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Response;
use Tests\TestCase;

class UserAuthenticationTest extends TestCase
{

    use RefreshDatabase;

    /**
     * @var \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model
     */
    private $user;

    protected function setUp(): void
    {
        parent::setUp();
        $this->withHeader('X-Requested-With', 'XMLHttpRequest');
        $this->withHeader('Accept', 'application/json');

        $this->user = factory(User::class)->create([
            'email' => 'test@example.com',
            'password' => bcrypt("123456"),
        ]);
    }

    /**
     * @test
     * */
    public function it_should_return_jwt_key_from_login()
    {
        $this->withoutExceptionHandling();
        $this->post('api/auth/login', ['email' => $this->user->email, 'password' => '123456'])
            ->assertStatus(Response::HTTP_OK)
            ->assertJsonStructure(['token', 'message']);

        $user = auth('api')->user();

        $this->assertEquals($user->id, $this->user->id);
    }

    /**
     * @test
     */
    public function it_can_open_authenticated_routes_with_the_token()
    {
        $this->withoutExceptionHandling();
        $this->post('api/auth/login', ['email' => $this->user->email, 'password' => '123456']);
        $this->get('api/account/me')->assertJsonStructure(['data'])->assertSee($this->user->name);
        $this->assertAuthenticatedAs($this->user);
    }

    /**
     * @test
     */
    public function it_should_throw_an_exception_when_credentials_are_invalid()
    {
        $this->post('api/auth/login', ['email' => $this->user->email, 'password' => '1234567'])->assertStatus(Response::HTTP_UNAUTHORIZED);
    }

    /**
     * @test
     */
    public function it_should_logout_user()
    {
        $this->withoutExceptionHandling();
        $this->post('api/auth/login', ['email' => $this->user->email, 'password' => '123456']);
        $this->delete('api/auth/logout')->assertStatus(Response::HTTP_ACCEPTED);
        $this->assertGuest('api');
    }

}

app/Exceptions/Handler.php


<?php

namespace App\Exceptions;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Response;
use Illuminate\Validation\UnauthorizedException;
use Throwable;

class Handler extends ExceptionHandler
{
    /**
     * A list of the exception types that are not reported.
     *
     * @var array
     */
    protected $dontReport = [
        //
    ];

    /**
     * A list of the inputs that are never flashed for validation exceptions.
     *
     * @var array
     */
    protected $dontFlash = [
        'password',
        'password_confirmation',
    ];

    /**
     * Report or log an exception.
     *
     * @param  \Throwable  $exception
     * @return void
     *
     * @throws \Exception
     */
    public function report(Throwable $exception)
    {
        parent::report($exception);
    }

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Throwable  $exception
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Throwable
     */
    public function render($request, Throwable $exception)
    {
        if(!$request->wantsJson()) {
            return parent::render($request, $exception);
        }

        switch($exception) {
            case $exception instanceof AuthorizationException:
                return response()->json(['message'=> $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
            break;
        }
    }
}

api.php


<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});
Route::post('auth/login', "AuthController@store");

Route::group(['middleware' => 'auth:api'], function () {
    Route::get('account/me', "AuthController@show");
    Route::delete('auth/logout', "AuthController@destroy");
});

app/Http/Controllers/AuthController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Response;

class AuthController extends Controller
{

    public function store()
    {
        if ($token = auth()->attempt(request()->only('email', 'password'))) {
            return response()->json([
                'token' => $token,
                'message' => "Login successful"
            ]);
        }

        throw new AuthorizationException("Invalid Credentials");
    }

    public function show()
    {
        return response()->json(['data' => ['user' => auth()->user()]]);
    }

    public function destroy()
    {
        auth()->logout();
        return response()->json(['message'=>'Logout'], Response::HTTP_ACCEPTED);
    }
}

Level 4: Staff Authentication

So basically you just copy the steps above, but this time we need to specify that we will be using the staff guard instead of the default api guard.

  1. Lets make the necessary files
> php artisan make:test StaffAuthenticationTest
Test created successfully.
> php artisan make:factory StaffFactory
Factory created 
> php artisan make:model Staff
Model created successfully.
> artisan make:controller StaffAuthenticationController
Controller created successfully.

Now here are the list of the files

tests/Feature/StaffAuthenticationTest.php

<?php

namespace Tests\Feature;

use App\Staff;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Response;
use Tests\TestCase;

class StaffAuthenticationTest extends TestCase
{

    use RefreshDatabase;

    /**
     * @var \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model
     */
    private $staff;

    protected function setUp(): void
    {
        parent::setUp();
        $this->withHeader('X-Requested-With', 'XMLHttpRequest');
        $this->withHeader('Accept', 'application/json');

        $this->staff = factory(Staff::class)->create([
            'email' => 'test@example.com',
            'password' => bcrypt("123456"),
        ]);
    }

    /**
     * @test
     * */
    public function it_should_return_jwt_key_from_login()
    {
        $this->withoutExceptionHandling();
        $this->post('api/staff/auth/login', ['email' => $this->staff->email, 'password' => '123456'])
            ->assertStatus(Response::HTTP_OK)
            ->assertJsonStructure(['token', 'message']);

        $user = auth('staff-api')->user();

        $this->assertEquals($user->id, $this->staff->id);
    }

    /**
     * @test
     */
    public function it_can_open_authenticated_routes_with_the_token()
    {
        $this->withoutExceptionHandling();
        $this->post('api/staff/auth/login', ['email' => $this->staff->email, 'password' => '123456']);
        $this->get('api/staff/me')->assertJsonStructure(['data'])->assertSee($this->staff->name);
        $this->assertAuthenticatedAs($this->staff);
    }

    /**
     * @test
     */
    public function it_should_throw_an_exception_when_credentials_are_invalid()
    {
        $this->post('api/staff/auth/login', ['email' => $this->staff->email, 'password' => '1234567'])->assertStatus(Response::HTTP_UNAUTHORIZED);
    }

    /**
     * @test
     */
    public function it_should_logout_user()
    {
        $this->withoutExceptionHandling();
        $this->post('api/staff/auth/login', ['email' => $this->staff->email, 'password' => '123456']);
        $this->delete('api/staff/auth/logout')->assertStatus(Response::HTTP_ACCEPTED);
        $this->assertGuest('api');
    }

}

app/Http/Controllers/StaffAuthenticationController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Response;

class StaffAuthenticationController extends Controller
{

    public function store()
    {
        if ($token = auth('staff-api')->attempt(request()->only('email', 'password'))) {
            return response()->json([
                'token' => $token,
                'message' => "Login successful"
            ]);
        }

        throw new AuthorizationException("Invalid Credentials");
    }

    public function show()
    {
        return response()->json(['data' => ['user' => auth('staff-api')->user()]]);
    }

    public function destroy()
    {
        auth('staff-api')->logout();
        return response()->json(['message'=>'Logout'], Response::HTTP_ACCEPTED);
    }
}

app/Staff.php

<?php

namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class Staff extends Authenticatable implements JWTSubject
{
    /**
     * @inheritDoc
     */
    public function getJWTIdentifier()
    {
       return $this->getKey();
    }

    /**
     * @inheritDoc
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}

api.php

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});
Route::post('auth/login', "AuthController@store");
Route::post('/staff/auth/login', "StaffAuthenticationController@store");

Route::group(['middleware' => 'auth:api'], function () {
    Route::get('account/me', "AuthController@show");
    Route::delete('auth/logout', "AuthController@destroy");
});


Route::group(['middleware' => 'auth:staff-api'], function () {
    Route::get('staff/me', "StaffAuthenticationController@show");
    Route::delete('staff/auth/logout', "StaffAuthenticationController@destroy");
});

database/factories/StaffFactory.php

<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Model;
use App\Staff;
use Faker\Generator as Faker;

$factory->define(Staff::class, function (Faker $faker) {
    return [
        'first_name' => $faker->firstName,
        'last_name' => $faker->lastName,
        'email' => $faker->unique()->safeEmail,
        'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
    ];
});

There you go! To verify that everything is working fine lets run our test one more time


☁  multi-auth [master] ⚡  ./vendor/bin/phpunit
PHPUnit 8.5.2 by Sebastian Bergmann and contributors.

..........                                                        10 / 10 (100%)

Time: 342 ms, Memory: 28.00 MB

OK (10 tests, 26 assertions)
☁  multi-auth [master]

If you are stuck you can view the repo over here