Database and tracking JSON Web Tokens (JWT) (Part 3)

Last updated: May 25th 2021

Introduction

In our previous articles (part1, part2) we explained the basic mechanic of JSON web tokens. In this article we will build a database and track them.

Tymon JWT package so far only tracks blacklisted tokens in the Laravel cache system. That gives us a freedom to design our own database of issues tokens. For simplicity of this tutorial, here we will use standard MySql database. In production software, you will probably use something like Redis memory database, and this article I already wrote, will give you an idea how to implement.

Database Migration Table

First, let's create a migration file:

$ php artisan make:migration create_tokens_table

Open newly created migration xyz_create_tokens_table.php file and fill in the code:

<?php

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

class CreateTokensTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
      		      ->constrained()
      		      ->onUpdate('cascade')
      		      ->onDelete('cascade');
            $table->string('value', 500);
            $table->string('jti', 50)->unique();
            $table->string('type', 15);
            $table->bigInteger('pair')->nullable();
            $table->string('status', 20)->default('issued');
            $table->json('payload')->default('[]');
            $table->timestamps();
        });
    }

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

Migrate and create a Token model for our JWT:

$ php artisan migrate

then,

$ php artisan make:model Token

...and insert code in our app\models\Token.php file:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Token extends Model
{
    use HasFactory;

    protected $primaryKey = 'id';
    protected $table = 'tokens';
    protected $fillable = array(
      'user_id',
      'value',
      'jti',
      'type',
      'pair',
      'status',
      'payload'
    );

    protected $casts = [
      'payload' => 'array'
    ];

    function user() {
      return $this->belongsTo( 'App\Models\User' );
    }
}

Next, let's connect User and Token model. Insert this function at the bottom of the app\models\User.php file:

    function token() {
      return $this->hasMany( 'App\Models\Token', 'user_id' );
    }

Here is quick explanation of a data in our Token model:

  • value: string representation of the token itself
  • jti: equivalent of the jti from token payload. Useful for quick search of auth/refresh pairs
  • type: equivalent of the xtype
  • pair: equivalent of the xpair, but in here we will store the ID of the token from the database
  • status: here we will track the status of our tokens. By default they will have issued status
  • payload: contents of the JWT payload as array

You can add or remove data as you see fit.

Saving Tokens

We made prerequisites for our next step which is: saving issued tokens. Every token, we issue to the front end app will be saved in database.

Now, we are going to edit AuthController.php. Add the following code under namespace declaration:

use App\Models\Token;

then replace respondWithToken function with this:

    protected function respondWithToken($access_token)
    {
      $response_array = [
        'access_token' => $access_token,
        'token_type' => 'bearer',
        'access_expires_in' => auth()->factory()->getTTL() * 60,
      ];

      $access_token_obj = Token::create([
        'user_id' => auth()->user()->id,
        'value' => $access_token, //or auth()->getToken()->get();
        'jti' => auth()->payload()->get('jti'),
        'type' => auth()->payload()->get('xtype'),
        'payload' => auth()->payload()->toArray(),
      ]);

      $refresh_token = auth()->claims([
          'xtype' => 'refresh',
          'xpair' => auth()->payload()->get('jti')
        ])->setTTL(auth()->factory()->getTTL() * 3)->tokenById(auth()->user()->id);

      $response_array +=[
        'refresh_token' => $refresh_token,
        'refresh_expires_in' => auth()->factory()->getTTL() * 60
      ];

      $refresh_token_obj = Token::create([
        'user_id' => auth()->user()->id,
        'value' => $refresh_token,
        'jti' => auth()->setToken($refresh_token)->payload()->get('jti'),
        'type' => auth()->setToken($refresh_token)->payload()->get('xtype'),
        'pair' => $access_token_obj->id,
        'payload' => auth()->setToken($refresh_token)->payload()->toArray(),
      ]);

      $access_token_obj->pair = $refresh_token_obj->id;
      $access_token_obj->save();

      return response()->json($response_array);
    }

Explanation:

Our new code outputs same data structure while filling the database with tokens and make connections between them. Let's make some more clarifications about our code here:

Official documentation doesn't state anywhere how to extract token value (string representation as xx.yy.zz) once bypass middleware and credentials accepted.By looking at the package itself, I found an elegant way:

$current_token_in_guard = auth()->getToken()->get();

Moreover, this information is important if you check this value AFTER refresh token is issued in our function... It is going to be access token not otherwise. Now, this is directly opposite of my statement in previous article where I discuss refresh route behavior and using 'auth () ->setToken ('eXy...');' explicitly after blacklisting.

Here is the catch: in the REFRESH route we never used attempt() method at all to bind token to user explicitly.

IMPORTANT NOTE!
Use auth()->setToken($token)->user() to set AuthGuard token. Use auth()->setToken($token) to extract token data without affecting current one in the AuthGuard.

Now you are free to test login and refresh route and inspect tokens in the database and JWT debugger.

Enhancing Middleware

In this section we are going to match JWT against the database and check did we actually issued token passed to our app.

Forging tokens outside application
In the previous article in the section "Experimenting with JWT" I explained how a VALID token can be forged and misused if our JWT SECRET key is exposed. Now it is a good time to repeat that experiment before we continue with new code.

Open app\models\Token.php file and add a new function:

    public static function findByValue( $value ){
      $token_obj=Token::where('value', $value)->first();

      if ( $token_obj ) return $token_obj;

      return null;
    }

It is obvious from the code what this function does: search token in the database. Best place in our code to run this check is in the jwt.verify middleware. Open /app/Http/Middleware/JwtMiddleware.php file and add this line under namespace:

use App\Models\Token;

...then just before return $next($request); this

		$token_obj = Token::findByValue( auth()->getToken()->get() );

		if ( !$token_obj ){
			//OUR APP DID NOT ISSUED THIS TOKEN, POSSIBLE SECURITY BREACH
			return response()->json(['status' => 'Token Invalid - bad issuer'], 403);
		}

Good. Now if you repeat our forging experiment you will see an access denied message. That is exactly what we are looking for.

In production environment I always avoid being descriptive in outputting error messages. For the purpose of learning process, you may leave the message "as is" just to see a different break point.

Logout

Ok, so far our application after logout forbids us to use the same access (auth) token. But, refresh token is still active and can be used for refresh route which will grant us another auth-refresh pair. We are going to change that behavior in this section, since after logout occurs only logical way to regain access is via login route.

Open app\models\Token.php file and add a new function:

    public static function findPairByValue( $token ){
      $token_obj = self::findByValue( $token );
      return Token::find( $token_obj->pair );
    }

...and edit an AuthController.php logout() function:

    public function logout()
    {
      $refresh_token_obj = Token::findPairByValue( auth()->getToken()->get() );
      auth()->logout();
      auth()->setToken( $refresh_token_obj->value )->logout();

      return response()->json(['message' => 'Successfully logged out']);
    }

Now, test refresh token (refresh route) after you successfully logout with its auth pair! As you can see refresh token is invalidated too.

Logout from all devices

You often can see this feature around web implemented in applications. So, why wouldn't we implement it in our app. It's quite easy since all prerequisites are there. First, we will create another API route. Open routes/api.php and insert this line into 'group' block:

Route::post('logoutall', [App\Http\Controllers\AuthController::class, 'logoutall']);

...then add a line in AuthController.php under namespace declaration

use Exception;

...and add function to the AuthController class:

    public function logoutall(Request $request){
      foreach( auth()->user()->token as $token_obj ){
        try{
          auth()->setToken( $token_obj->value )->invalidate(true);
        }
        catch (Exception $e){
          //do nothing, it's already bad token for various reasons
        }
      }

      return response()->json(['message' => 'Successfully logged out from all devices']);
    }

Now, you can test this route with Postman. Login several times, and use one of the access (auth) token for this route, then test all the rest active tokens against me route. As you can see, the user is permanently logged out of the application. Cool.

Conclusion

All  the code we have shown in this article has been pushed to GitHub with the tag "apijwt.v3.0". Click on this card to take you there!

In this article we introduced token database as our own solution. Because of that, we straightened our app security on multiple levels. Moreover, we build infrastructure for future features and manipulations with tokens we are issuing.

Homework: If you pay attention, in our Token model we introduced a column named status. By default its value is 'issued'. It would be nice that this status is changed to INVALID whenever we run to bad token, and/or on logout action. I did not want to bloat this tutorial code anymore with that feature, thus leaving that to you. After that, you could load only possible active tokens ( at logoutall() function for example) not all tokens ever issued. Moreover, if your cache system ever crashes, you will still have a full list of active tokens in the database, as an additional layer of security.

In our next article we will introduce IP filtering and dynamic access to resources.

Series Author: Aleksandar Milivojevic is a PHP programmer for over 10 years (Laravel and Wordpress). Writing articles in brief pauses between projects, to help out the community and popularize PHP in general.

<< Previous Article in this series: The Anatomy of JSON Web Tokens (JWT) (Part 2)

Next Article in this Series: JSON Web Tokens and Laravel API custom filtering (JWT) (Part 4) >>

Related articles