Protecting API with Laravel Sanctum in TDD Approach

2 months ago

Hi

If you want to know how to protect API with Laravel Sanctum by following the TDD Approach, this could be one of the right place. In this tutorial, I will show you how to protect an API endpoint from public access by using Sanctum and the whole the process will follow the Test Driven Development (TDD) approach. Let's jump into it.

Plan

We should have two endpoints:

  • GET /api/users which will be publicly accessible.
  • GET /api/users/{id} protected and only accessible via the authenticated user.

Settings

Since we are doing some database operations, we need to set up db. I will use sqlite for testing and real implementation for this demo.

So, I will just uncomment phpunit.xml section.

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="BCRYPT_ROUNDS" value="4"/>
    <env name="CACHE_DRIVER" value="array"/>
-    <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
-    <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+    <env name="DB_CONNECTION" value="sqlite"/>
+    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="TELESCOPE_ENABLED" value="false"/>
</php>

Sanctum

Install Sanctum

First of all, install the package

composer require laravel/sanctum

Let's publish the vendor file:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Finally, you need to run the migration files:

php artisan migrate

Create an endpoint for token

Since Sanctum protects your route, that means somehow you need access_token to access the protected route. Now the question is how do you get the token?

For that, we need to create a new route.

Route::post('/tokens/create', function (Request $request) {
    $validator = Validator::make($request->all(), [
        'email' => 'required|email',
        'password' => 'required',
    ]);

    if ($validator->fails()) {
        return response()->json([
            $validator->errors()
        ], 422);
    }

    if (!Auth::attempt($request->only('email', 'password'))) {
        return response()->json([
            'message' => 'Invalid Credentials'
        ], 401);
    }

    $user = User::where('email', $request->email)->first();
    $token = $user->createToken('auth_token')->plainTextToken;

    return response()->json([
        'access_token' => $token,
        'token_type' => 'Bearer'
    ]);
});

It will do normal authentication type and return token if someone provides valid credentials, otherwise respective errors.

Create Tests

First of all, I want to create a test class for the API test.

php artisan make:test UserApiTest

It will create a test class in the /tests/feature directory. Now, I would like to list down all the possible features for the users resources.

class UserApiTest extends TestCase
{
    /** @test */
    public function it_list_down_all_users() {}

    /** @test */
    public function it_shows_deatils_of_a_user_to_authenticated_user_only() {}

    /** @test */
    public function it_does_not_allow_to_access_publicly() {}

    /** @test */
    public function it_returns_404_if_no_record_found_with_the_given_id() {}
}

These are my plan to confirm for my test. Let's jump into the first test it_list_down_all_users(). This is my plan:

/** @test */
public function it_list_down_all_users()
{
    $user1 = User::factory()->create();
    $user2 = User::factory()->create();

    $response = $this->get('/api/users');

    $response->assertStatus(200);
    $response->assertJson([
        'users' => [
            [
                'name' => $user1->name,
                'email' => $user1->email
            ],
            [
                'name' => $user2->name,
                'email' => $user2->email
            ],
        ]
    ]);
}

Running this will give me errors because I don't have any implementation yet. Let's create a route first. Btw, to make this tutorial short, I won't use a controller, but in the real implementation, you should use a controller.

routes/api.php

Route::get('/users', function () {
    $users = User::get();

    return response()->json([
        'users' => $users
    ]);
})->name('users.index');

Now if you run your test, it should pass. Nice, let's continue other implementations.

 /** @test */
public function it_shows_deatils_of_a_user_to_authenticated_user_only()
{
    Sanctum::actingAs(
        User::factory()->create(),
    );

    $user = User::factory()->create();
    $response = $this->get('/api/users/' . $user->id);

    $response->assertStatus(200);
    $response->assertJson([
        'user' => [
            'name' => $user->name,
            'email' => $user->email
        ],
    ]);
}

⚠️ Notice that in this test we use Sanctum to make the test user authenticated.

Let's do the implementation. The single route should be protected by auth.sanctum middleware.

Route::middleware('auth:sanctum')->get('/users/{id}', function ($id) {
    $user = User::find($id);

    if (!$user) {
        return response()->json([
            'User Not Found'
        ], 404);
    }

    return response()->json([
        'user' => $user
    ]);
});

Now if we run the test, it should pass also.

Finally, let's implement the rest of the features in the test.

/** @test */
public function it_does_not_allow_to_access_publicly()
{
    $response = $this->get('/api/users/1');

    $response->assertStatus(302);
}

/** @test */
public function it_returns_404_if_no_record_found_with_the_given_id()
{
    Sanctum::actingAs(
        User::factory()->create(),
    );

    $response = $this->get('/api/users/999');

    $response->assertStatus(404);
}

Now, if you run the whole test suite, it should pass.

Now if you try to access /api/users/{id} in your browser, you should redirect to the login route.

Perfect.

AuthTest

Now, let's check for the authentication test to make sure everything is working as expected. Let's create a test for that.

php artisan make:test AuthTest

It will create a new class in tests/feature directory.

tests/Feature/AuthTest.php

class AuthTest extends TestCase
{
    /** @test */
    public function it_provides_token_to_a_valid_user() {}

    /** @test */
    public function it_does_not_provide_token_with_wrong_credentials() {}

    /** @test */
    public function email_field_is_required() {}

    /** @test */
    public function email_value_must_be_a_valid_email_address() {}

    /** @test */
    public function password_field_is_required() {}
}

Now let's do it one by one.

it_provides_token_to_a_valid_user

/** @test */
public function it_provides_token_to_a_valid_user()
{
    User::factory()->create([
        'email' => '[email protected]',
        'password' => bcrypt('password')
    ]);

    $response = $this->post('/api/tokens/create', [
        'email' => '[email protected]',
        'password' => 'password'
    ]);

    $response->assertStatus(200);
    $response->assertSeeText('access_token');
    $response->assertSeeText('token_type');
    $response->assertSeeText('Bearer');
}

Now run the test and it will work fine.

/** @test */
public function it_does_not_provide_token_with_wrong_crednetials()
{
    User::factory()->create([
        'email' => '[email protected]',
        'password' => bcrypt('password')
    ]);

    $response = $this->post('/api/tokens/create', [
        'email' => '[email protected]',
        'password' => 'WRONG-PASSWORD'
    ]);

    $response->assertStatus(401);
    $response->assertSeeText('Invalid Credentials');
}

Some more validation checks.

/** @test */
public function email_field_is_required()
{
    User::factory()->create([
        'email' => '[email protected]',
        'password' => bcrypt('password')
    ]);

    $response = $this->post('/api/tokens/create', [
        'email' => null,
        'password' => 'password'
    ]);

    $response->assertStatus(422);
    $response->assertJson([
        [
            'email' => [
                'The email field is required.'
            ]
        ]
    ]);
}

/** @test */
public function email_value_must_be_a_valid_email_address()
{
    User::factory()->create([
        'email' => '[email protected]',
        'password' => bcrypt('password')
    ]);

    $response = $this->post('/api/tokens/create', [
        'email' => 'foo',
        'password' => 'password'
    ]);

    $response->assertStatus(422);
    $response->assertJson([
        [
            'email' => [
                'The email must be a valid email address.'
            ]
        ]
    ]);
}

/** @test */
public function password_field_is_required()
{
    User::factory()->create([
        'email' => '[email protected]',
        'password' => bcrypt('password')
    ]);

    $response = $this->post('/api/tokens/create', [
        'email' => '[email protected]',
        'password' => null,
    ]);

    $response->assertStatus(422);
    $response->assertJson([
        [
            'password' => [
                'The password field is required.'
            ]
        ]
    ]);
}

Now if you run the whole test suite, I think it will pass all tests.

php artisan test

img.png

Perfect.

Hope it makes sense to you.

If you have any questions, feel free to ask me in the comments section.

Get Code in Github

I screencast the same approach in the Bangla language. If anyone is interested, feel free to take a look into it.