Comment restreindre l'accès à votre site web à une région avec Symfony 5
Certains gestionnaires d'applications choisissent de refuser l'accès aux utilisateurs en fonction de leur localisation. C'est ce qu'on appelle le géo-blocage
Introduction et contexte
Certains gestionnaires d'applications choisissent de refuser l'accès aux utilisateurs en fonction de leur localisation. C'est ce qu'on appelle le géo-blocage, qui peut être utilisé par exemple sur certains sites de vente en ligne qui peuvent choisir de ne pas recevoir de visiteurs provenant de pays où ils n'expédient pas de marchandises ou en raison de la législation en vigueur dans ce pays. Si vous êtes prêt à mettre en œuvre cette fonctionnalité dans votre projet Symfony 5, vous avez trouvé le bon endroit.
Géolocalisation d'un visiteur
La première étape est de géo-localiser le visiteur pour cela plusieurs solutions s'offrent à nous parmi lesquelles :
- La géolocalisation grâce à l'adresse IP
- Géo-localisation grâce au navigateur(GPS)
Dans ce tutoriel nous allons explorer la première méthode, il faut savoir qu'une adresse IP est un numéro d'identification qui est attribué de manière permanente ou temporaire à chaque appareil connecté à un réseau informatique qui utilise le protocole Internet. L'adresse IP est la base du système d'acheminement des paquets de données sur Internet. Chaque fois que vous vous connectez à l'Internet, vous obtenez une adresse IP.
En général, les fournisseurs d'accès à Internet (FAI) disposent d'une certaine gamme d'adresses IP avec lesquelles ils travaillent, et ils peuvent déléguer différentes adresses à différents utilisateurs lors de leur connexion.
La géolocalisation basée sur l'IP fonctionne en vérifiant simplement quelle plage d'adresses IP est utilisée par quelle zone, en utilisant une base de données de localisation basée sur l'IP. Ainsi, vous pouvez obtenir des informations telles que le pays et la ville d'où proviennent vos utilisateurs, simplement en connaissant leur adresse IP. Cependant, la géolocalisation basée sur l'IP ne peut pas être précise à 100%, car elle est basée sur une adresse qui est généralement mélangée par quelques utilisateurs dans une certaine zone. Pour notre cas d'utilisation, c'est plus que suffisant, car nous avons seulement besoin de connaître le pays de l'utilisateur.
Obtenir la base de données de correspondance IP - Région
Tout d'abord, nous aurons besoin de la base de données binaire de GeoLite dans votre projet et la rendre accessible au niveau du système. Vous pouvez télécharger la base de données gratuitement à partir du site officiel MaxMind GeoLite2, vous devez vous enregistrer pour un compte GeoLite2, après avoir créé le compte et suivi les étapes que vous avez reçu par e-mail, vous serez en mesure de télécharger la base de données MaxMind en privé, dans ce cas, nous allons utiliser la version GeoLite2 Country.
Nous allons utiliser et inclure la base de données de notre propre projet dans le répertoire /data de notre projet symfony (notez que ce répertoire n'existe pas et doit être créé, vous pouvez changer le chemin de la base de données selon vos besoins) et nous allons utiliser la version GeoLite2 Country de la base de données qui nous permet d'obtenir le pays IP du visiteur. Les bases de données sont compressées en utilisant tar, donc vous pouvez extraire le contenu depuis la ligne de commande en utilisant la commande suivante :
$ tar -xzf GeoLite2-Country_20200121.tar.gz
La base de données a un format spécial créé par les créateurs de GeoIP, à savoir MaxMind DB. Le format de fichier MaxMind DB est un format de base de données qui fait correspondre les adresses IPv4 et IPv6 aux enregistrements de données en utilisant un arbre de recherche binaire efficace. Pour plus d'informations sur le type de base de données utilisé par ce projet, veuillez lire plus sur MaxMind DB.
Nous avons maintenant besoin d'un lecteur pour ce format de base de données. Heureusement, l'équipe de MaxMind a écrit une excellente bibliothèque pour PHP qui rend l'interaction avec la base de données assez facile et vous serez en mesure de récupérer des informations géographiques sur l'IP d'un utilisateur avec seulement quelques lignes de programmation. Elle s'appelle MaxMind GeoIP PHP Api, un package qui fournit une API pour les services web et les bases de données GeoIP2. L'API fonctionne également avec les bases de données gratuites GeoLite2 (celle que nous utilisons). Vous pouvez installer ce paquet dans votre projet Symfony avec compose en exécutant la commande suivante :
$ compose require geoip2/geoip2
Mettre en place un middle-ware
Sur chaque application Symfony, beaucoup de choses se passent sous le capot, nous avons donc besoin de savoir quand ces choses se produisent ; Symfony vous permet de savoir quand cela se produit par le biais d'événements, il déclenche plusieurs événements liés au Kernel pendant le traitement de la requête HTTP. C'est exactement là que nous allons identifier le pays de l'utilisateur et déterminer s'il doit y avoir accès ou non.
La logique sera la suivante, la classe DisallowCountryRequestListener dans le répertoire EventListener de votre projet va "écouter" toutes les requêtes et un DisallowCountryRepository va stocker en base de données les pays qui n'ont pas le droit d'accès (vous pouvez aussi utiliser un tableau contenant la liste des pays, nous utilisons ici un repository pour faciliter la gestion des accès sur le long terme)
Sauvegarder la liste des pays pour en restreindre l'accès
Nous allons créer une table qui contiendra tous les pays, cette table aura un champ iso2 et has_access un booléen déterminant si les utilisateurs du pays ont le droit d'accès ou non. Pour cela nous allons créer une entité DisallowCountry et un fixture DisallowCountryFixtures.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\DisallowCountryRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=DisallowCountryRepository::class)
* @ORM\Cache("READ_ONLY")
*/
class DisallowCountry
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\Column(type="string", length=255)
*/
private ?string $name = null;
/**
* @ORM\Column(type="string", length=3)
*/
private ?string $iso2 = null;
/**
* @ORM\Column(type="boolean")
*/
private ?bool $has_access = true;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getIso2(): ?string
{
return $this->iso2;
}
public function setIso2(string $iso2): self
{
$this->iso2 = $iso2;
return $this;
}
public function getHasAccess(): ?bool
{
return $this->has_access;
}
public function setHasAccess(bool $has_access): self
{
$this->has_access = $has_access;
return $this;
}
}
Dans le repository la méthode getDisallowedCountries nous permettra d'obtenir la liste des pays sans droit d'accès, cette liste est un simple tableau contenant le code ISO2 des pays
['CD', 'CA', 'US', 'FR'] ;
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\DisallowCountry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
use Psr\Log\LoggerInterface;
/**
* @method DisallowCountry|null find($id, $lockMode = null, $lockVersion = null)
* @method DisallowCountry|null findOneBy(array $criteria, array $orderBy = null)
* @method DisallowCountry[] findAll()
* @method DisallowCountry[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class DisallowCountryRepository extends ServiceEntityRepository
{
private LoggerInterface $logger;
/**
* DisallowCountryRepository constructor.
* @param ManagerRegistry $registry
* @param LoggerInterface $logger
* @author bernard-ng <bernard@devscast.tech>
*/
public function __construct(ManagerRegistry $registry, LoggerInterface $logger)
{
parent::__construct($registry, DisallowCountry::class);
$this->logger = $logger;
}
/**
* @return array
* @author bernard-ng <bernard@devscast.tech>
*/
public function getDisallowedCountries(): array
{
$sql = <<< SQL
SELECT iso2 FROM disallow_country
WHERE has_access = '0'
ORDER BY name
SQL;
try {
$connexion = $this->_em->getConnection();
$statement = $connexion->prepare($sql);
$statement->execute();
// important nous avons d'un tableau contenant unique la list de pays
// ['CD', 'CA', 'FR', 'US'] par exemple
return $statement->fetchFirstColumn();
} catch (Exception | \Doctrine\DBAL\Driver\Exception $e) {
$this->logger->error($e->getMessage(), $e->getTrace());
return [];
}
}
}
php bin/console make:fixtures DisallowCountryFixutres
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Domain\Application\Entity\DisallowCountry;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Intl\Countries;
/**
* Class DisallowCountryFixtures
* @package App\DataFixtures
* @author bernard-ng <bernard@devscast.tech>
*/
class DisallowCountryFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$countries = Countries::getNames();
foreach ($countries as $iso => $name) {
$data = (new DisallowCountry())
->setName($name)
->setIso2($iso)
->setHasAccess(true);
$manager->persist($data);
}
$manager->flush();
}
}
Nous utilisons ici Symfony\Component\Intl\Countries pour obtenir la liste des pays et le code ISO2, maintenant que nous avons la liste des pays, nous allons contrôler l'accès pour chaque requête pour ce faire nous aurons besoin d'un DisallowCountryRequestListener.
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Repository\DisallowCountryRepository;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use MaxMind\Db\Reader\InvalidDatabaseException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Intl\Countries;
use Twig\Environment;
/**
* Class DisallowedCountryRequestListener
* @package App\EventListener
* @author bernard-ng <bernard@devscast.tech>
*/
class DisallowedCountryRequestListener
{
private Environment $twig;
private KernelInterface $kernel;
private LoggerInterface $logger;
private DisallowCountryRepository $repository;
public function __construct(
Environment $twig,
KernelInterface $kernel,
LoggerInterface $logger,
DisallowCountryRepository $repository
)
{
$this->twig = $twig;
$this->kernel = $kernel;
$this->logger = $logger;
$this->repository = $repository;
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMasterRequest()) {
return;
}
$this->restrictAccessOnDisallowedCountries($event);
}
private function restrictAccessOnDisallowedCountries(RequestEvent $event)
{
try {
$request = $event->getRequest();
$reader = new Reader(
$this->kernel->getProjectDir() . "/data/geoip_country.mmdb",
array_unique([$request->getLocale(), 'fr'])
);
$ip = $request->getClientIp();
$record = $reader->country($ip === '127.0.0.1' ? $_ENV['APP_LOCALHOST_IP'] : $ip);
$isoCode = $record->country->isoCode;
if (in_array($isoCode, $this->repository->getDisallowedCountries())) {
$response = new Response();
$response->setStatusCode(Response::HTTP_FORBIDDEN);
$response->setContent($this->twig->render("@common/application/disallowed_country.html.twig", [
'country' => Countries::getName($isoCode),
'iso' => $isoCode
]));
$response->headers->set('Content-Type', 'text/html');
$event->setResponse($response);
}
} catch (AddressNotFoundException | InvalidDatabaseException $e) {
$this->logger->error($e->getMessage(), $e->getTrace());
}
}
}
C'est ici que toute la magie opère, nous aurons besoin de Twig pour afficher une page d'erreur, du Kernel pour récupérer le chemin vers la racine du projet et enfin du repository pour récupérer la liste des pays, dans la méthode restrictAccessOnDisallowedCountries de notre listener nous commençons par récupérer l'adresse IP du visiteur
$request = $event->getRequest() ;
$reader = new Reader(
$this->kernel->getProjectDir() . "/data/geoip_country.mmdb",
array_unique([$request->getLocale(), 'fr'])
) ;
$ip = $request->getClientIp() ;
$record = $reader->country($ip === '127.0.0.1' ?$_ENV['APP_LOCALHOST_IP'] : $ip) ;
$isoCode = $record->country->isoCode ;
Notez que lorsque notre application tourne en local, nous avons par défaut une adresse IP 127.0.0.1 pour faciliter les tests vous pouvez choisir de remplacer cette adresse par une adresse d'un autre pays (facile à trouver sur internet), maintenant que nous avons l'adresse IP du visiteur nous allons récupérer le code ISO2 du pays dans lequel il se trouve afin de vérifier s'il a le droit d'accès
if (in_array($isoCode, $this->repository->getDisallowedCountries())) {
$response = new Response() ;
$response->setStatusCode(Response::HTTP_FORBIDDEN) ;
$response->setContent(
$this>twig>render("disallowed_country.html.twig", [
'country' => Countries::getName($isoCode),
'iso' => $isoCode
])
) ;
$response->headers->set('Content-Type', 'text/html') ;
$event->setResponse($response) ;
}
S'il a accès, sa requête est exécutée, sinon il renvoie une page d'erreur avec le statut 403 Forbidden et voilà !
Note : Si le visiteur utilise un VPN il peut facilement contourner notre vérification, si cette vérification est très importante pour le bon fonctionnement de votre application il serait préférable de combiner cette méthode avec la géo-localisation avec le navigateur (deuxième solution dans cet article)