Comment gérer les téléchargements de fichiers avec l'authentification basée sur JWT?

116

J'écris une webapp en Angular où l'authentification est gérée par un jeton JWT, ce qui signifie que chaque demande a un en-tête "Authentication" avec toutes les informations nécessaires.

Cela fonctionne bien pour les appels REST, mais je ne comprends pas comment je dois gérer les liens de téléchargement pour les fichiers hébergés sur le backend (les fichiers résident sur le même serveur où les services Web sont hébergés).

Je ne peux pas utiliser de <a href='...'/>liens réguliers car ils ne porteront aucun en-tête et l'authentification échouera. Idem pour les différentes incantations de window.open(...).

Quelques solutions auxquelles j'ai pensé:

  1. Générer un lien de téléchargement temporaire non sécurisé sur le serveur
  2. Transmettez les informations d'authentification en tant que paramètre d'URL et gérez manuellement le cas
  3. Récupérez les données via XHR et enregistrez le fichier côté client.

Tout ce qui précède est loin d’être satisfaisant.

1 est la solution que j'utilise actuellement. Je n'aime pas ça pour deux raisons: premièrement ce n'est pas idéal du point de vue sécurité, deuxièmement ça marche mais ça demande beaucoup de travail surtout sur le serveur: pour télécharger quelque chose, je dois appeler un service qui génère un nouveau "aléatoire "url, le stocke quelque part (éventuellement sur la base de données) pendant un certain temps, et le renvoie au client. Le client obtient l'URL et utilise window.open ou similaire avec. Lorsqu'elle est demandée, la nouvelle URL doit vérifier si elle est toujours valide, puis renvoyer les données.

2 semble au moins autant de travail.

3 semble beaucoup de travail, même en utilisant les bibliothèques disponibles, et beaucoup de problèmes potentiels. (J'aurais besoin de fournir ma propre barre d'état de téléchargement, de charger le fichier entier en mémoire, puis de demander à l'utilisateur de sauvegarder le fichier localement).

La tâche semble cependant assez basique, alors je me demande s'il y a quelque chose de beaucoup plus simple que je puisse utiliser.

Je ne cherche pas forcément une solution "à la manière angulaire". Javascript régulier serait bien.

Marco Righele
la source
Par télécommande, voulez-vous dire que les fichiers téléchargeables sont sur un domaine différent de celui de l'application Angular? Contrôlez-vous la télécommande (avez-vous accès pour modifier son backend) ou non?
robertjd
Je veux dire que les données du fichier ne sont pas sur le client (navigateur); le fichier est hébergé sur le même domaine et j'ai le contrôle du backend. Je mettrai à jour la question pour la rendre moins ambiguë.
Marco Righele le
La difficulté de l'option 2 dépend de votre backend. Si vous pouvez dire à votre backend de vérifier la chaîne de requête en plus de l'en-tête d'autorisation pour le JWT lorsqu'il passe par la couche d'authentification, vous avez terminé. Quel backend utilisez-vous?
Technetium

Réponses:

47

Voici un moyen de le télécharger sur le client à l' aide de l'attribut de téléchargement , de l'API fetch et de URL.createObjectURL . Vous récupérez le fichier à l'aide de votre JWT, convertissez la charge utile en un objet blob, placez l'objet blob dans une objectURL, définissez la source d'une balise d'ancrage sur cette objectURL et cliquez sur cette objectURL en javascript.

let anchor = document.createElement("a");
document.body.appendChild(anchor);
let file = 'https://www.example.com/some-file.pdf';

let headers = new Headers();
headers.append('Authorization', 'Bearer MY-TOKEN');

fetch(file, { headers })
    .then(response => response.blob())
    .then(blobby => {
        let objectUrl = window.URL.createObjectURL(blobby);

        anchor.href = objectUrl;
        anchor.download = 'some-file.pdf';
        anchor.click();

        window.URL.revokeObjectURL(objectUrl);
    });

La valeur de l' downloadattribut sera le nom éventuel du fichier. Si vous le souhaitez, vous pouvez extraire un nom de fichier prévu de l'en-tête de réponse de disposition de contenu comme décrit dans d'autres réponses .

Technétium
la source
1
Je me demande pourquoi personne n'envisage cette réponse. C'est simple et depuis que nous vivons en 2017, le support de la plateforme est assez bon.
Rafal Pastuszak
1
Mais le support iosSafari pour l'attribut de téléchargement semble assez rouge :(
Martin Cremer
1
Cela a bien fonctionné pour moi en chrome. Pour Firefox, cela a fonctionné après avoir ajouté l'ancre au document: document.body.appendChild (ancre); Je n'ai trouvé aucune solution pour Edge ...
Tompi
12
Cette solution fonctionne, mais cette solution gère-t-elle les problèmes UX avec des fichiers volumineux? Si je dois parfois télécharger un fichier de 300 Mo, le téléchargement peut prendre un certain temps avant de cliquer sur le lien et de l'envoyer au gestionnaire de téléchargement du navigateur. Nous pourrions dépenser l'effort d'utiliser l'api fetch-progress et créer notre propre interface utilisateur de progression de téléchargement ... mais il y a aussi la pratique discutable de charger un fichier de 300 Mo dans js-land (en mémoire?) Pour simplement le transférer au téléchargement directeur.
scvnc
1
@Tompi je ne pourrais pas non plus faire ce travail pour Edge et IE
zappa
34

Technique

Sur la base de ce conseil de Matias Woloski d'Auth0, évangéliste connu du JWT, je l'ai résolu en générant une requête signée avec Hawk .

Citant Woloski:

La façon dont vous résolvez ce problème consiste à générer une demande signée comme le fait AWS, par exemple.

Voici un exemple de cette technique, utilisée pour les liens d'activation.

backend

J'ai créé une API pour signer mes URL de téléchargement:

Demande:

POST /api/sign
Content-Type: application/json
Authorization: Bearer...
{"url": "https://path.to/protected.file"}

Réponse:

{"url": "https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c"}

Avec une URL signée, nous pouvons obtenir le fichier

Demande:

GET https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c

Réponse:

Content-Type: multipart/mixed; charset="UTF-8"
Content-Disposition': attachment; filename=protected.file
{BLOB}

frontend (par jojoyuji )

De cette façon, vous pouvez tout faire en un seul clic d'utilisateur:

function clickedOnDownloadButton() {

  postToSignWithAuthorizationHeader({
    url: 'https://path.to/protected.file'
  }).then(function(signed) {
    window.location = signed.url;
  });

}
Ezequias Dinella
la source
2
C'est cool mais je ne comprends pas en quoi c'est différent, du point de vue de la sécurité, de l'option n ° 2 de l'OP (jeton en tant que paramètre de chaîne de requête). En fait, je peux imaginer que la demande signée pourrait être plus restrictive, c'est-à-dire simplement autorisée à accéder à un point final particulier. Mais le n ° 2 du PO semble plus facile / moins d'étapes, qu'est-ce qui ne va pas?
Tyler Collier
4
En fonction de votre serveur Web, l'URL complète peut être enregistrée dans ses fichiers journaux. Vous ne voudrez peut-être pas que vos informaticiens aient accès à tous les jetons.
Ezequias Dinella
2
En outre, l'URL avec la chaîne de requête serait enregistrée dans l'historique de votre utilisateur, permettant à d'autres utilisateurs de la même machine d'accéder à l'URL.
Ezequias Dinella
1
Enfin, et ce qui rend cela très peu sûr, c'est que l'URL est envoyée dans l'en-tête Referer de toutes les demandes pour toute ressource, même des ressources tierces. Donc, si vous utilisez Google Analytics par exemple, vous enverrez à Google le jeton URL et tout à eux.
Ezequias Dinella
1
Ce texte est tiré d'ici: stackoverflow.com/questions/643355/…
Ezequias Dinella
10

Une alternative aux approches existantes "fetch / createObjectURL" et "download-token" déjà mentionnées est un formulaire POST standard qui cible une nouvelle fenêtre . Une fois que le navigateur a lu l'en-tête de la pièce jointe sur la réponse du serveur, il fermera le nouvel onglet et commencera le téléchargement. Cette même approche fonctionne également bien pour afficher une ressource comme un PDF dans un nouvel onglet.

Cela permet une meilleure prise en charge des navigateurs plus anciens et évite d'avoir à gérer un nouveau type de jeton. Cela aura également une meilleure prise en charge à long terme que l'authentification de base sur l'URL, car la prise en charge du nom d'utilisateur / mot de passe sur l'URL est supprimée par les navigateurs .

Du côté client, nous utilisons target="_blank"pour éviter la navigation même en cas d'échec, ce qui est particulièrement important pour les SPA (applications à page unique).

La principale mise en garde est que la validation JWT côté serveur doit obtenir le jeton à partir des données POST et non de l'en-tête . Si votre infrastructure gère automatiquement l'accès aux gestionnaires de route à l'aide de l'en-tête Authentication, vous devrez peut-être marquer votre gestionnaire comme non authentifié / anonyme afin de pouvoir valider manuellement le JWT pour garantir une autorisation appropriée.

Le formulaire peut être créé dynamiquement et immédiatement détruit afin qu'il soit correctement nettoyé (note: cela peut être fait en JS simple, mais JQuery est utilisé ici pour plus de clarté) -

function DownloadWithJwtViaFormPost(url, id, token) {
    var jwtInput = $('<input type="hidden" name="jwtToken">').val(token);
    var idInput = $('<input type="hidden" name="id">').val(id);
    $('<form method="post" target="_blank"></form>')
                .attr("action", url)
                .append(jwtInput)
                .append(idInput)
                .appendTo('body')
                .submit()
                .remove();
}

Ajoutez simplement toutes les données supplémentaires que vous devez soumettre en tant qu'entrées cachées et assurez-vous qu'elles sont ajoutées au formulaire.

James
la source
1
Je pense que cette solution est largement sous-estimée. C'est facile, propre et fonctionne parfaitement.
Yura Fedoriv le
6

Je générerais des jetons à télécharger.

Dans angular, faites une demande authentifiée pour obtenir un jeton temporaire (disons une heure), puis ajoutez-le à l'url en tant que paramètre get. De cette façon, vous pouvez télécharger des fichiers comme vous le souhaitez (window.open ...)

Fred
la source
2
C'est la solution que j'utilise pour l'instant, mais je n'en suis pas satisfait car c'est pas mal de travail et j'espère qu'il y a une meilleure solution "là-bas" ...
Marco Righele
3
Je pense que c'est la solution la plus propre disponible et je ne vois pas beaucoup de travail là-bas. Mais je choisirais une durée de validité du jeton plus petite (par exemple 3 minutes) ou en ferais un jeton unique en conservant une liste des jetons sur le serveur et en supprimant les jetons utilisés (n'acceptant pas les jetons qui ne figurent pas sur ma liste ).
nabinca
5

Une solution supplémentaire: utiliser l'authentification de base. Bien que cela nécessite un peu de travail sur le backend, les jetons ne seront pas visibles dans les journaux et aucune signature d'URL ne devra être implémentée.


Côté client

Un exemple d'URL pourrait être:

http://jwt:<user jwt token>@some.url/file/35/download

Exemple avec un jeton factice:

http://jwt:eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwIiwibmFtZSI6IiIsImlhdCI6MH0.KsKmQOZM-jcy4l_7NFsv1lWfpH8ofniVCv75ZRQrWno@some.url/file/35/download

Vous pouvez ensuite pousser ceci dans <a href="...">ou window.open("...")- le navigateur s'occupe du reste.


Du côté serveur

La mise en œuvre ici dépend de vous et dépend de la configuration de votre serveur - ce n'est pas trop différent de l'utilisation du ?token=paramètre de requête.

En utilisant Laravel, j'ai choisi la voie la plus simple et j'ai transformé le mot de passe d'authentification de base en en- Authorization: Bearer <...>tête JWT , laissant le middleware d'authentification normal gérer le reste:

class CarryBasic
{
    /**
     * @param Request $request
     * @param \Closure $next
     * @return mixed
     */
    public function handle($request, \Closure $next)
    {
        // if no basic auth is passed,
        // or the user is not "jwt",
        // send a 401 and trigger the basic auth dialog
        if ($request->getUser() !== 'jwt') {
            return $this->failedBasicResponse();
        }

        // if there _is_ basic auth passed,
        // and the user is JWT,
        // shove the password into the "Authorization: Bearer <...>"
        // header and let the other middleware
        // handle it.
        $request->headers->set(
            'Authorization',
            'Bearer ' . $request->getPassword()
        );

        return $next($request);
    }

    /**
     * Get the response for basic authentication.
     *
     * @return void
     * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
     */
    protected function failedBasicResponse()
    {
        throw new UnauthorizedHttpException('Basic', 'Invalid credentials.');
    }
}
AlbinosSécheresse
la source
Cette approche semble prometteuse, mais je ne vois pas de moyen d'accéder au jeton JWT de cette façon. Pouvez-vous m'indiquer comment le serveur analyse cette URL étrange et où accéder à la valeur du jeton jwt?
Jiri Vetyska
1
@JiriVetyska LOL PROMETTEUR? Le jeton est encore plus clair que de le passer dans les en-têtes ahahahha
Liquid Core