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:
- Create UserAuthenticationTest that will test the user
- Setup the api endpoint with our controller
- 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:
- When credentials are incorrect
- Show the details of the authenticated user
- 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.
- 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