Просмотр исходного кода

Merge branch 'feature/V8-4174_secure_internal_files_requests' into develop

Olivier Massot 2 лет назад
Родитель
Сommit
5b47898604

+ 5 - 4
.env

@@ -18,10 +18,6 @@ APP_DEBUG=0
 APP_SECRET=6a76497c8658bb23e2236f97a2627df3
 ###< symfony/framework-bundle ###
 
-###> files management ###
-INTERNAL_FILES_DOWNLOAD_URI=https://api.opentalent.fr/_internal/secure/files
-###< files management ###
-
 ###> doctrine/doctrine-bundle ###
 # => defined in the .env.<environment> files
 ###< doctrine/doctrine-bundle ###
@@ -107,3 +103,8 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
 ###> filename log ###
 LOG_FILE_NAME=undefined
 ###< filename log ###
+
+### Internal requests (@see doc/internal_requests.md)
+INTERNAL_REQUESTS_TOKEN=sRyfu6SZLR9StpnSKYRdl6i9wr5qs1bJQzro4DUiVyYJ2jknl
+INTERNAL_FILES_DOWNLOAD_URI=https://api.opentalent.fr/_internal/secure/files
+###

+ 2 - 0
config/packages/security.yaml

@@ -169,6 +169,8 @@ security:
     # Note: Only the *first* access control that matches will be used
     access_control:
         - { path: ^/api/public, roles: PUBLIC_ACCESS }
+        - { path: ^/api/internal, roles: INTERNAL_REQUEST }
+        - { path: ^/api/internal, roles: ROLE_NO_ACCESS }
         - { path: ^/api/, roles: IS_HAVING_MODULE }
 
 when@test:

+ 1 - 0
config/services.yaml

@@ -46,6 +46,7 @@ services:
         bind:
             $opentalentConfig: '%kernel.project_dir%%env(OPENTALENT_CONFIG)%'
             $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
+            $internalRequestsToken: '%env(INTERNAL_REQUESTS_TOKEN)%'
             $bindfileBufferFile: '%env(BIND_FILE_BUFFER_FILE)%'
             $persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'
             $removeProcessor: '@api_platform.doctrine.orm.state.remove_processor'

+ 71 - 0
doc/internal_requests.md

@@ -0,0 +1,71 @@
+# Internal Requests
+
+### Principe général
+
+Les requêtes internes sont des requêtes envoyées de ap2i vers opentalent-platform ou dans le sens inverse, par 
+exemple pour demander un fichier.
+
+Ces requêtes ne sont pas protégées par l'authentification Symfony standard, car elles doivent pouvoir être exécutées 
+en dehors du cadre d'une requête utilisateur, par exemple lors d'une exécution en ligne de commande ou lors 
+d'un processus asynchrone exécuté par messenger.
+
+Pour éviter tout risque de sécurité lié à ces routes :
+
+* on restreint leur accès aux ips internes
+* on conditionne l'autorisation à la présence d'un token
+* on limite les routes concernées
+
+Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` sur ap2i :
+
+* Un utilisateur dans le VPN qui ferait un curl à cette adresse recevra une erreur 500 à cause du token manquant
+* Un utilisateur hors VPN, même s'il connaissait le token, recevra une erreur 500, car n'ayant pas une ip autorisée
+* Une requête issue de la V1 sera autorisée sans authentification
+
+### Ip internes 
+
+Les ips considérées comme interne sont :
+
+- Le localhost (`127.0.0.[0-1]`)
+- Les adresses venant de l'intérieur du VPN (`10.8.0.[0-255]`)
+- Les adresses publiques des serveurs Opentalent (`141.94.117.[33-61]`)
+- Les adresses privées des serveurs Opentalent (`172.16.0.[0-255]`)
+- Les adresses des autres containers docker (`172.20.[0-255].[0-255]`)
+
+> Plus d'infos ici : https://ressources.opentalent.fr/display/SI/Infrastructure+et+reseau
+
+
+### Mise en oeuvre
+
+On met en place un pattern de routes de la forme `/api/internal/*` qui sera uniquement dédié aux requêtes internes entre
+les deux API ou à d'autres éventuels échanges entre systèmes.
+
+Les appels à cette route ne sont autorisés que si :
+
+1. Que l'ip du client dont émet la requête fait partie d'un pool autorisé d'ips internes
+2. Qu'un header 'internal-requests-token' est défini et que sa valeur correspond à la valeur attendue.
+
+Si ces deux conditions ne sont pas remplies, la requête est rejetée, et ce même si l'utilisateur est authentifié.
+
+
+### Valider le fonctionnement
+
+Soit `$id` l'id d'un fichier stocké sur l'environnement V2
+On part du principe que l'utilisateur authentifié a des droits suffisants pour voir ce fichier.
+
+
+Côté ap2i, les requêtes suivantes doivent donner les résultats correspondants :
+
+| query                      | header défini | authentifié | VPN activé | Résultat attendu |
+|----------------------------|---------------|-------------|------------|------------------|
+| /api/internal/download/$id | NON           | NON         | NON        | 401 Unauthorized |
+| /api/internal/download/$id | OUI           | NON         | NON        | 401 Unauthorized |
+| /api/internal/download/$id | OUI           | NON         | OUI        | 200 OK           |
+| /api/internal/download/$id | OUI           | OUI         | OUI        | 200 OK           |
+| /api/internal/download/$id | OUI           | OUI         | NON        | 403 Forbidden    |
+| /api/internal/download/$id | NON           | OUI         | OUI        | 403 Forbidden    |
+| /api/download/$id          | *             | NON         | *          | 401 Unauthorized |
+| /api/download/$id          | *             | OUI         | *          | 200 OK           |
+
+
+Les mêmes tests s'appliquent côté V1, appliqués à un fichier stocké sur l'environnement de la V1.
+

+ 1 - 1
phpunit.xml.dist

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
-  <coverage processUncoveredFiles="true">
+  <coverage includeUncoveredFiles="true">
     <include>
       <directory suffix=".php">src</directory>
     </include>

+ 8 - 4
src/ApiResources/DownloadRequest.php → src/ApiResources/Core/File/DownloadRequest.php

@@ -1,12 +1,11 @@
 <?php
 declare (strict_types=1);
 
-namespace App\ApiResources;
+namespace App\ApiResources\Core\File;
 
-use ApiPlatform\Metadata\Link;
-use ApiPlatform\Metadata\Get;
-use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
 use App\State\Provider\Core\DownloadRequestProvider;
 
 /**
@@ -19,6 +18,11 @@ use App\State\Provider\Core\DownloadRequestProvider;
             requirements: ['fileId' => '\\d+'],
             security: 'is_granted("ROLE_FILE")',
             provider: DownloadRequestProvider::class
+        ),
+        new Get(
+            uriTemplate: '/internal/download/{fileId}',
+            requirements: ['fileId' => '\\d+'],
+            provider: DownloadRequestProvider::class
         )
     ]
 )]

+ 56 - 0
src/Security/Voter/InternalRequestsVoter.php

@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Security\Voter;
+
+use App\Service\Security\InternalRequestsService;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
+/**
+ * @package App\Security
+ */
+class InternalRequestsVoter extends Voter
+{
+    public function __construct(
+        private readonly InternalRequestsService $internalRequestsService,
+        protected RequestStack $requestStack,
+    )
+    {}
+
+    /**
+     * @param string $attribute
+     * @param mixed $subject
+     * @return bool
+     */
+    protected function supports(string $attribute, mixed $subject): bool
+    {
+        return $attribute === 'INTERNAL_REQUEST';
+    }
+
+    /**
+     * @param string $attribute
+     * @param mixed $subject
+     * @param TokenInterface $token
+     * @return bool
+     */
+    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+    {
+        return $this->isValidInternalRequest();
+    }
+
+    /**
+     * Is the current request a valid internal request ?
+     *
+     * @return bool
+     */
+    protected function isValidInternalRequest(): bool {
+        $request = $this->requestStack->getCurrentRequest();
+
+        $clientIp = $request->server->get('REMOTE_ADDR');
+        $internalRequestsToken = $request->headers->get('internal-requests-token')?? null;
+
+        return $internalRequestsToken && $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
+    }
+}

+ 8 - 4
src/Service/ApiLegacy/ApiLegacyRequestService.php

@@ -20,9 +20,10 @@ class ApiLegacyRequestService extends ApiRequestService
 {
     #[Pure]
     public function __construct(
-        HttpClientInterface $apiLegacyClient,
-        private Security $security,
-        private JWTTokenManagerInterface $jwtManager
+        HttpClientInterface                       $apiLegacyClient,
+        private readonly Security                 $security,
+        private readonly JWTTokenManagerInterface $jwtManager,
+        readonly private string                   $internalRequestsToken
     )
     {
         parent::__construct($apiLegacyClient);
@@ -33,7 +34,6 @@ class ApiLegacyRequestService extends ApiRequestService
      * @param string $url
      * @param array<mixed> $parameters
      * @param array<mixed> $options
-     * @noinspection PhpPossiblePolymorphicInvocationInspection
      */
     public function request(
         string $method,
@@ -74,6 +74,10 @@ class ApiLegacyRequestService extends ApiRequestService
         if ($jwt !== null) {
             $headers['authorization'] = 'BEARER ' . $jwt;
         }
+
+        // Add the internal requests token
+        $headers['internal-requests-token'] = $this->internalRequestsToken;
+
         $options['headers'] = array_merge($options['headers'] ?? [], $headers);
 
         return parent::request($method, $url, $parameters, $options);

+ 2 - 5
src/Service/File/FileManager.php

@@ -6,8 +6,7 @@ namespace App\Service\File;
 use ApiPlatform\Api\IriConverterInterface;
 use ApiPlatform\Api\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Get;
-use App\ApiResources\DownloadRequest;
-use App\Entity\Access\Access;
+use App\ApiResources\Core\File\DownloadRequest;
 use App\Entity\Core\File;
 use App\Enum\Core\FileHostEnum;
 use App\Service\File\Exception\FileNotFoundException;
@@ -15,7 +14,6 @@ use App\Service\File\Storage\ApiLegacyStorage;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\LocalStorage;
 use Mimey\MimeTypes;
-use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
 use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
 
 /**
@@ -53,11 +51,10 @@ class FileManager
      * Lit le fichier et retourne son contenu
      *
      * @param File $file
-     * @param TokenInterface|null $token  Used to read files from API v1 with a switch account
      * @return string
      * @throws FileNotFoundException
      */
-    public function read(File $file, ?TokenInterface $token=null): string
+    public function read(File $file): string
     {
         $storage = $this->getStorageFor($file);
         return $storage->read($file);

+ 63 - 0
src/Service/Security/InternalRequestsService.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Service\Security;
+
+/**
+ * Identify and allow internal requests between api v1 and v2
+ *
+ * @see doc/internal_requests.md
+ */
+class InternalRequestsService
+{
+    // Internal ips allowed to access private files without being authenticated
+    const INTERNAL_IPS = [
+        '/^127\.0\.0\.[0-1]$/', // Localhost
+        '/^localhost$/',  // Localhost
+        '/^10\.8\.0\.\d{1,3}$/', // 10.8.0.[0-255] - VPN
+        '/^141\.94\.117\.((3[3-9])|(4\d)|(5\d)|(6[0-1]))$/',   // 141.94.117.[33-61] - Opentalent hosts public ips
+        '/^172\.16\.0.\d{1,3}$/',   // 172.16.0.[0-255] - Opentalent hosts private ips
+        '/^172\.20\.\d{1,3}\.\d{1,3}$/',  // 172.20.[0-255].[0-255] - Docker
+    ];
+
+    public function __construct(
+        readonly private string $internalRequestsToken
+    ) {}
+
+    /**
+     * Returns true if the client Ip is allowed to access restricted content without auth
+     *
+     * @param string $clientIp
+     * @return bool
+     */
+    protected function isInternalIp(string $clientIp): bool
+    {
+        foreach (self::INTERNAL_IPS as $ipRule) {
+            if (preg_match($ipRule, $clientIp)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Compare the given token to the expected one, and return true if they are identical
+     * An empty token can not be valid
+     *
+     * @param string $token
+     * @return bool
+     */
+    protected function tokenIsValid(string $token): bool {
+        return $token && $token === $this->internalRequestsToken;
+    }
+
+    /**
+     * Is the given request a valid internal request, which shall be responded even without authentication
+     *
+     * @param string $ip
+     * @param string $token
+     * @return bool
+     */
+    public function isAllowed(string $ip, string $token): bool {
+        return $this->isInternalIp($ip) && $this->tokenIsValid($token);
+    }
+}

+ 21 - 56
src/State/Provider/Core/DownloadRequestProvider.php

@@ -6,7 +6,7 @@ namespace App\State\Provider\Core;
 use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\ProviderInterface;
-use App\ApiResources\DownloadRequest;
+use App\ApiResources\Core\File\DownloadRequest;
 use App\Enum\Core\FileStatusEnum;
 use App\Repository\Core\FileRepository;
 use App\Service\File\Exception\FileNotFoundException;
@@ -15,60 +15,21 @@ use RuntimeException;
 use Symfony\Component\HttpFoundation\HeaderUtils;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Response;
-use Symfony\Bundle\SecurityBundle\Security;
 
 /**
  * Custom provider pour le téléchargement des fichiers du LocalStorage
  */
 final class DownloadRequestProvider implements ProviderInterface
 {
-    // Internal ips allowed to access private files without being authenticated
-    const INTERNAL_IPS = [
-        '/^127\.0\.0\.[0-1]$/',
-        '/^localhost$/',
-        '/^10\.8\.0\.\d{1,3}$/', // VPN
-        '/^141\.94\.117\.[33-61]$/',   // internal public ips
-        '/^172\.20\.\d{1,3}\.\d{1,3}$/',  // docker
-    ];
-
     public function __construct(
-        private FileRepository $fileRepository,
-        private FileManager $fileManager,
-        private Security $security,
-    )
-    {}
-
-    /**
-     * @param string $resourceClass
-     * @param string|null $operationName
-     * @param mixed[] $context
-     * @return bool
-     */
-    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
-    {
-        return DownloadRequest::class === $resourceClass;
-    }
-
-    /**
-     * Returns true if the client Ip is allowed to access restricted content without auth
-     *
-     * @param string $clientIp
-     * @return bool
-     */
-    private function isInternalIp(string $clientIp): bool
-    {
-        foreach (self::INTERNAL_IPS as $ipRule) {
-            if (preg_match($ipRule, $clientIp)) {
-                return true;
-            }
-        }
-        return false;
-    }
+        private readonly FileRepository $fileRepository,
+        private readonly FileManager    $fileManager,
+    ) {}
 
     /**
      * @param Operation $operation
-     * @param mixed[] $uriVariables
-     * @param mixed[] $context
+     * @param array<mixed> $uriVariables
+     * @param array<mixed> $context
      * @return Response|RedirectResponse
      * @throws FileNotFoundException
      */
@@ -78,22 +39,25 @@ final class DownloadRequestProvider implements ProviderInterface
             throw new RuntimeException('not supported', 500);
         }
 
-        $id = $uriVariables['id'];
-        $file = $this->fileRepository->find($id);
+        return $this->serveFile($uriVariables['fileId']);
+    }
+
+    /**
+     * @param int $fileId
+     * @return Response
+     * @throws FileNotFoundException
+     */
+    protected function serveFile(int $fileId): Response {
+        $file = $this->fileRepository->find($fileId);
+
         if (empty($file)) {
-            throw new RuntimeException("File " . $id . " does not exist; abort.");
+            throw new RuntimeException("File " . $fileId . " does not exist; abort.");
         }
         if ($file->getStatus() !== FileStatusEnum::READY()->getValue()) {
-            throw new RuntimeException("File " . $id . " has " . $file->getStatus() . " status; abort.");
+            throw new RuntimeException("File " . $fileId . " has " . $file->getStatus() . " status; abort.");
         }
 
-        // This is a request from an authorized IP
-        $clientIp = $_SERVER['REMOTE_ADDR'];
-        $internalIp = $this->isInternalIp($clientIp);
-
-        // Read the file
-        $token = $internalIp ? null : $this->security->getToken();
-        $content = $this->fileManager->read($file, $token);
+        $content = $this->fileManager->read($file);
 
         // Build the response and attach the file to it
         // @see https://symfony.com/doc/current/components/http_foundation.html#serving-files
@@ -113,4 +77,5 @@ final class DownloadRequestProvider implements ProviderInterface
 
         return $response;
     }
+
 }

+ 18 - 16
tests/Service/ApiLegacy/ApiLegacyRequestServiceTest.php

@@ -6,6 +6,7 @@ use App\Entity\Access\Access;
 use App\Entity\Person\Person;
 use App\Service\ApiLegacy\ApiLegacyRequestService;
 use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\Security\Core\Authentication\Token\NullToken;
@@ -21,6 +22,8 @@ class ApiLegacyRequestServiceTest extends TestCase
     private Security $security;
     private JWTTokenManagerInterface $jwtManager;
 
+    const internalRequestsToken = 'azerty';
+
     public function setUp(): void
     {
         $this->apiLegacyClient = $this->getMockBuilder(HttpClientInterface::class)->disableOriginalConstructor()->getMock();
@@ -28,16 +31,20 @@ class ApiLegacyRequestServiceTest extends TestCase
         $this->jwtManager = $this->getMockBuilder(JWTTokenManagerInterface::class)->disableOriginalConstructor()->getMock();
     }
 
+    public function getApiLegacyRequestServiceMockFor(string $methodName): ApiLegacyRequestService | MockObject {
+        return $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager, self::internalRequestsToken])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
     /**
      * @see ApiLegacyRequestService::request()
      */
     public function testRequest(): void
     {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
+        $api1RequestService = $this->getApiLegacyRequestServiceMockFor('request');
 
         $person = $this->getMockBuilder(Person::class)->disableOriginalConstructor()->getMock();
 
@@ -60,7 +67,8 @@ class ApiLegacyRequestServiceTest extends TestCase
             'Charset' => 'UTF-8',
             'Accept-Encoding' => 'gzip, deflate, br',
             'Content-Type' => 'application/ld+json',
-            'x-accessid' => '1'
+            'x-accessid' => '1',
+            'internal-requests-token' => self::internalRequestsToken
         ];
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
@@ -84,11 +92,7 @@ class ApiLegacyRequestServiceTest extends TestCase
      */
     public function testRequestSwitchUser(): void
     {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
+        $api1RequestService = $this->getApiLegacyRequestServiceMockFor('request');
 
         $user = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
         $user->method('getId')->willReturn(10);
@@ -120,6 +124,8 @@ class ApiLegacyRequestServiceTest extends TestCase
             'Content-Type' => 'application/ld+json',
             'x-accessid' => '20',
             'x-switch-access' => '10',
+            'internal-requests-token' => self::internalRequestsToken
+
         ];
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
@@ -138,11 +144,7 @@ class ApiLegacyRequestServiceTest extends TestCase
      */
     public function testRequestSwitchInvalidUser(): void
     {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
+        $api1RequestService = $this->getApiLegacyRequestServiceMockFor('request');
 
         $user = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
         $user->method('getId')->willReturn(10);

+ 1 - 1
tests/Service/File/FileManagerTest.php

@@ -5,7 +5,7 @@ namespace App\Tests\Service\File;
 use ApiPlatform\Api\IriConverterInterface;
 use ApiPlatform\Api\UrlGeneratorInterface;
 use ApiPlatform\Metadata\Get;
-use App\ApiResources\DownloadRequest;
+use App\ApiResources\Core\File\DownloadRequest;
 use App\Entity\Core\File;
 use App\Enum\Core\FileHostEnum;
 use App\Service\File\Exception\FileNotFoundException;

+ 93 - 0
tests/Service/Security/InternalRequestsServiceTest.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Tests\Service\Security;
+
+use App\Service\Security\InternalRequestsService;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class TestableInternalRequestsService extends InternalRequestsService {
+    public function isInternalIp(string $ip): bool { return parent::isInternalIp($ip); }
+    public function tokenIsValid(string $token): bool { return parent::tokenIsValid($token); }
+}
+
+class InternalRequestsServiceTest extends TestCase
+{
+    const internalRequestsToken = 'azerty';
+
+    public function setUp(): void {
+    }
+
+    private function getInternalRequestsServiceMockFor(string $methodName, string $token = null): TestableInternalRequestsService | MockObject {
+        return $this->getMockBuilder(TestableInternalRequestsService::class)
+            ->setConstructorArgs([$token ?? self::internalRequestsToken])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    public function testIsInternalIp(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isInternalIp');
+
+        $this->assertTrue($internalRequestsService->isInternalIp('127.0.0.0'));
+        $this->assertTrue($internalRequestsService->isInternalIp('127.0.0.1'));
+
+        $this->assertTrue($internalRequestsService->isInternalIp('10.8.0.1'));
+        $this->assertTrue($internalRequestsService->isInternalIp('10.8.0.255'));
+
+        $this->assertFalse($internalRequestsService->isInternalIp('141.94.117.32'));
+        $this->assertTrue($internalRequestsService->isInternalIp('141.94.117.33'));
+        $this->assertTrue($internalRequestsService->isInternalIp('141.94.117.50'));
+        $this->assertTrue($internalRequestsService->isInternalIp('141.94.117.61'));
+        $this->assertFalse($internalRequestsService->isInternalIp('141.94.117.62'));
+
+        $this->assertTrue($internalRequestsService->isInternalIp('172.20.0.0'));
+        $this->assertTrue($internalRequestsService->isInternalIp('172.20.255.255'));
+    }
+
+    public function testTokenIsValid(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('tokenIsValid');
+
+        $this->assertTrue($internalRequestsService->tokenIsValid(self::internalRequestsToken));
+        $this->assertFalse($internalRequestsService->tokenIsValid('foo'));
+    }
+
+    public function testTokenIsValidWithEmptyToken(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('tokenIsValid', '');
+
+        // A token can not be valid if it is an empty string, even if it's equal to the internal token
+        $this->assertFalse($internalRequestsService->tokenIsValid(''));
+    }
+
+    public function testIsAllowed(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isAllowed');
+
+        $internalRequestsService->expects(self::once())->method('isInternalIp')->with('128.0.0.1')->willReturn(True);
+        $internalRequestsService->expects(self::once())->method('tokenIsValid')->with('azerty')->willReturn(True);
+
+        $result = $internalRequestsService->isAllowed('128.0.0.1', 'azerty');
+
+        $this->assertTrue($result);
+    }
+
+    public function testIsAllowedInvalidIp(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isAllowed');
+
+        $internalRequestsService->expects(self::once())->method('isInternalIp')->with('128.0.0.1')->willReturn(False);
+        $internalRequestsService->expects(self::never())->method('tokenIsValid');
+
+        $result = $internalRequestsService->isAllowed('128.0.0.1', 'azerty');
+
+        $this->assertFalse($result);
+    }
+
+    public function testIsAllowedInvalidToken(): void {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isAllowed');
+
+        $internalRequestsService->expects(self::once())->method('isInternalIp')->with('128.0.0.1')->willReturn(True);
+        $internalRequestsService->expects(self::once())->method('tokenIsValid')->with('azerty')->willReturn(False);
+
+        $result = $internalRequestsService->isAllowed('128.0.0.1', 'azerty');
+
+        $this->assertFalse($result);
+    }
+}