Laravel Sanctum

Introduction

Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.

Since LdapRecord-Laravel provides a database authentication driver, it integrates with Sanctum directly, similarly to the built in eloquent driver.

Important: Before getting started, please complete the below guides:

Preparing The User Eloquent Model

If you've followed the above guides, your Eloquent user model should resemble the below:

// app/Models/User.php

// ...
use Laravel\Sanctum\HasApiTokens;
use LdapRecord\Laravel\Auth\HasLdapUser;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;

class User extends Authenticatable implements LdapAuthenticatable
{
    // ...
    use HasApiTokens;
    use AuthenticatesWithLdap;
    // ...
}

SPA Authentication

Important:

Please read Laravel Sanctum's SPA Authentication setup guide before proceeding.

SPA Authentication means that you have a frontend-based application that will be sending requests to your own application's protected route endpoints (via the auth:sanctum middleware).

Preparing The Authentication Guard

Laravel Sanctum will utilize the web authentication guard specified in your config/auth.php file by default.

Make sure this guard exists and is utilizing the session driver:

// config/auth.php

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
    
    // ...
],

If you want to change the guard Sanctum uses, publish its configuration file by running the below command:

Important: As mentioned above, any custom guard must use a session driver for Sanctum to function.

php artisan vendor:publish --tag="sanctum-config"

Then, update the guard configuration option:

// config/sanctum.php

'guard' => ['web'],

Setting Up The Sanctum Middleware

If you're going to be sending requests to your /api endpoints from your application's frontend, you must insert a Sanctum middleware into your Laravel application's api middleware group for those requests to be automatically authenticated.

This means that users who have logged into your frontend application will not need to manually provide a Sanctum token to send requests to your protected /api endpoints. This middleware is responsible for booting up the session during API requests that are received from your frontend:

Important:

The position of this middleware is crucial. The throttle:api middleware will utilize a different throttle cache key for authenticated users than guests.

Since the EnsureFrontendRequestsAreStateful is inserted before throttle:api, the session will be started and an authenticated user will exist, allowing the throttle:api to access them and bind a unique throttle key to them.

You may have to tweak this throttle:api middleware if your frontend application sends large amounts of API requests.

// app/Http/Kernel.php

protected $middlewareGroups = [
    // ...

    'api' => [
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // <-- Inserted here.
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
],

How does Sanctum know that the request came from your frontend? Well, it does this by checking the domain that the request was sent from to see if it matches with your application server's URL, or one of the URL's configured inside the sanctum.php configuration file:

// config/sanctum.php

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
    Sanctum::currentApplicationUrlWithPort()
))),

As you can see, various localhost domains are included, which is why we can test with Sanctum locally.

Logging In

Important:

It's recommended to use Laravel Fortify as a starting point for authenticating users. This guide assumes you have set up authentication using one of the documented packages.

As mentioned in the Laravel Sanctum documentation, you must first initialize a CSRF cookie by requesting one from an endpoint Sanctum integrates into your application automatically (/sanctum/csrf-token). When you send a request to this endpoint, your application will send cookie headers back, containing the X-CSRF token.

Once a successful 204 (No content) response is received from the CSRF token endpoint resulting in a new CSRF cookie containing the token, you may send a login request to your application. This login request will initialize the session by sending back a new session cookie in the header (upon providing valid credentials).

When this session cookie is received by your web browser, axios (and other HTTP JavaScript clients) should automatically send this cookie along with any subsequent requests. Laravel Sanctum will read this cookie that is sent along your request and authenticate the user for you, allowing the user to access protected routes.

Here's how a login request via axios could be made:

let credentials = {
    email: '[email protected]',
    password: 'secret',
};

axios.get('/sanctum/csrf-cookie').then(response => {
    axios.post('/login', credentials).then('...');
});

API Token Authentication

Important:

Please read Laravel Sanctum's API Token Authentication setup guide before proceeding.

If your application is going to provide an API to end users or external services, you will need to implement API token authentication.

Issuing API Tokens

To start issuing new user API tokens, we will take Sanctum's default suggested endpoint for issuing tokens, and then tweak it a little bit.

Open your routes/api.php file, and paste the below:

// routes/api.php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::post('sanctum/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

    // Remember, an LDAP query will be executed on all the array
    // elements in the credentials array (excluding "password").
    // Here, we're locating a user via their "mail" attribute.
    $credentials = [
        'mail' => $request->email,
        'password' => $request->password,
    ];

    if (Auth::validate($credentials)) {
        $user = Auth::getLastAttempted();

        return [
            'token' => $user->createToken($request->device_name)->plainTextToken
        ];
    }

    throw ValidationException::withMessages([
        'email' => ['The provided credentials are incorrect.'],
    ]);
});

This is all that is needed to generate new API tokens, and to start using those tokens to authenticate against your server with.

Let's make sure these endpoints work with some tests.

Testing

Tinkerwell Testing

To test your Sanctum endpoint with Tinkerwell, serve your Laravel application by running the below command:

php artisan serve

> Starting Laravel development server: http://127.0.0.1:8000

Then, send a post request to api/sanctum/token:

use Illuminate\Support\Facades\Http;

$data = [
  'email' => '[email protected]',
  'password' => 'secret',
  'device_name' => 'browser',
];

$response = Http::baseUrl('http://127.0.0.1:8000')
  ->withHeaders(['accept' => 'application/json'])
  ->post('api/sanctum/token', $data)
  ->json();

// ['token' => '5|iSO2wH8W....']
dd($response);

To ensure the token works, capture a returned token from the above request, and send it with Authorization headers to api/user:

$token = '5|iSO2wH8W....';

$response = Http::baseUrl('http://127.0.0.1:8000')
  ->withHeaders(['accept' => 'application/json'])
  ->withToken($token)
  ->get('api/user')
  ->body();

// ['id' => 1, 'name' => 'John Doe', ...]
dd($response);

PHPUnit Testing

Important: If you are using SQLite to test, remember to install doctrine/dbal before getting started, as mentioned in the test guide:

composer require doctrine/dbal --dev

To begin, let's create a Sanctum test to make sure both our API endpoints are working:

php artisan make:test SanctumTokenTest

Open the new file and erase the existing example test:

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SanctumTokenTest extends TestCase
{
}

We'll need to use the DatabaseMigrations trait to run our application's migrations:

// ...
use Illuminate\Foundation\Testing\DatabaseMigrations;

class SanctumTokenTest extends TestCase
{
    use DatabaseMigrations;
}

Now let's add a test to ensure our LDAP user can authenticate and get an API token:

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Laravel\Sanctum\PersonalAccessToken;
use Illuminate\Testing\Fluent\AssertableJson;
use LdapRecord\Laravel\Testing\DirectoryEmulator;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use LdapRecord\Models\ActiveDirectory\User as LdapUser;

class SanctumTokenTest extends TestCase
{
    use DatabaseMigrations;

    protected function tearDown(): void
    {
        DirectoryEmulator::tearDown();

        parent::tearDown();
    }

    public function testLdapUserCanRetrieveToken()
    {
        // Set up the directory emulator to create a fake, existing LDAP user:
        $fake = DirectoryEmulator::setup();

        // Remember to supply the "sync_attributes" you have assigned inside
        // your config/auth.php. You will receive an exception if any
        // non-nullable SQL fields are not present on the LDAP user:
        $ldapUser = LdapUser::create([
            'cn' => 'John Doe',
            'mail' => '[email protected]',
        ]);

        // Set the user to automatically pass LDAP authentication:
        $fake->actingAs($ldapUser);

        // Post the token endpoint and ensure a new token was created:
        $this->postJson('api/sanctum/token', [
            'email' => $ldapUser->mail[0],
            'password' => 'secret',
            'device_name' => 'browser',
        ])->assertJson(
            fn (AssertableJson $json) => $json->whereType('token', 'string')
        );

        // Ensure the user was imported:
        $this->assertDatabaseHas(User::class, [
            'email' => $ldapUser->mail[0],
            'name' => $ldapUser->cn[0],
        ]);

        // Ensure a token exists:
        $this->assertDatabaseCount(PersonalAccessToken::class, 1);
    }
}

Great, now let's add a test to ensure an existing LDAP user that fails authentication is not imported, and no token is created:

class SanctumTokenTest extends TestCase
{
    // ...
        
    public function testLdapUserCannotRetrieveTokenWithInvalidPassword()
    {
        DirectoryEmulator::setup();

        $ldapUser = LdapUser::create([
            'cn' => 'John Doe',
            'mail' => '[email protected]',
        ]);

        // Post the token endpoint and ensure a validation error is thrown:
        $this->postJson('api/sanctum/token', [
            'email' => $ldapUser->mail[0],
            'password' => 'secret',
            'device_name' => 'browser',
        ])->assertJsonValidationErrors([
            'email' => 'The provided credentials are incorrect.'
        ]);

        // Ensure the user was not imported:
        $this->assertDatabaseMissing(User::class, [
            'email' => $ldapUser->mail[0],
            'name' => $ldapUser->cn[0],
        ]);

        // Ensure no token was created:
        $this->assertDatabaseCount(PersonalAccessToken::class, 0);
    }
}

Nice, now let's add another to ensure our LDAP users can authenticate using their token:

class SanctumTokenTest extends TestCase
{
    // ...

    public function testLdapUserCanUseTokenOnProtectedRoutes()
    {
        $fake = DirectoryEmulator::setup();

        $ldapUser = LdapUser::create([
            'cn' => 'John Doe',
            'mail' => '[email protected]',
        ]);

        $fake->actingAs($ldapUser);

        // Post the token endpoint and grab the API token:
        $plainTextToken = $this->postJson('api/sanctum/token', [
            'email' => $ldapUser->mail[0],
            'password' => 'secret',
            'device_name' => 'browser',
        ])->json('token');

        // Attempt retrieving the user using the returned token:
        $this->getJson('api/user', [
            'Authorization' => "Bearer $plainTextToken",
        ])->assertJsonStructure([
            'id',
            'guid',
            'name',
            'email',
            'domain',
        ]);
    }
}

Great. Now we have our API Sanctum endpoints tested against our LDAP integration!

Generated on November 8, 2024
Edit on GitHub