Pragmatically testing multi-guard authentication in Laravel
Tags: laravel, php, testing
Published: 2020-02-13 12:09:43

Last week our team launched [Mailcoach](https://mailcoach.app), a self-hosted solution to send out email campaigns and newsletters. Rather than being the end, laughing something is the beginning of a journey. Users start encountering bugs and ask for features that weren't considered before.

[One of those features requests we got](https://github.com/spatie/mailcoach-support/issues/11), is the ability the set the guard to be used when checking if somebody is allowed to access the Mailcoach UI.

In this blog post, I'd like to show you how we implemented and tested this.

## Implementing a setting to specify a guard

In a Laravel app, a guard defines how users are authenticated for each request. Some apps, like the one from [Lee Overy](https://github.com/leeovery), who opened that [issue](https://github.com/spatie/mailcoach-support/issues/11), need multiple ways to authenticate. Luckily Laravel offers support for multiple guards. 

In the initial release of Mailcoach, we just used the default guard. To offer multi guard support, we added a config value to our the `mailcoach.php` config file.

```php
/*
 *  This configuration option defines the authentication guard that will
 *  be used to protect the Mailcoach UI. This option should match one
 *  of the authentication guards defined in the "auth" config file.
 */
'guard' => env('MAILCOACH_GUARD', null),
```

Next, we created a new `Authenticate` middleware.

```php
<?php

namespace Spatie\Mailcoach\Http\App\Middleware;

use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Middleware\Authenticate as BaseAuthenticationMiddleware;

class Authenticate extends BaseAuthenticationMiddleware
{
    /**
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string[]  ...$guards
     * @return mixed
     *
     * @throws \Illuminate\Auth\AuthenticationException
     */
    public function handle($request, Closure $next, ...$guards)
    {
        try {
            $guard = config('mailcoach.guard');

            if (! empty($guard)) {
                $guards[] = $guard;
            }

            return parent::handle($request, $next, ...$guards);
        } catch (AuthenticationException $e) {
            throw new AuthenticationException('Unauthenticated.', $e->guards());
        }
    }
}
```

The code above is mostly taken from [Nova](https://nova.laravel.com). In this middleware, we add the guard name that was used in the `mailcoach.php` config file to the array of guards to check.

The last thing we needed to do was use this middleware on all Mailcoach routes. This is done in the `MailcoachServiceProvider`:

```php
// in MailcoachServiceProvider.php

protected function bootRoutes()
{
    Route::macro('mailcoach', function (string $url = '') {
        Route::get($url, HomeController::class)->name('mailcoach.home');

        Route::prefix($url)->group(function () {
            Route::prefix('')->group(__DIR__ . '/../routes/mailcoach-api.php');
            Route::middleware([
                'web',
                Authenticate::class,
                Authorize::class,
                SetMailcoachDefaults::class,
            ])->group(__DIR__ . '/../routes/mailcoach-ui.php');
        });
    });

    return $this;
}
```

And with that, the feature is completed.

## Testing the `Authenticate` middleware

Because this added functionality concerns security, I most certainly wanted to have tests around this feature. A first test proves that the normal behavior, without using a custom guard, works.

```php
/** @test */
public function when_not_authenticated_it_redirects_to_the_login_route()
{
    $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
}
```

By default, Mailcoach will redirect unauthenticated users to a route called `login`. The test above proves that that works. To make sure that the test actually works, I needed to set up some stuff in the setup method.

```php
public function setUp(): void
{
    parent::setUp();

    Route::get('login')->name('login');

    $this->withExceptionHandling();
}
```

Adding that login route is necessary because, by default, Mailcoach itself doesn't have a `login` route. We assume that the application you install Mailcoach will have that. So, in our tests, we need to set that up. That `withExceptionHandling` is necessary because otherwise, the test will blow up with an `AuthenticationException`.

Let's look at a second test. This one makes sure that when you are logged in, you can view the campaign screen of the mailcoach UI.

```php
/** @test */
public function when_authenticated_it_can_view_the_mailcoach_ui()
{
    $this->authenticate();

    $this->get(route('mailcoach.campaigns'))->assertSuccessful();
}
```

This is what that `authenticate` method looks like.

```
public function authenticate(string $guard = null)
{
    $user = factory(User::class)->create();

    $this->actingAs($user, $guard);
}
```

We instantiate a new user and make it the logged in user with `actingAs`. The `$guard` in the test above will be `null`, meaning the default guard, called `web` will be used. (This default guard is set up in the `auth.php` config file.

Up until now, we've only tested the default behavior and nothing about the code we added. I still like having those tests, to be 100% sure that our new feature doesn't mess with the default behavior.

Let's take a look at our third test.

```php
/** @test */
public function it_will_redirect_to_the_login_page_when_authenticated_with_the_wrong_guard()
{
    config()->set('mailcoach.guard', 'api');

    $this->authenticate('web');

    $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
}
```

In this test, we make sure that, if you're logged in with the wrong guard, you get redirected. First, we set the guard that mailcoach should use to `api` (`api` is also one of the guards that's being set up by default in a regular Laravel app). We use `web` to authenticate, and because that doesn't match with the guard that mailcoach uses, it will redirect to the login page.

Finally, let's look a the last test, in which we make sure that if mailcoach uses an alternative guard and you are logged in via that guard, you can see the UI.

```
/** @test */
public function when_authenticated_with_the_right_guard_it_can_view_the_mailcoach_ui()
{
    config()->set('mailcoach.guard', 'api');

    $this->authenticate('api');

    $this->get(route('mailcoach.campaigns'))->assertSuccessful();
}
```

And with that, we're sure that our `Authenticate` middleware works as expected.

Here's the full test.

```php
namespace Spatie\Mailcoach\Tests\Http\Middleware;

use Illuminate\Support\Facades\Route;
use Spatie\Mailcoach\Tests\TestCase;

class AuthenticateTest extends TestCase
{
    public function setUp(): void
    {
        parent::setUp();

        Route::get('login')->name('login');

        $this->withExceptionHandling();
    }

    /** @test */
    public function when_not_authenticated_it_redirects_to_the_login_route()
    {
        $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
    }

    /** @test */
    public function when_authenticated_it_can_view_the_mailcoach_ui()
    {
        $this->authenticate();

        $this->get(route('mailcoach.campaigns'))->assertSuccessful();
    }

    /** @test */
    public function it_will_redirect_to_the_login_page_when_authenticated_with_the_wrong_guard()
    {
        config()->set('mailcoach.guard', 'api');

        $this->authenticate('web');

        $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
    }

    /** @test */
    public function when_authenticated_with_the_right_guard_it_can_view_the_mailcoach_ui()
    {
        config()->set('mailcoach.guard', 'api');

        $this->authenticate('api');

        $this->get(route('mailcoach.campaigns'))->assertSuccessful();
    }
}
```

## In closing

What I like about these tests is that they are straightforward. Some might think it's better to test all of this in isolation (so you'd mock a request and let that go through the middleware), but I feel that pragmatic, more feature test like approach gives enough confidence that everything works as intended.

A big thank you to my colleague [Alex](https://twitter.com/AlexVanderbist) who helped with figuring out these tests.