Insbesondere bei API’s arbeitet man häufig bei der Authentifizierung mit JWT und/oder Token. Für Symfony Projekte gibt es auch eine gute Dokumentation eines Token Subscribers, den man mittels einem Interface an jeden Controller hängen kann, der dann automatisch die Gültigkeit eines Tokens prüfen soll. Spätestens aber wenn man Unittests dafür schreiben muss, wird es hakelig.

Für Standard API’s mag das auch so völlig ausreichend sein. Was aber, wenn man keine Exception direkt an der API Route zurückgeben möchte? Tatsächlich kam genau diese Frage in einem Projekt nun auf und so google’te ich sogar zusammen mit Kollegen nach Möglichkeiten. Die Lösungen, die wir hauptsächlich aus Stackoverflow herausfanden sind nicht wirklich saubere Lösungen, die da wären wie eine direkte JsonResponse aus dem Subscriber zurückzugeben. Denn wenn man eine hohe Coverage beim Testen erreichen möchte, ist es fast unmöglich mindestens aber unheimlich komplex durch eine Vielzahl an Mockings den Subscriber zu testen (abgesehen davon, dass man von einem Subscriber aus keine Response zurückgibt)!

Die Lösung sind weitere Events

Eigentlich ist die Lösung recht simpel, in dem man einfach auf das KernelEvent Exception hört (also ein weiterer EventListener) und hier in die Response seine Message schreibt. Verdeutlichen möchte ich das in diesem Artikel mit Beispielen, aber insbesondere auch, wie man beide Events dann sauber testen kann und damit auch eine hohe Coverage erreicht.

Ich erspare mir hier das Interface und die Einbindung in einem Controller wie man Tokens prüfen kann darzustellen, dass findet man auch in dem Artikel aus der Symfony Dokumentation.

Den Subscriber für die Prüpfung des Tokens allerdings stelle ich hier vor sowie auch den zugehörigen Unittest dafür. Bitte auch die Kommentare innerhalb des Codes beachten!

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Controller\Api\TokenAuthenticatedController;
use App\Service\LoginService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Exception;

class TokenSubscriber implements EventSubscriberInterface
{
    
    private LoginService $loginService;

    public function __construct(LoginService $loginService)
    {
        $this->loginService = $loginService;
    }

    
    public function onKernelController(ControllerEvent $event): void
    {
        $controller = $event->getController();
        $controller = is_array($controller) ? $controller[0] : $controller;
        
        
        if ($controller instanceof TokenAuthenticatedController) {
            $token = $this->getJwtFromHeader($event);
            if (empty($token)) {
                throw new AuthenticationException('Invalid or empty token', 401);
            } else {
                
                $data = $this->loginService->checkLogin($token);

                
                $request = $event->getRequest();
                $request->attributes->set('userId', $data['userId']);
                $request->attributes->set('userName', $data['userName']);
            }
        }
    }

    #[ArrayShape([KernelEvents::CONTROLLER => "string"])]
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::CONTROLLER => 'onKernelController'
        ];
    }
    
    private function getJwtFromHeader(ControllerEvent $event): ?string
    {
        return $event->getRequest()->headers->get('x-jwt-token');
    }
}

Dieser Token Subscriber ist doch eigentlich echt simpel und verständlich, oder? Demzufolge ist auch das Unittesten dieses Subscribers ziemlich simpel. Wenn es sich durch den gesamten Code zieht, erreicht man nicht nur relativ schnell überschaubaren Code, sondern auch eine sehr hohe Coverage, sofern man zumindest auch wehement Unittests für alles mögliche schreibt.

Noch einen Hinweis zum Prüfen des Tokens: Da ich vor allem im aktuellen Projekt sehr intensiv gelernt habe möglichst sauberen und lesbaren Code zu schreiben, lagere ich die eigentliche Prüfung des Tokens in einem Service (hier LoginService genannt) aus. In diesem Service kann man u.a. nicht nur die Prüfung feiner granulieren, sondern auch die Verarbeitung der einzelnen Rückgaben etc. Im Git-Repo ist natürlich auch ein Unittest für den LoginService vorhanden.

Auf KernelExceptions reagieren

Nun zum interessanten Teil, nämlich auf die AuthenticatedException mittels einer JsonResponse zu reagieren. Die Gründe hierfür sind vielfältig. Zum Beispiel könnte man genau hier auch auf BruteForce reagieren, oder man möchte einfach verhinden, dass schlimmstenfalls irgendwelche Informationen durch eine Exception weitergegeben werden (sollte im Prod ja eigentlich nicht, aber versehentlich kann alles passieren!).

<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

class AuthenticatedExceptionListener
{
    public function onKernelException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        
        $event->allowCustomResponseCode();

        if ($exception instanceof AuthenticationException && 401 === $exception->getCode()) {
            $response = new JsonResponse(
                [
                    'message' => $exception->getMessage(),
                ],
                Response::HTTP_UNAUTHORIZED
            );
            $event->setResponse($response);
        }
    }
}

Es fällt direkt auf, dass man diesen Listener auch viel flexibler gestalten und damit auf diverse Exceptions oder Codes reagieren könnte. Wie auch schon zuvor genannt, kann man nach Prüfung welche Exception geworfen wird, sogar auf BruteForce reagieren (mehrere AuthenticationExceptions von immer wieder gleichen IP innerhalb weniger Sekunden) und die IP z.B. serverseitig sperren (Cloudflare oder Server-Lösungen sind natürlich besser).

Um auf die Kernel Exceptions zu reagieren, müssen wir nun den Listener noch im Framework bekannt machen:

services:
    # ....
    App\EventListener\AuthenticatedExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

Testing time…. Bindet das Ganze doch mal in eure API ein und übergibt mal keinen Token (sprich die API Url einfach im Browser aufrufen). Ihr solltet dann eine JsonResponse mit der Nachricht „Invalid or empty token“ erhalten, die durch den AuthenticatedExceptionListener zurückgegeben wird (sofern Ihr natürlich auch an die Symfony Dokumentation gehalten und das passende Interface im Controller implementiert habt).

Die fehlenden Unittests

Nun, ich bin ja neuerdings Freund von umfangreichen Unittests möglichst des gesamten Systems. Allein dieser Umstand für den TokenSubscriber sind mindestens 2 Manntage drauf gegangen, wie wir den Subscriber sauber Unittesten können. Wir hatten zwar relativ am Ende eine Möglichkeit mit sehr viel Mocking erreicht, hatten aber dennoch rote Balken im Coverage-Report. Damit wollte ich mich nicht zufrieden geben, weshalb ich auf die o.g. Lösung kam und mit folgenden Unittests eine 100% Coverage beider Klassen erhielt.

Da dennoch auch einige Mockings generiert werden müssen und das auch nicht gerade trivial war, alles herauszufinden, teile ich es etwas auf.

Zunächst benötigte ich einen Helper für die Requests, die ich nicht nur in den beiden Event Unittest brauche. Diesen Helper platziere ich daher im Test-Framework.

<?php

declare(strict_types=1);

namespace App\Tests\Helper;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;

class RequestHelper extends TestCase
{
    public static function initRequest(
        string $returnValue,
        ?string $uri = null,
        ?string $query = null,
        ?string $token = null,
        ?array $setAttributes = null
    ): Request {
        $headerBag = [];
        $attributes = [];

        $requestMock = (new RequestHelper())->createMock(Request::class);
        if (!empty($token)) {

            $headerBag['x-jwt-token'] = $token;
        }

        if (!empty($setAttributes)) {
            $attributes = $setAttributes;
        }

        $requestMock->headers = new HeaderBag($headerBag);
        $requestMock->attributes = new ParameterBag($attributes);

        $requestMock->method('getRequestUri')
            ->willReturn($uri);
        $requestMock->method('getContent')
            ->willReturn($returnValue);
        $requestMock->method('get')
            ->willReturn($query);

        return $requestMock;
    }
}

Die Vorbereitungen sind damit soweit erstmal durch und ich beginne mal mit dem einfacheren Unittest für den Event Listener AuthenticatedExceptionListener:

<?php

declare(strict_types=1);

namespace App\Tests\EventListener;

use App\EventListener\AuthenticatedExceptionListener;
use App\Tests\Helper\RequestHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class AuthenticatedExceptionListenerTest extends TestCase
{
    public function testAuthenticatedExceptionListenerReturnsJsonResponse(): void
    {
        $httpKernelInterface = $this->createMock(HttpKernelInterface::class);
        $request = RequestHelper::initRequest(
            '',
            '/api/',
            null,
            null
        );

        $exceptionEvent = new ExceptionEvent(
            $httpKernelInterface,
            $request,
            HttpKernelInterface::MAIN_REQUEST,
            new AuthenticationException('exception', 401)
        );

        $expected = json_encode([
            'message' => 'exception'
        ]);

        $eventListener = new AuthenticatedExceptionListener();
        $eventListener->onKernelException($exceptionEvent);

        $response = $exceptionEvent->getResponse();

        $this->assertEquals($expected, $response->getContent());
    }
}

Oh man, irgendwie doch recht einfach. Was noch viel glücklicher macht, wenn man sich den Coverage Report ansieht: 100% der Klasse durchgetestet, Gotcha!

Nun aber noch der etwas kompliziertere Test mit dem TokenSubscriber. Hier müssen wir ja doch etwas mehr Mocken und natürlich ist das auch stark davon abhängig, wie der Controller aufgebaut wurde.

<?php

declare(strict_types=1);

namespace App\Tests\EventSubscriber;

use App\Controller\ExampleController;
use App\EventSubscriber\TokenSubscriber;
use App\Service\LoginService;
use App\Tests\Helper\RequestHelper;
use Exception;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class TokenSubscriberTest extends TestCase
{
    
    public function testExpectExceptionOnInvalidToken(): void
    {
        $loginService = $this->getMockBuilder(LoginService::class)
            ->disableOriginalConstructor()
            ->getMock();

        $httpKernelInterface = $this->createMock(HttpKernelInterface::class);

        $request = RequestHelper(
            '',
            '/api/'
        );

        $controller = $this->getMockBuilder(ExampleController::class)
            ->addMethods(['__invoke'])
            ->onlyMethods(['index'])
            ->getMock();
        $controller->method('__invoke')
            ->willReturn(function() use ($controller) {
                return $controller;
            });
        
        $controllerEvent = new ControllerEvent(
            $httpKernelInterface,
            $controller,
            $request,
            1
        );

        $tokenSubscriber = new TokenSubscriber($loginService);

        $this->expectException(AuthenticationException::class);
        $this->expectExceptionMessage('Invalid or empty token');

        $tokenSubscriber->onKernelController($controllerEvent);
    }

     
    public function testGetSubscribedEventsReturnsArray(): void
    {
        $this->assertIsArray(TokenSubscriber::getSubscribedEvents());
        $this->assertEquals([
            KernelEvents::CONTROLLER => 'onKernelController'
        ], TokenSubscriber::getSubscribedEvents());
    }

    
    public function testRequestAttributesExists(): void
    {
        $loginService = $this->getMockBuilder(LoginService::class)
            ->disableOriginalConstructor()
            ->getMock();
        $loginService->method('checkLogin')
            willReturn([
                'userId' => 1,
                'userName' => 'test'
            ]);

        $httpKernelInterface = $this->createMock(HttpKernelInterface::class);
        $request = RequestHelper::initRequest(
            '',
            '/api/',
            null,
            'fakeToken',
            [
                'userId' => 1,
                'userName' => 'test'
            ]
        );
        $controller = $this->getMockBuilder(ExampleController::class)
            ->addMethods(['__invoke'])
            ->onlyMethods(['action'])
            ->getMock();
        $controller->method('__invoke')
            ->willReturn(function() use ($controller) {
                return $controller;
            });
        $controllerEvent = new ControllerEvent(
            $httpKernelInterface,
            $controller,
            $request,
            1
        );

        $subscriber = new TokenSubscriber($loginService);
        $subscriber->onKernelController($controllerEvent);

        $this->assertTrue($request->attributes->has('userId'));
        $this->assertTrue($request->attributes->has('userName'));
        $this->assertEquals(1, $request->attributes->get('userId'));
        $this->assertEquals('test', $request->attributes->get('userName'));
    }
}

Fazit

In meinem Git-Repo SymfonyTokenSubscriber habe ich den gesamten Code passend zum Artikel gepusht und kann dort in Gänze nochmals nachgelesen werden. Hier findest Du unter anderem auch einen Unittest des Controllers, ganz einfach deshalb interessant, weil FunctionalTests schlicht weg echt teuer werden können (dadurch das bei jedem Test Durchlauf dauernd ein FakeBrowser mitgestartet werden muss). Vielleicht regt es auch an, für andere Controller entsprechende Unittests zu schreiben (mir ist auch nicht bewusst, wieso keine Unittests für Controller in der Symfony Dokumentation zu finden sind).

Wie bin ich (eigentlich wir!) zu dem Ergebnis gekommen? Nun, wenn eine Vorgabe ist, eine Coverage von mind. 97% des Codes zu erreichen, muss man sich wirklich gewaltig viele Gedanken um seinen Code machen. Denn sehr oft beschleichen einem erhebliche Bedenken, wenn man dann einen Coverage-Report einzelner Services & Klassen nochmals genauer anschaut. Es gibt schlicht viele Stellen im Code, die zwar mit einigen „leichten“ Lösungen aus dem Netz gelöst werden können, nicht aber eigentlich programmatisch / technisch wirklich richtig sind und vor allem solche Unittests beweisen es dann mit immenser Schlagkraft.

Es bleibt einem nichts anderes übrig, als in dem gesamten Code zu ermitteln, wie man eine vernünftige, saubere Lösung implementieren kann und insbesondere das Symfony Framework hat mir mal wieder bewiesen, dass es durchaus sogar relativ einfach möglich ist, guten, sauberen Code zu produzieren.