Mailcoach is a self-hosted email list manager. It integrates with services like Amazon SES, Mailgun or Sendgrid to send out mailings affordably.

Win a free license by entering our contest!.

Pragmatically testing multi-guard authentication in Laravel

Original – by Freek Van der Herten – 2 minute read

Last week our team launched Mailcoach, 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, 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, who opened that issue, 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.

/*
 *  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

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. 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:

// 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.

/** @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.

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.

/** @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.

/** @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.

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 who helped with figuring out these tests.

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

You can comment on this post by replying to this tweet.
Neil Carlo Faisan Sucuangco liked on 4th February 2020
Thibault Six liked on 4th February 2020
Matthew Poulter liked on 4th February 2020
Alex Pierre liked on 4th February 2020
Chris Blackwell liked on 4th February 2020
Chris Balicki liked on 3rd February 2020
PakistanZindabad liked on 3rd February 2020
Ahmed Abd El Ftah liked on 3rd February 2020
Edilson D Mucanze liked on 3rd February 2020
Guus liked on 3rd February 2020
Spatie retweeted on 3rd February 2020
PHP Synopsis retweeted on 3rd February 2020
. liked on 3rd February 2020
José Cage liked on 3rd February 2020
oluwajubelo loves VueJS 🚨 liked on 3rd February 2020
Kassius Kress retweeted on 3rd February 2020
Kassius Kress liked on 3rd February 2020
Benjamin Crozat liked on 3rd February 2020
PHP Synopsis liked on 3rd February 2020
Tony Messias liked on 3rd February 2020
Mina Abadir liked on 3rd February 2020