Uwierzytelnianie (#2) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Dane przesyłane między systemami są wrażliwe. Mimo, że aplikacje komunikujące się między sobą są wykorzystywane tylko wewnątrz organizacji to istnieją w sieci. Należy zadbać o ich odpowiednie bezpieczeństwo. Istnieje kilka popularnych sposobów uwierzytelniania API. Najczęściej opierają się one o tak zwany token, czyli szyfrowany ciąg znaków. Token zazwyczaj jest generowany na żądanie i ma określony czas trwania. Po jego wygaśnięciu należy go odświeżyć lub uzyskać nowy. Prostszą, ale mniej bezpieczną wersją może być stały token, który nie wygasa (jak hasło). Aktualnie chyba najpopularniejszą metodą uwierzytelniania jest Oauth2.

Ja zaimplementuję uwierzytelnianie w oparciu o Basic Authentication. Nie jest to może najbezpieczniejsza opcja, ale zdecydowanie najprostsza. Takie rozwiązanie opiera się o nagłówek HTTP. Nie potrzebuje więc ciastek, sesji czy jakiegoś specjalnego logowania. Na podstawie nazwy użytkownika i hasła tworzy hash base64 z formatu username:password. Jako, że w sposób jawny przesyła się dane dostępowe, jeśli ktoś przechwyci pakiety uzyska dostęp do API. Nagłówek ten dołączany jest do każdego żądania, więc istnieje wyższe prawdopodobieństwo jego podsłuchania, niż w przypadku jednorazowego logowania w celu uzyskania tokena. Oczywiście w tamtych metodach do każdego żądania dołączany jest token. Jego przechwycenie umożliwi jednak dostęp do systemu, określony jego czasem wygaśnięcia, a nie na stałe.

Moim celem nie jest jednak tworzenie najbezpieczniejszego modułu, a prezentacja możliwości Laminas Mezzio. Oprócz HTTP Basic, w najprostszy możliwy sposób zabezpieczę dostęp po adresie IP. No i rzecz jasna, cała komunikacja odbywa się z wykorzystaniem SSL. W każdym razie, do produkcyjnego wykorzystania pewnie warto pomyśleć o takim sposobie uwierzytelniania jaki spełnia kryteria bezpieczeństwa dla danej aplikacji, czy nawet organizacji.

Implementacja uwierzytelniania API w Mezzio

Implementacja całego mechanizmu od zera nie byłaby aż tak skomplikowana, ale skoro istnieje gotowy i sprawdzony komponent dostarczany przez twórcę to dlaczego by z niego nie skorzystać. Zacznę od dociągnięcia właściwych zależności. Chodzi o mezzio-authentication i adapter mezzio-authentication-basic. Jako, że ten drugi zależny jest od pierwszego to wystarczy wykonać w terminalu poniższe polecenie.

composer require mezzio/mezzio-authentication-basic

Jest to gotowy adapter do uwierzytelniania tego rodzaju. Zaraz przejdę do tego jak użyć go w praktyce. Zanim jednak zacznę, wspomnę tylko że nie jest to jedyny przygotowany adapter. Można skorzystać z opcji OAuth2, czy klasycznej sesji.

Jako że jest to zewnętrzy komponent, postaram się go rozszerzyć w taki sposób, by aż tak bardzo się od niego nie uzależnić. Plus za to, że działa na interfejsach, więc dostawca mi to ułatwia. Funkcjonalność tę wydzielam do osobnego modułu. Tworzę go więc obok modułu API, a do autoloadera w composer.json dorzucam "Auth\": "src/Auth/src" i dla deweloperskiego "AuthTest\": "src/Auth/test". Jeszcze konfiguracja testów w pliku phpunit.xml. Zostało tylko stworzyć w nowym module klasę Auth\ConfigProvider.php i w pliku config.php dorzucić go do agregacji w ten sposób jak poniżej. Upewnijcie się też, że macie dodane dwa pierwsze providery z pakietu Mezzio\Authentication.

<?php

declare(strict_types=1);

use Laminas\ConfigAggregator\ArrayProvider;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\ConfigAggregator\PhpFileProvider;

$cacheConfig = [
    'config_cache_path' => 'data/cache/config-cache.php',
];

$aggregator = new ConfigAggregator([
    Mezzio\Authentication\Basic\ConfigProvider::class,
    Mezzio\Authentication\ConfigProvider::class,
    Mezzio\Router\FastRouteRouter\ConfigProvider::class,
    Laminas\HttpHandlerRunner\ConfigProvider::class,
    new ArrayProvider($cacheConfig),
    Mezzio\Helper\ConfigProvider::class,
    Mezzio\ConfigProvider::class,
    Mezzio\Router\ConfigProvider::class,
    Laminas\Diactoros\ConfigProvider::class,
    Auth\ConfigProvider::class,
    Api\ConfigProvider::class,
    new PhpFileProvider(realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php'),
    new PhpFileProvider(realpath(__DIR__) . '/development.config.php'),
], $cacheConfig['config_cache_path']);

return $aggregator->getMergedConfig();

Na razie klasa Auth\ConfigProvider.php powinna wyglądać jak kod pod spodem. Pusty szablon, który można skopiować z modułu bazowego i usunąć tamtejsze zależności.

<?php

declare(strict_types=1);

namespace Auth;

use Mezzio\Authentication\AuthenticationInterface;
use Mezzio\Authentication\Basic\BasicAccess;
use Mezzio\Authentication\UserRepositoryInterface;

class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies' => $this->getDependencies()
        ];
    }

    public function getDependencies(): array
    {
        return [];
    }
}

Teraz, kiedy już wykonałem to ręcznie i wiadomo, co dzieje się za kurtyną, dodam że stworzyć i zarejestrować nowy moduł można za pomocą komendy w terminalu. Ewentualnie też stworzyć ręcznie, a tylko zarejestrować z poziomu terminala. Możliwe jest to dzięki Command Line Tooling, z którego akurat ja nie korzystam w tym projekcie. Jeśli chcecie używać to trzeba doinstalować poniższym poleceniem.

composer require --dev mezzio/mezzio-tooling

Teraz można wywołać poniższą komendę i tym samym uzyskać ten sam efekt do którego ja doszedłem ręcznie. Oczywiście konfigurację dla testów nadal trzeba ogarnąć samodzielnie.

./vendor/bin/mezzio module:create -c -p Auth

Następnie tworzę plik konfiguracyjny config/autoload/auth.global.php i wrzucam w niego wymaganą konfigurację, która prezentuje się następująco:

<?php

declare(strict_types=1);

return [
    'authentication' => [
        'realm' => 'api'
    ]
];

W tym momencie w klasie konfiguracyjnej wprowadzam kilka zmian. Normalnie rejestruje zależności dopiero po utworzeniu klas, ale tym razem od razu chcę pokazać, co będzie potrzebne.

<?php

declare(strict_types=1);

namespace Auth;

use Mezzio\Authentication\AuthenticationInterface;
use Mezzio\Authentication\Basic\BasicAccess;
use Mezzio\Authentication\UserRepositoryInterface;

class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies' => $this->getDependencies()
        ];
    }

    public function getDependencies(): array
    {
        return [
            'aliases' => [
                UserRepositoryInterface::class => Repository\UserRepository::class,
                AuthenticationInterface::class => BasicAccess::class
            ],
            'factories'  => [
                Repository\UserRepository::class => Factory\UserRepositoryFactory::class
            ]
        ];
    }
}

Aliasy to opcja wskazania kontenerowi DI, żeby dla żądania takiej klasy/interfejsu pobrać odpowiadające im klasy. Poza UserRepository wszystkie te klasy pochodzą właśnie z dociągniętego wcześniej komponentu. Repozytorium użytkowników trzeba stworzyć samodzielnie. I dobrze, bo w jednym systemie użytkownicy mogą pochodzić z relacyjnej bazy danych, w innym nierelacyjnej, a w tej aplikacji będą z pliku konfiguracyjnego.

Repozytorium musi implementować wspomniany interfejs, który aby spełnić wystarczy jedna metoda authenticate(). Ja go rozszerzę, po to by w aplikacji działać na własnym interfejsie. Repozytorium użytkownika na pewno w przyszłości będzie zawierało inne metody, które właśnie tam można definiować. Oczywiście mógłbym działać na dwóch interfejsach, bo klasa przecież może implementować ich wiele, ale postanowiłem go rozszerzyć. Jedyne miejsce, gdzie wykorzystuje Mezzio\Authentication\UserRepositoryInterface to właśnie na potrzeby uwierzytelniania.

<?php

declare(strict_types=1);

namespace Auth\Repository;

use Mezzio\Authentication\UserRepositoryInterface as AuthUserRepositoryInterface;

interface UserRepositoryInterface extends AuthUserRepositoryInterface
{

}

Teraz wystarczy zaimplementować owe repozytorium. Ja dostarczam mu kolekcję użytkowników. W metodzie odpowiedzialnej za uwierzytelnianie, sprawdzam czy użytkownik istnieje i jeśli tak, to czy jego hasło się zgadza. Jeśli zostanie zwrócony null to uwierzytelnianie się nie powiedzie.

<?php

declare(strict_types=1);

namespace Auth\Repository;

use Auth\Model\UserCollection;
use Mezzio\Authentication\UserInterface;

class UserRepository implements UserRepositoryInterface
{
    private UserCollection $users;

    public function __construct(UserCollection $users)
    {
        $this->users = $users;
    }

    public function authenticate(string $credential, ?string $password = null): ?UserInterface
    {
        $user = $this->users->get($credential);

        return $user?->isPasswordValid($password) ? $user : null;
    }
}

Zauważcie, że repozytorium użytkownika działa na kolekcji. Tej klasy nie obchodzi skąd ją dostanie. A w tym przypadku będzie to fabryka. Najpierw jednak dwie potrzebne klasy: User i UserCollection.

Użytkownik na potrzeby metody authenticate() musi spełniać interfejs Mezzio\Authentication\UserInterface. Analogicznie jak wcześniej stworzę własny, który go rozszerzy.

<?php

declare(strict_types=1);

namespace Auth\Model;

use Mezzio\Authentication\UserInterface as AuthUserInterface;

interface UserInterface extends AuthUserInterface
{
    public function isPasswordValid(string $password): bool;

    public function getIpAddresses(): array;
}

Metoda getIpAddresses() przyda się później do zabezpieczenia dostępu po adresie IP. Pełna implementacja klasy użytkownika wygląda następująco:

<?php

declare(strict_types=1);

namespace Auth\Model;

class User implements UserInterface
{
    private string $identity;

    private string $hashedPassword;

    private array $ipAddresses;

    public function __construct(
        string $identity,
        string $hashedPassword,
        array $ipAddresses
    ) {
        $this->identity = $identity;
        $this->hashedPassword = $hashedPassword;
        $this->ipAddresses = $ipAddresses;
    }

    public function getIdentity(): string
    {
        return $this->identity;
    }

    public function isPasswordValid(string $password): bool
    {
        return password_verify($password, $this->hashedPassword);
    }

    public function getIpAddresses(): array
    {
        return $this->ipAddresses;
    }

    public function getRoles(): iterable
    {
        return [];
    }

    public function getDetail(string $name, $default = null): mixed
    {
        return $default;
    }

    public function getDetails(): array
    {
        return [];
    }
}

Metody getRoles(), getDetail() i getDetails() są wymagane przez interfejs, ale nie będą potrzebne, więc zostawiam je „puste”. Wydaje mi się, że reszta jest czytelna. No i jeszcze prosta implementacja kolekcji użytkowników. Można byłoby oprzeć się o zwykłą tablicę, ale taka klasa się przyda. Pilnuje tego, żeby w worku mogły znaleźć się tylko instancje Auth\Model\UserInterface.

<?php

declare(strict_types=1);

namespace Auth\Model;

class UserCollection
{
    private array $users;

    public function __construct()
    {
        $this->users = [];
    }

    public function get(string $userId): ?User
    {
        return $this->users[$userId] ?? null;
    }

    public function add(UserInterface $user): void
    {
        $this->users[$user->getIdentity()] = $user;
    }
}

Mając już tak zamodelowanego użytkownika, można przejść do fabryki odpowiadającej za dostarczenie użytkowników do repozytorium.

<?php

declare(strict_types=1);

namespace Auth\Factory;

use Auth\Model\User;
use Auth\Model\UserCollection;
use Auth\Repository\UserRepository;
use Interop\Container\ContainerInterface;

class UserRepositoryFactory
{
    public function __invoke(ContainerInterface $container): UserRepository
    {
        $config = $container->get('config');

        $users = new UserCollection();
        foreach ($config['authentication']['users'] ?? [] as $userId => $userData) {
            if (empty($userData['password'])) {
                throw new \InvalidArgumentException('User password is required.');
            }

            $users->add(
                new User(
                    $userId,
                    $userData['password'],
                    $userData['ipAddresses'] ?? []
                )
            );
        }

        return new UserRepository($users);
    }
}

Dla tych co nie wiedzą – fabryka to zwykła klasa odpowiedzialna za tworzenie obiektu. To implementacja najprostszej formy, czyli Simple Factory. Żeby Service Manager sobie z nią poradził, musi ona zawierać metodę magiczną __invoke(ContainerInterface $container) i brak zależności w konstruktorze. OK, klasa musi stworzyć repozytorium użytkownika do którego musi dostarczyć kolekcję. Z kontenera wyciągam konfigurację, a z niej dane o użytkownikach. Użytkownicy są tworzeni i dodawani do worka z danymi, który następnie zostaje przekazany do właściwej klasy. To wszystko. Poniżej jeszcze plik konfiguracyjny config/autoload/auth.local.php.dist, z którego należy stworzyć plik config/autoload/auth.local.php z właściwą dla środowiska konfiguracją systemów mających dostęp do API.

<?php

declare(strict_types=1);

return [
    'authentication' => [
        'users' => [
            ':username' => [
                'password' => ':password',
                'ipAddresses' => [
                    ':ipAddress1',
                    ':ipAddress2'
                ]
            ]
        ]
    ]
];

Uwierzytelnianie jest już gotowe do wykorzystania. Nie zrobię tego jednak w tym kroku, gdyż nie posiadam jeszcze endpointów API. Na ten moment istnieje tylko strona główna z „Hello World”, która docelowo zostanie usunięta. Trzymam ją tylko po to żeby widzieć, że aplikacja działa. Żeby włączyć uwierzytelnianie wystarczy zarejestrować Mezzio\Authentication\AuthenticationMiddleware.

Zabezpieczenie dostępu po adresie IP

Czas na stworzenie pierwszego własnego middleware, który będzie odpowiedzialny za sprawdzenie, czy system próbujący się uwierzytelnić wykonuje żądania z określonego adresu IP. Middleware to kod pośredniczący, który jest wykonywany pomiędzy żądaniem, a odpowiedzią. Stanowi pewną warstwę filtrującą i jest odpowiedzialny za wykonanie pojedynczej, niewielkiej funkcji i przekazanie rezultatu do kolejnej warstwy. Są to małe klocki, które powinny być łatwe w napisaniu i utrzymaniu, a także na tyle uniwersalne, by można było ich używać w wielu miejscach.

Jeśli chodzi o typy middleware w Laminas Mezzio, to wyróżnić można dwa główne. Pierwszy z nich to pipeline, którego zadaniem jest kierowanie przepływem w aplikacji, co oznacza tyle, że odpalany jest przy każdym żądaniu (ewentualnie ograniczonym konkretną ścieżką). Rejestrowane są w pliku config/pipeline.php. Drugi to routed – ten zaś przypisany jest do konkretnej akcji, która wykona się tylko dla określonego routingu. Rejestrowane są razem z routingiem w pliku config/routes.php.

Do jakiej grupy należeć będą więc Auth\Model\IpAccessControlMiddleware i Mezzio\Authentication\AuthenticationMiddleware? Wszystko zależy od przeznaczenia. Może się zdarzyć, że różne endpointy zabezpieczane są w inny sposób. Niektóre może nawet są publiczne i nie wymagają uwierzytelniania. Wówczas można iść w kierunku routed middleware, ale wtedy trzeba pamiętać, by dla każdej nowej ścieżki go dorzucać. W przypadku tej aplikacji, zdecydowałem że będą to pipeline dla wszystkich ścieżek /api/v1. Tak jak pisałem, rejestracji dokonam w momencie posiadania już endpointów.

Wiedząc, czym są middleware – czas na stworzenie pierwszego! Klasa musi implementować Psr\Http\Server\MiddlewareInterface, który wymusza jedynie metodę process(). Metoda ta przyjmuje jako argumenty Psr\Http\Message\ServerRequestInterface i Psr\Http\Server\RequestHandlerInterface, a zwraca Psr\Http\Message\ResponseInterface. Jak widzicie wszystko oparte o interfejsy PSR.

<?php

declare(strict_types=1);

namespace Auth\Middleware;

use Auth\Model\UserInterface;
use Mezzio\Authentication\AuthenticationInterface;
use Mezzio\Authentication\UserInterface as AuthUserInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Webmozart\Assert\Assert;

class IpAccessControlMiddleware implements MiddlewareInterface
{
    private AuthenticationInterface $auth;

    public function __construct(AuthenticationInterface $auth)
    {
        $this->auth = $auth;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        /** @var UserInterface $user */
        $user = $request->getAttribute(AuthUserInterface::class);
        Assert::isInstanceOf($user, UserInterface::class);

        $requestIp = $request->getServerParams()['REMOTE_ADDR'];
        if (!in_array($requestIp, $user->getIpAddresses())) {
            return $this->auth->unauthorizedResponse($request);
        }

        return $handler->handle(
            $request->withAttribute(UserInterface::class, $user)
        );
    }
}

Kodu nie ma za dużo, co widać na załączonym obrazku. Mała funkcjonalność i klasa przekazuje sztafetę do handlera, bądź jeśli w łańcuchu pośredników znajduje się kolejny, to właśnie on przejmie pałeczkę. Z obiektu $request pobierany jest użytkownik. Wcześniejszy middleware musi więc go przekazać. Jako, że ten middleware będzie poprzedzony przez Mezzio\Authentication\AuthenticationMiddleware to sprawa wydaje się być załatwiona.

Przekazać parametry do requesta można za pomocą metody withAttribute() tak jak ma to miejsce w powyższym kodzie. Cała funkcjonalność zamyka się więc w pobraniu adresu IP z instancji żądania i sprawdzenia, czy występuje on wśród adresów użytkownika. Jeśli nie, to metoda zwróci próbę nieautoryzowanego dostępu.

Aby middleware mógł zostać stworzony potrzebuje obiektu Mezzio\Authentication\AuthenticationInterface. Sprawę jak zwykle załatwia fabryka i wyciągniecie owego obiektu z kontenera.

<?php

declare(strict_types=1);

namespace Auth\Factory;

use Auth\Middleware\IpAccessControlMiddleware;
use Interop\Container\ContainerInterface;
use Mezzio\Authentication\AuthenticationInterface;
use Webmozart\Assert\Assert;

class IpAccessControlMiddlewareFactory
{
    public function __invoke(ContainerInterface $container): IpAccessControlMiddleware
    {
        $authentication = $container->has(AuthenticationInterface::class)
            ? $container->get(AuthenticationInterface::class)
            : null;
        Assert::notNull($authentication, 'AuthenticationInterface service is missing');
        Assert::isInstanceOf($authentication, AuthenticationInterface::class);

        return new IpAccessControlMiddleware($authentication);
    }
}

Teraz trzeba jeszcze zarejestrować nową klasę. Należy wrócić więc do Auth\ConfigProvider.php i dorzucić jedną linię jak w poniższym fragmencie kodu.

<?php

declare(strict_types=1);

namespace Auth;

use Mezzio\Authentication\AuthenticationInterface;
use Mezzio\Authentication\Basic\BasicAccess;
use Mezzio\Authentication\UserRepositoryInterface;

class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies' => $this->getDependencies()
        ];
    }

    public function getDependencies(): array
    {
        return [
            'aliases' => [
                UserRepositoryInterface::class => Repository\UserRepository::class,
                AuthenticationInterface::class => BasicAccess::class
            ],
            'factories'  => [
                Repository\UserRepository::class => Factory\UserRepositoryFactory::class,
                Middleware\IpAccessControlMiddleware::class => Factory\IpAccessControlMiddlewareFactory::class
            ]
        ];
    }
}

W tym momencie moduł uwierzytelniania jest gotowy do użycia. Zarówno sprawdzenie danych dostępowych jak i adresu IP. Tak jak wspomniałem, wykorzystam go w dalszej części serii. Aby jednak mieć pewność, że zadziała – przyda się kilka testów.

Testy dla modułu uwierzytelniania

Trzy klasy pokryłem testami i to wydaje mi się wystarczające. Na pierwszy ogień AuthTest\Repository\UserRepositoryTest. Na ten moment przede wszystkim zależy mi na zweryfikowaniu poprawności metody authenticate().

<?php

declare(strict_types=1);

namespace AuthTest\Repository;

use Auth\Model\User;
use Auth\Model\UserCollection;
use Auth\Model\UserInterface;
use Auth\Repository\UserRepository;
use Auth\Repository\UserRepositoryInterface;
use PHPUnit\Framework\TestCase;

class UserRepositoryTest extends TestCase
{
    private const USER_VALID_ID = 'validId';
    private const USER_INVALID_ID = 'invalidId';
    private const USER_VALID_PASSWORD = 'validPassword';
    private const USER_INVALID_PASSWORD = 'invalidPassword';
    private const USER_VALID_PASSWORD_HASH = '$2y$10$dKjKO6iC9lP.UVbaCVTZcut51ODnOhUe1rQKC.YA8lMkzUpaGI7WC';

    private UserRepositoryInterface $userRepository;

    protected function setUp(): void
    {
        $users = new UserCollection();
        $users->add(
            new User(self::USER_VALID_ID, self::USER_VALID_PASSWORD_HASH, [])
        );

        $this->userRepository = new UserRepository($users);
    }

    public function testIsAuthenticationFailedWhenCredentialIsWrong(): void
    {
        $this->assertNull(
            $this->userRepository->authenticate(self::USER_INVALID_ID, self::USER_VALID_PASSWORD)
        );
    }

    public function testIsAuthenticationFailedWhenPasswordIsWrong(): void
    {
        $this->assertNull(
            $this->userRepository->authenticate(self::USER_VALID_ID, self::USER_INVALID_PASSWORD)
        );
    }

    public function testCanPassThruAuthenticationWhenCredentialsAreGood(): void
    {
        $this->assertInstanceOf(
            UserInterface::class,
            $this->userRepository->authenticate(self::USER_VALID_ID, self::USER_VALID_PASSWORD)
        );
    }
}

Drugi w kolejce jest AuthTest\Model\UserTest. Tu z kolei ważna jest metoda weryfikująca poprawność hasła. W tym wypadku, modyfikatorów (getter/setter) nie ma sensu testować.

<?php

declare(strict_types=1);

namespace AuthTest\Model;

use Auth\Model\User;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function testCanVerifyPassword(): void
    {
        $invalidPassword = 'invalidPassword';
        $validPassword = 'validPassword';
        $validPasswordHash = '$2y$10$dKjKO6iC9lP.UVbaCVTZcut51ODnOhUe1rQKC.YA8lMkzUpaGI7WC';

        $user = new User('testId', $validPasswordHash, []);

        $this->assertFalse($user->isPasswordValid($invalidPassword));
        $this->assertTrue($user->isPasswordValid($validPassword));
    }
}

No i ostatni test, który wymaga już trochę więcej komentarza: AuthTest\Middleware\IpAccessControlMiddlewareTest. Testy jednostkowe middleware zazwyczaj będą sprowadzały się do tego samego. Na wejściu zamockowane request oraz handler i ewentualne zależności middleware. Na końcu odpalenie jedynej metody publicznej process(). No i oczywiście sprawdzenie rezultatu. Można faktycznie sprawdzać zwrotkę, ale dla mnie wystarczająca jest informacja, że odpowiednie metody zostały (bądź nie) wywołane.

<?php

declare(strict_types=1);

namespace AuthTest\Middleware;

use Auth\Middleware\IpAccessControlMiddleware;
use Auth\Model\User;
use Auth\Model\UserInterface;
use Mezzio\Authentication\AuthenticationInterface;
use Mezzio\Authentication\UserInterface as AuthUserInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class IpAccessControlMiddlewareTest extends TestCase
{
    use ProphecyTrait;

    public function testCannotAccessWithWrongIpAddress(): void
    {
        /** @var ServerRequestInterface|ObjectProphecy $request */
        $request = $this->prophesize(ServerRequestInterface::class);

        /** @var RequestHandlerInterface|ObjectProphecy $handler */
        $handler = $this->prophesize(RequestHandlerInterface::class);

        /** @var AuthenticationInterface|ObjectProphecy $auth */
        $auth = $this->prophesize(AuthenticationInterface::class);

        $request
            ->getAttribute(AuthUserInterface::class)
            ->willReturn(
                new User('testId', 'hashedPassword', ['196.168.0.1'])
            );

        $request
            ->getServerParams()
            ->willReturn([
                'REMOTE_ADDR' => '127.0.0.1'
            ]);

        $auth
            ->unauthorizedResponse($request->reveal())
            ->shouldBeCalled();

        $middleware = new IpAccessControlMiddleware($auth->reveal());
        $middleware->process($request->reveal(), $handler->reveal());
    }

    public function testCanAccessWithAllowedIpAddress(): void
    {
        /** @var ServerRequestInterface|ObjectProphecy $request */
        $request = $this->prophesize(ServerRequestInterface::class);

        /** @var RequestHandlerInterface|ObjectProphecy $handler */
        $handler = $this->prophesize(RequestHandlerInterface::class);

        /** @var AuthenticationInterface|ObjectProphecy $auth */
        $auth = $this->prophesize(AuthenticationInterface::class);

        $user = new User('testId', 'hashedPassword', ['127.0.0.1']);

        $request
            ->getAttribute(AuthUserInterface::class)
            ->willReturn($user);

        $request
            ->getServerParams()
            ->willReturn([
                'REMOTE_ADDR' => '127.0.0.1'
            ]);

        $request
            ->withAttribute(UserInterface::class, $user)
            ->willReturn($request->reveal());

        $auth
            ->unauthorizedResponse($request->reveal())
            ->shouldNotBeenCalled();

        $handler
            ->handle($request->reveal())
            ->shouldBeCalled();

        $middleware = new IpAccessControlMiddleware($auth->reveal());
        $middleware->process($request->reveal(), $handler->reveal());
    }
}

Podsumowanie uwierzytelniania w Laminas Mezzio

Cała implementacja jest przykładem tego jak można to zrobić. Tak jak wspomniałem, istnieją bezpieczniejsze formy uwierzytelniania, niż Basic Auth. W kolejnym kroku zajmę się przygotowaniem abstrakcji dla komunikacji z bazą danych. W czwartym kroku, kiedy dojdzie do implementacji faktycznych endpointów API, oba te moduły zostaną wykorzystane.

Kod dostępny w repozytorium jako gałąź step02. Zachęcam do poczytania o middleware w oficjalnej dokumentacji Laminas Mezzio.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.