Le blog

UrlSignerBundle, créer et valider des URLs signées avec Symfony

Une URL signée est une URL contenant des informations d'authentification directement dans sa query string. Elle permet à un utilisateur non authentifié d'accéder à des ressources ou à des routes protégées, pour une durée limitée.

Signer une URL peut être très utile dans une application stateless. Il existe deux cas d'utilisation principaux :

  • permettre à un utilisateur externe d'accéder à une ressource autrement inaccessible sans être authentifié,
  • télécharger un fichier avec le navigateur sans utiliser Ajax (sans un fetch() envoyant les informations d'authentification).

Ce dernier cas est le plus courant. Prenons un exemple : nous avons créé un cloud personnel où les utilisateurs peuvent stocker leurs fichiers. Cette application est construite avec une SPA pour le front et communique avec une API sécurisée en utilisant un jeton JWT. Jean a ajouté une grosse vidéo montrant une session de codage en direct d'une fonctionnalité intéressante d'API Platform, et il veut la télécharger pour la regarder à nouveau.

Le premier réflexe pour implémenter cette fonctionnalité de téléchargement dans la SPA serait de faire quelque chose comme ça :

const res = await fetch('videos/d35e0296-7b19-42be-82f2-789213229f36/download', {
    headers: {
        Authorization: `Bearer ${localStorage.getItem('token')}`,
        Accept: 'application/octet-stream',
    },
});
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objUrl;
link.download = true;
link.click();

Cette méthode présente de nombreux inconvénients :

  • L'interface de téléchargement du navigateur n'est utilisée qu'après le téléchargement du fichier. Nous devons montrer à Jean qu'un téléchargement est en cours, par exemple en ajoutant un spinner quelque part. Jean ne peut pas mettre en pause ou annuler le téléchargement.
  • Le fichier téléchargé est stocké en mémoire jusqu'à ce qu'il soit complet, ce qui peut entraîner des problèmes de performance si le fichier est trop volumineux.

Pour résoudre ce problème, il serait possible d'utiliser le jeton JWT dans la query string pour télécharger la vidéo. Oui, c’est possible, mais c’est une mauvaise idée :

  • Un jeton JWT peut être gros. Assez gros pour dépasser la limite de longueur de l'URL.
  • Vous ne voulez pas que les jetons JWT apparaissent dans les logs de votre serveur. Il s'agit d'une faille de sécurité dans votre application qui faciliterait une attaque de type session hijacking.

C'est pourquoi, dans ce cas, il est préférable d'utiliser une URL signée, comme ceci :

const res = await fetch('videos/d35e0296-7b19-42be-82f2-789213229f36/signed-url', {
    headers: {
        Authorization: `Bearer ${localStorage.getItem('token')}`,
    },
});
const {url} = await res.json();
const link = document.createElement('a');
link.href = url;
link.download = true;
link.click();

C'est beaucoup mieux : fetch() est uniquement utilisée pour obtenir l'URL signée avec de l’authentification, URL signée qui est ensuite utilisée directement pour télécharger le fichier. Et bien sûr, vous pouvez aussi utiliser cette génération d'URL signée pour ajouter une fonction de partage, par exemple si Jean veut que ses ami·e·s téléchargent la vidéo sans être authentifié·e·s.

Laravel inclut la génération d'URLs signées depuis sa version 5.6, mais il n'y avait pas de moyen facile de le faire dans Symfony.

Avec le UrlSignerBundle, c'est maintenant corrigé !

Alors, comment l'utiliser ?

Utilisation

Première étape, installer le bundle. En utilisant Symfony Flex, c'est très simple :

composer req tilleuls/url-signer-bundle

Grâce à Flex, toute la configuration a été faite (voir le fichier config/packages/url_signer.yaml).

Puis changez la signature dans le fichier .env (ou .env.local) :

SIGNATURE_KEY=XAR5vxusewjY9KJ2w8apxVzVq5EPtl

Tout est prêt pour commencer à utiliser le bundle et pour créer votre URL signée !

Par exemple dans un contrôleur, nous allons injecter le service UrlSignerInterface puis l’utiliser pour créer une URL signée qui sera retournée dans la réponse JSON de la route /videos/{id}/signed-url :

<?php
declare(strict_types=1);

namespace AppController;

use CoopTilleulsUrlSignerBundleUrlSignerUrlSignerInterface;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAnnotationRoute;

class VideoController extends AbstractController
{
    public function __construct(
        private UrlSignerInterface $urlSigner,
    )
    {
    }

    #[Route('/videos/{id}/signed-url', methods: ['GET'])]
    public function newSignedUrl(string $id): Response
    {
        $this->denyAccessUnlessGranted('ROLE_USER');
        return new JsonResponse(['url' => $this->generateSignedUrl($id)]);
    }

    private function generateSignedUrl(string $id): string
    {
        $url = $this->generateUrl('download_video', ['id' => $id]);
        // Expirera après 10 secondes.
        $expiration = (new DateTime('now'))->add(new DateInterval('PT10S'));
        return $this->urlSigner->sign($url, $expiration);
    }
}

Nous pouvons maintenant récupérer une URL signée à partir de la route /videos/{id}/signed-url. L'URL signée ressemblera à ceci :

/videos/d35e0296-7b19-42be-82f2-789213229f36/download?expires=1611316656&signature=82f6958bd5c96fda58b7a55ade7f651fadb51e12171d58ed271e744bcc7c85c3

Comme vous pouvez le constater, l’URL signée possède deux paramètres :

  • expires qui contient le timestamp de la date d’expiration : l’URL n’est plus valide passée cette date.
  • signature qui est en fait le hash du path, du paramètre d’expiration et du secret.

La dernière étape consiste à la valider lors de l'accès à la route correspondant à l'URL. Nous réutilisons notre VideoController :

<?php
declare(strict_types=1);

namespace AppController;

use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationBinaryFileResponse;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAnnotationRoute;

class VideoController extends AbstractController
{
    #[Route('/videos/{id}/download', name: 'download_video', defaults: ['_signed' => true], methods: ['GET'])]
    public function download(string $id): Response
    {
        // Récupération du fichier.        return new BinaryFileResponse();
    }
}

Comme vous pouvez le voir, nous venons d'ajouter un paramètre extra _signed à la route. C’est ce paramètre qui active la validation de la signature lorsqu’une requête arrive sur à la route.

Et c'est tout ! Si vous accédez à la route de téléchargement sans signature ou avec une signature invalide, vous obtiendrez une réponse 403.

Si vous voulez utiliser des URLs signées dans API Platform, comment faire ? Et bien c'est encore plus facile !

Voici à quoi cela ressemblera :

<?php
declare(strict_types=1);

namespace AppEntity;

use ApiPlatformCoreAnnotationApiResource;
use AppControllerCreateSignedUrlVideoAction;
use AppControllerDownloadVideoAction;

#[ApiResource(
    itemOperations: [
    'get',
    'delete',
    'put',
    'signed_url' => [
        'security' => "is_granted('ROLE_USER')",
        'method' => 'GET',
        'path' => '/videos/{id}/signed-url',
        'controller' => CreateSignedUrlVideoAction::class,
    ],
    'download' => [
        'method' => 'GET',
        'path' => '/videos/{id}/download',
        'controller' => DownloadVideoAction::class,
        'defaults' => ['_signed' => true],
        'formats' => ['binary'],
    ],
],
)]
class Video
{
    // Propriétés, getters, setters
}

L’opération signed_url permet de créer l’URL signée (et possède de l’authentification). L’opération download permet de télécharger la vidéo en se servant de l’URL signée, avec le paramètre extra _signed qui permet d’activer la validation de l’URL signée. Les contrôleurs correspondants sont très ressemblants au contrôleur VideoController précédent.

Fonctionnalités

Il y a bien sûr d'autres fonctionnalités dans le bundle :

  • L'algorithme de signature de l'URL peut être modifié. Par défaut, il s'agit de SHA-256.
  • Vous pouvez créer facilement votre propre Signer : étendez la classe AbstractUrlSigner, et vous avez terminé (vous pouvez l’utiliser en modifiant la configuration dans le fichier config/packages/url_signer.yaml) !
  • Le délai avant expiration est personnalisable (par défaut fixé à 24h).
  • Les noms des paramètres dans l'URL signée sont personnalisables.
Comment ça fonctionne ?

En interne le bundle utilise la bibliothèque spatie/url-signer.

Chaque Signer correspond à un algorithme et étend une classe abstraite (AbstractUrlSigner) basée sur le signer de Spatie. Dans un compiler pass dédié, le signer configuré est automatiquement aliasé par UrlSignerInterface.

Pour la validation, la méthode est très simple : un event listener est utilisé. Lors de chaque requête, il vérifie si la route a le paramètre _signed. Si c'est le cas, la signature est validée en utilisant le signer.

Le bundle est très bien testé : il dispose de tests unitaires avec PHPUnit et Prophecy et de tests fonctionnels avec Behat et la SymfonyExtension. Les tests couvrent 100% du code, et mieux encore, le score de mutation, calculé avec le framework de test de mutation Infection est également de 100%.

Enfin, la couverture des types est également de 100 %, calculée par Psalm (PHPStan est également utilisé).

Si vous avez besoin d'URLs signées dans un projet Symfony ou API Platform, ce bundle est fait pour vous ! N'hésitez pas à le tester, à nous dire ce que vous en pensez, et pourquoi pas à contribuer à son développement sur GitHub : https://github.com/coopTilleuls/UrlSignerBundle.

Alan Poulain

Lead developer et consultant

Sécurité, mot de passe, phrase de passe, passphrase,, Symfony