Authentifier l'accès à vos ressources avec Dragonfly

Publié le 11 mai 2017 par Nicolas Cavigneaux | back

Cet article est publié sous licence CC BY-NC-SA

Rappel

Pour ceux qui ne connaissent pas Dragonfly, c’est une application Rack qui peut être utilisée seule ou via un middleware.

Le fait que ce soit une application Rack la rend compatible avec toutes les applications écrites en Ruby et utilisant Rack comme point d’entrée pour traiter les requêtes HTTP. Vous pourrez donc vous en servir dans Sinatra, Rails ou encore Hanami.

Pour faciliter la tâche, cette gem est livrée avec tout le nécessaire pour une intégration simple et complète dans Rails et ActiveRecord.

Dragonfly a pour but de faciliter la gestion des uploads de fichiers et leur manipulation. Vous n’aurez donc pas à gérer un objet Rack::Multipart::UploadedFile pour enregistrer son contenu sur le disque du serveur ou dans le cloud. C’est pratique, mais pas vraiment innovant.

Là où Dragonfly est différenciant c’est qu’il permet de traiter systématiquement tous les fichiers après upload ou à la demande pour, par exemple, générer des versions alternatives des images (changement de résolution, de format, passage en dégradé de gris, …). Vous pouvez de la même manière traiter tout type de fichier (vidéos, PDF, …).

Si vous ne l’avez jamais testé, vous manquez sans aucun doute quelque chose.

Fonctionnement

Dragonfly va donc automatiser la gestion des uploads. Vous n’aurez qu’à ajouter un champ de type file (file_field dans Rails) et déclarer l’attribut concerné comme étant géré par Dragonfly dans votre modèle.

Dès lors vous n’avez plus rien à faire, à chaque upload, une entrée est créée en base, ce qui permet de persister des informations sur le fichier.

Pour réutiliser ces fichiers, les afficher ou permettre leur téléchargement, Dragonfly met à disposition un certain nombre d’helpers pour faciliter la génération des URLs vers les fichiers.

En effet, pour plusieurs raisons, Dragonfly adopte un chemin et nom de fichier particulier pour accéder aux fichiers. Ça permet plusieurs choses :

  • abstraire la méthode d’accès au fichier (l’URL sera la même que le fichier soit stocké localement, sur S3 ou autrement)
  • gérer le cache navigateur pour éviter les appels réseau inutiles
  • passer dans le middleware de Dragonfly pour pouvoir manipuler la requête et le fichier avant de le servir

Dans le cas de Rails, deux helpers sont mis à votre disposition :

  • remote_url pour obtenir une URL d’accès publique qui correspond au lien direct vers le système de stockage “http://host/system/…” ou le lien direct vers le stockage S3 par exemple
  • url qui permet d’obtenir une URL privée qui passe par l’application et ne dévoile pas l’URL d’accès direct au fichier sur le système de stockage. Avec la configuration par défaut, quelque chose du type “/media/…”

L’accès via l’URL publique outrepasse complètement la stack de l’application, ça veut donc dire que pour récupérer le fichier, l’application n’est absolument pas sollicitée. C’est le proxy (Nginx par exemple) ou le service dans le cloud (S3) qui va servir directement la ressource. C’est plus rapide, moins gourmand mais nous n’avons aucun contrôle sur la récupération de la ressource. C’est donc le moyen de récupération idéal pour des ressources destinées à être publiques et cachées par les navigateurs.

L’accès via l’URL privée passe lui au contraire par l’application. Pour être plus précis, la demande va passer par le middleware Dragonfly qui va alors se charger d’aller retrouver l’enregistrement en base qui concerne le fichier demandé grâce à son identifiant unique passé dans l’URL. Cette solution est moins performante puisque le fichier n’est pas servi directement, on passe par une sous-partie de l’application Rails. Elle a néanmoins l’avantage de laisser beaucoup plus de liberté quant à la gestion de ce fichier, de son accès et d’éventuels traitements à lui appliquer. C’est grâce à ça que Dragonfly permet notamment d’accéder à des versions de l’image retravaillées (thumb, grayscale, …) qui sont générées à la volée lors de la requête puis cachées au niveau HTTP.

Il faut donc connaître cette différence et utiliser la méthode de livraison du fichier la plus adaptée en fonction du besoin :

  • pour accéder au fichier original qui est à vocation publique, on utilisera remote_url qui est l’accès le plus direct au fichier et ne nécessite absolument aucun traitement
  • pour accéder à un fichier à retravailler ou dont l’accès doit être contrôlé, on utilisera url qui passe dans l’application et permet donc d’intervenir dans le cycle de récupération et d’envoi du fichier

Problématique d’authentification

Les applications ayant une partie privée dans laquelle les utilisateurs peuvent gérer des uploads ont souvent pour but de ne permettre l’accès à ces fichiers qu’à un nombre restreint d’utilisateurs (en fonction des droits, du rôle, de l’uploader, de la date, …).

On va donc naturellement, pour un utilisateur donné, n’afficher que les liens vers les fichiers auxquels il a accès. Aucune raison pour l’utilisateur du groupe A d’accéder aux fichiers réservés au groupe B.

Est-ce réellement suffisant pour garantir la confidentialité des documents ? Non…

Par défaut aucun fichier géré par Dragonfly n’est authentifié d’aucune sorte que ce soit. Même en passant par la méthode url, et donc la stack, le fichier est disponible à tous. Certains me diront « Oui mais j’ai la gem Devise ! » et bien peu importe.

Si un utilisateur du groupe B donne un lien d’un fichier à un utilisateur du groupe A ou même à quelqu’un d’étranger à l’application, alors ces personnes seront en mesure de consulter ledit fichier. Tout ce qui se passe quand on clique sur ce lien c’est qu’on entre dans l’application, on passe par le middleware Dragonfly qui ne fait que retrouver le fichier correspondant et le servir, vos filtres et autres vérifications internes mises en place dans les contrôleurs de votre application ne sont pas appelés, la requête n’arrive pas jusque là puisqu’une réponse est servie bien avant par le middleware.

Dans bien des cas ce n’est pas souhaitable, l’accès doit être restreint.

La solution évidente mais inélégante

On pourrait se dire qu’il est facile de répondre à cette problématique en n’utilisant jamais les liens Dragonfly directement. On pourrait créer une route dédiée à l’authentification et l’envoi des fichiers.

On aurait donc une action qui vérifierait si l’utilisateur courant a accès au fichier. Effectivement ça fonctionne mais cette solution a plusieurs inconvénients :

  • mettre en place et maintenir un helper pour créer le lien et pouvant garantir la richesse fonctionnelle des helpers de base de Dragonfly ainsi que la robustesse vis à vis de la sécurité
  • mettre en place et maintenir une action contrôleur qui va se charger de vérifier si l’utilisateur est autorisé, récupérer le fichier demandé puis gérer l’envoie avec un send_file
  • on va passer dans toute la stack Rails pour servir les fichiers. Ce sera donc plus long et plus gourmand
  • l’éventuel cache devra être géré à la main

C’est mieux que de ne pas gérer l’authentification du tout, on aura mis en place notre mesure de sécurité mais techniquement ce n’est franchement pas idéal.

La solution méconnue mais performante et flexible

La bonne solution selon moi est de venir s’intercaler directement dans le middleware Dragonfly pour ajouter notre logique d’authentification.

Cette solution n’a que des avantages puisqu’elle évite d’avoir à passer dans toute la stack et ne nécessite pas la mise en place de routes, contrôleurs ou actions dédiés.

On reste sur le système “natif” mis en place par Dragonfly qu’on ne fait qu’enrichir de notre logique.

On pourra continuer à utiliser Dragonfly classiquement avec ses différents helpers de manière tout à fait transparente.

Mise en œuvre

Quand vous mettez en place Dragonfly dans votre application Rails, un fichier de configuration est ajouté dans les initializers. Ce fichier a pour but de définir la façon dont vous souhaitez stocker vos ressources, les plugins à activer, le format de l’URL, etc.

Cette configuration se fait dans le bloc configure de l’application Rack Dragonfly. Pour notre plus grand bonheur, les développeurs de Dragonfly ont eu la bonne idée de mettre à disposition un callback before_serve qui nous permet d’intervenir dans le processus juste avant la livraison de la ressource.

C’est parfait pour ce que nous souhaitons faire, on va pouvoir faire des vérifications, modifier des en-têtes, etc.

Par défaut le fichier de configuration doit ressembler à quelque chose comme :

config/initializers/dragonfly.rb :

require 'dragonfly'

Dragonfly.app.configure do
  plugin :imagemagick

  protect_from_dos_attacks true
  secret "3d6f1d7f46c13d9b27ae3379b99b46ea3e7f0e2c89dfe54c8509ad3f3b2554e2"

  url_format "/media/:job/:name"
end

# Logger
Dragonfly.logger = Rails.logger

# Mount as middleware
Rails.application.middleware.use Dragonfly::Middleware

# Add model functionality
if defined?(ActiveRecord::Base)
  ActiveRecord::Base.extend Dragonfly::Model
  ActiveRecord::Base.extend Dragonfly::Model::Validations
end

On a donc le plugin imagemagick qui est activé. La protection contre les attaques DOS est également active. Une clé secrète est définie pour permettre de générer le SHA de protection lors des requêtes d’accès aux fichiers.

Finalement, le format de l’URL vers les ressources est spécifié.

Tout ce qui est en dehors du bloc configure sert simplement à bien intégrer Dragonfly dans Rails en utilisant son logger, en montant le middleware (celui dans lequel nous allons venir ajouter des choses), puis en étendant les fonctionnalités d’ActiveRecord pour que les modèles aient un accès facilité aux ressources Dragonfly.

Pour notre exemple, supposons que notre application utilise Devise pour authentifier nos utilisateurs. On a donc à notre disposition une méthode current_user qui nous retourne l’utilisateur courant.

Disons également que nous utilisons un système d’autorisation qui nous permet d’affecter des droits spécifiques à chaque utilisateur en fonction de son rôle ou son groupe par exemple. Cette gestion d’autorisation est mise en place grâce à CanCanCan.

On a donc plusieurs cas de figure qui deviennent possibles lorsqu’on veut accéder au fichier :

  • le fichier n’est pas soumis à authentification
  • le fichier est soumis à authentification et accessible à la personne
  • le fichier est soumis à authentification mais n’est pas accessible à la personne

Comment mettre ces vérifications en place ?

Nous allons simplement profiter du callback before_serve pour faire nos vérifications et envoyer la réponse adéquate.

Ce callback est à ajouter dans le bloc configure :

require 'dragonfly'

# Configure
Dragonfly.app.configure do
  # Code de base vu plus tôt supprimé pour la concision

  # Authentification et autorisation de la ressource demandée
  before_serve do |job, env|
    if job.fetch_step
      user = env['warden'].user :user
      file = Upload.where(file_uid: job.fetch_step.uid).first

      if file
        ability = Ability.new(user)

        unless file.public? || ability.can?(:read, file.group)
          throw :halt, [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
        end
      else
        throw :halt, [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
      end
    end
  end
end

Avant de commencer l’explication, comme vous pouvez le voir le code est très court et c’est tout ce dont nous avons besoin pour gérer tous nos cas de figure.

Avant de servir le fichier, on va donc récupérer l’utilisateur courant s’il existe :

user = env['warden'].user :user

Pour ceux qui ne connaissent pas le fonctionnement interne de Devise, il faut savoir qu’il se base sur Warden qui est un framework d’authentification basé sur Rack. Lorsque vous vous identifiez dans une application utilisant Devise, ce dernier va déléguer un certain nombre de choses à Warden et pour se faire il va renseigner l’attribut user de Warden avec l’objet User (par défaut) courant.

N’oubliez pas qu’on est au niveau de Rack, on n’a donc pas accès aux méthodes définies dans les contrôleurs Rails et donc impossible de simplement utiliser current_user. Heureusement que Devise s’appuie sur Rack et y passe l’objet représentant l’utilisateur courant ! Si ce n’était pas le cas, il aurait fallu le gérer nous même à l’identification et passer l’information à Rack par nous-mêmes.

Ensuite nous allons récupérer l’enregistrement correspondant au fichier ayant à l’UID passé à la requête :

file = Upload.where(file_uid: job.fetch_step.uid).first

Rien d’inconnu pour vous. Cet objet nous est utile pour deux raisons. On veut autoriser son accès sur la base de ses attributs, notamment son groupe d’appartenance. Au passage on va en profiter pour vérifier que ce fichier existe toujours en base. Il pourrait en effet toujours exister sur le disque mais plus en base, en connaissant son UID on pourrait encore y accéder… Dragonfly ne vérifie rien en base, il essaie simplement de trouver le fichier sur le disque.

On vérifie donc que l’enregistrement est bien existant puis on procède aux vérifications d’accès :

ability = Ability.new(user)

Avant toute chose on crée une instance de notre classe Ability en lui passant notre utilisateur courant. On peut grâce à ça vérifier les autorisations accordées à notre utilisateur. Cette ligne est spécifique à l’utilisation de CanCanCan mais pourrait être adaptée à n’importe quel système d’autorisation.

On passe à la portion de code suivante :

unless file.public? || ability.can?(:read, file.group)
  throw :halt, [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
end

Pour coller à notre logique métier on vérifie deux choses pour autoriser ou refuser l’accès au fichier. Premièrement, si le fichier est marqué comme étant public alors on laisse Dragonfly servir ce dernier.

S’il n’est pas public on continue notre vérification en cherchant une correspondance dans les droits de l’utilisateur pour le groupe associé au fichier qui est demandé.

Si aucune des deux conditions n’est remplie alors l’accès au fichier est refusé, on doit donc le faire savoir. Pour faire savoir à Rack qu’on veux annuler la requête, il suffit d’envoyer le tag :halt en passant par la méthode throw. Rack connaît ce tag et va comprendre qu’on lui demande d’arrêter là.

On précise le contenu de la réponse à envoyer avec le statut 403 qui est le statut adéquat pour une interdiction d’accès. Le deuxième élément du tableau est un Hash qui représente les en-têtes de la réponse et finalement le dernier élément est le contenu du body.

Pour finir nous avons le contenu de notre branche else qui pour rappel sert à gérer le cas où un upload a été supprimé de la base mais le fichier associé est resté disponible sur le disque :

throw :halt, [404, { "Content-Type" => "text/plain" }, ["Not Found"]]

Dans ce cas nous renvoyons simplement une 404 pour signifier l’absence de disponibilité du fichier.

Profiter au maximum des outils à votre disposition

Le cas que je met en avant dans cet article n’est qu’un exemple parmi tant d’autres. Les applications sont multiples, on pourrait très bien imaginer manipuler les en-têtes en fonction du navigateur pour satisfaire des besoins particulier, notamment pour streamer du contenu vidéo à Safari. Toutes les manipulations possibles et imaginables sur les réponses d’un middleware Rack sont envisageables puisque Rack nous laisse un contrôle total sur la réponse finale.

N’oubliez pas que les middlewares permettent potentiellement d’outrepasser une grosse partie de la stack et donc de gagner en performance. Sachant cela, il y a de nombreuses applications possibles dans lesquelles des actions spécifiques pourraient être gérées par un middleware si vous n’avez pas besoin de l’artillerie complète que propose Rails mais que vous mettez plutôt l’accent sur le temps de réponse.

Rien de tel que de se plonger dans la documentation d’API et le code source des gems que vous utilisez souvent, et qui ajoutent de la “magie” ou des comportements avancés. Cela vous permet, le jour venu, d’être en mesure de vous interfacer au mieux avec ces gems, pour en étendre les possibilités et aller plus loin que la simple utilisation de base décrite dans le README.


L’équipe Synbioz.
Libres d’être ensemble.