Wie gehe ich mit Dateidownloads mit JWT-basierter Authentifizierung um?

116

Ich schreibe eine Webanwendung in Angular, in der die Authentifizierung von einem JWT-Token verwaltet wird. Dies bedeutet, dass jede Anforderung einen "Authentifizierungs" -Header mit allen erforderlichen Informationen enthält.

Dies funktioniert gut für REST-Aufrufe, aber ich verstehe nicht, wie ich mit Download-Links für im Backend gehostete Dateien umgehen soll (die Dateien befinden sich auf demselben Server, auf dem die Webservices gehostet werden).

Ich kann keine regulären <a href='...'/>Links verwenden, da diese keinen Header enthalten und die Authentifizierung fehlschlägt. Gleiches gilt für die verschiedenen Beschwörungsformeln von window.open(...).

Einige Lösungen, an die ich gedacht habe:

  1. Generieren Sie einen temporären ungesicherten Download-Link auf dem Server
  2. Übergeben Sie die Authentifizierungsinformationen als URL-Parameter und behandeln Sie den Fall manuell
  3. Holen Sie sich die Daten über XHR und speichern Sie die Datei clientseitig.

Alle oben genannten sind weniger als zufriedenstellend.

1 ist die Lösung, die ich gerade verwende. Ich mag es aus zwei Gründen nicht: Erstens ist es in Bezug auf die Sicherheit nicht ideal, zweitens funktioniert es, aber es erfordert ziemlich viel Arbeit, insbesondere auf dem Server: Um etwas herunterzuladen, muss ich einen Dienst aufrufen, der einen neuen "Zufall" generiert "url, speichert es für einige Zeit irgendwo (möglicherweise in der Datenbank) und gibt es an den Client zurück. Der Client erhält die URL und verwendet window.open oder ähnliches. Auf Anfrage sollte die neue URL prüfen, ob sie noch gültig ist, und dann die Daten zurückgeben.

2 scheint mindestens so viel Arbeit.

3 scheint viel Arbeit zu sein, selbst wenn verfügbare Bibliotheken verwendet werden, und viele potenzielle Probleme. (Ich müsste meine eigene Download-Statusleiste bereitstellen, die gesamte Datei in den Speicher laden und dann den Benutzer bitten, die Datei lokal zu speichern.)

Die Aufgabe scheint jedoch ziemlich einfach zu sein, daher frage ich mich, ob es etwas viel Einfacheres gibt, das ich verwenden kann.

Ich bin nicht unbedingt auf der Suche nach einer Lösung "the Angular way". Normales Javascript wäre in Ordnung.

Marco Righele
quelle
Mit Remote meinen Sie, dass sich die herunterladbaren Dateien in einer anderen Domäne befinden als die Angular-App? Steuern Sie die Fernbedienung (haben Sie Zugriff, um das Backend zu ändern) oder nicht?
Robertjd
Ich meine, dass sich die Dateidaten nicht auf dem Client (Browser) befinden. Die Datei wird in derselben Domain gehostet und ich habe die Kontrolle über das Backend. Ich werde die Frage aktualisieren, um sie weniger mehrdeutig zu machen.
Marco Righele
Die Schwierigkeit von Option 2 hängt von Ihrem Backend ab. Wenn Sie Ihr Backend anweisen können, die Abfragezeichenfolge zusätzlich zum Autorisierungsheader für das JWT zu überprüfen, wenn es die Authentifizierungsschicht durchläuft, sind Sie fertig. Welches Backend verwenden Sie?
Technetium

Antworten:

47

Hier ist eine Möglichkeit, es mit dem Download-Attribut , der Fetch-API und URL.createObjectURL auf den Client herunterzuladen . Sie würden die Datei mit Ihrem JWT abrufen, die Nutzdaten in einen Blob konvertieren, den Blob in eine objectURL einfügen, die Quelle eines Ankertags auf diese objectURL setzen und auf diese objectURL in Javascript klicken.

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);
    });

Der Wert des downloadAttributs ist der eventuelle Dateiname. Falls gewünscht, können Sie einen beabsichtigten Dateinamen aus dem Header der Inhaltsdisposition abrufen, wie in anderen Antworten beschrieben .

Technetium
quelle
1
Ich frage mich immer wieder, warum niemand diese Antwort in Betracht zieht. Es ist einfach und da wir 2017 leben, ist die Plattformunterstützung ziemlich gut.
Rafal Pastuszak
1
Aber iosSafari Unterstützung für das Download-Attribut sieht ziemlich rot aus :(
Martin Cremer
1
Das hat bei mir in Chrom gut funktioniert. Für Firefox funktionierte es, nachdem ich den Anker zum Dokument hinzugefügt hatte: document.body.appendChild (anchor); Keine Lösung für Edge gefunden ...
Tompi
11
Diese Lösung funktioniert, aber behandelt diese Lösung UX-Probleme mit großen Dateien? Wenn ich manchmal eine 300-MB-Datei herunterladen muss, kann das Herunterladen einige Zeit dauern, bevor ich auf den Link klicke und ihn an den Download-Manager des Browsers sende. Wir könnten uns die Mühe machen, die Fetch-Progress-API zu verwenden und unsere eigene Benutzeroberfläche für den Download-Fortschritt zu erstellen. Dann gibt es aber auch die fragwürdige Praxis, eine 300-MB-Datei in js-land (im Speicher?) Zu laden, um sie lediglich an den Download weiterzugeben Manager.
scvnc
1
@Tompi auch ich diese Arbeit nicht für Kanten- und IE machen könnte
zappa
34

Technik

Basierend auf diesem Rat von Matias Woloski von Auth0, einem bekannten JWT-Evangelisten, löste ich ihn, indem ich eine unterschriebene Anfrage mit Hawk generierte .

Woloski zitieren:

Sie lösen dies, indem Sie eine signierte Anfrage generieren, wie dies beispielsweise bei AWS der Fall ist.

Hier haben Sie ein Beispiel für diese Technik, die für Aktivierungslinks verwendet wird.

Backend

Ich habe eine API erstellt, um meine Download-URLs zu signieren:

Anfrage:

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

Antwort:

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

Mit einer signierten URL können wir die Datei erhalten

Anfrage:

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

Antwort:

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

Frontend (von Jojoyuji )

Auf diese Weise können Sie alles mit einem einzigen Benutzerklick erledigen:

function clickedOnDownloadButton() {

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

}
Ezequias Dinella
quelle
2
Das ist cool, aber ich verstehe nicht, wie es sich aus Sicherheitsgründen von der Option 2 des OP unterscheidet (Token als Abfragezeichenfolgenparameter). Eigentlich kann ich mir vorstellen, dass die signierte Anfrage restriktiver sein könnte, dh nur auf einen bestimmten Endpunkt zugreifen darf. Aber die Nummer 2 des OP scheint einfacher zu sein / weniger Schritte. Was ist daran falsch?
Tyler Collier
4
Abhängig von Ihrem Webserver wird möglicherweise die vollständige URL in den Protokolldateien protokolliert. Möglicherweise möchten Sie nicht, dass Ihre IT-Mitarbeiter Zugriff auf alle Token haben.
Ezequias Dinella
2
Außerdem wird die URL mit der Abfragezeichenfolge im Verlauf Ihres Benutzers gespeichert, sodass andere Benutzer desselben Computers auf die URL zugreifen können.
Ezequias Dinella
1
Und was dies sehr unsicher macht, ist, dass die URL im Referer-Header aller Anforderungen für eine Ressource gesendet wird, auch für Ressourcen von Drittanbietern. Wenn Sie beispielsweise Google Analytics verwenden, senden Sie Google das URL-Token und alle an Google.
Ezequias Dinella
1
Dieser Text wurde von hier übernommen: stackoverflow.com/questions/643355/…
Ezequias Dinella
10

Eine Alternative zu den bereits erwähnten Ansätzen "fetch / createObjectURL" und "download-token" ist ein Standard- Formular-POST, der auf ein neues Fenster abzielt . Sobald der Browser den Anhangskopf in der Serverantwort liest, schließt er die neue Registerkarte und beginnt mit dem Download. Dieser Ansatz eignet sich auch gut zum Anzeigen einer Ressource wie einer PDF-Datei in einem neuen Tab.

Dies bietet eine bessere Unterstützung für ältere Browser und vermeidet die Verwaltung eines neuen Tokentyps. Dies hat auch langfristig eine bessere Unterstützung als die grundlegende Authentifizierung der URL, da die Unterstützung für Benutzername / Passwort in der URL von Browsern entfernt wird .

Auf der Clientseitetarget="_blank" vermeiden wir die Navigation auch in Fehlerfällen, was besonders für SPAs (Single Page Apps) wichtig ist.

Die größte Einschränkung besteht darin, dass die serverseitige JWT-Validierung das Token aus den POST-Daten und nicht aus dem Header abrufen muss . Wenn Ihr Framework den Zugriff auf Routenhandler mithilfe des Authentifizierungsheaders automatisch verwaltet, müssen Sie Ihren Handler möglicherweise als nicht authentifiziert / anonym markieren, damit Sie die JWT manuell validieren können, um eine ordnungsgemäße Autorisierung sicherzustellen.

Das Formular kann dynamisch erstellt und sofort zerstört werden, damit es ordnungsgemäß bereinigt wird (Hinweis: Dies kann in einfachem JS erfolgen, JQuery wird hier jedoch aus Gründen der Übersichtlichkeit verwendet).

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();
}

Fügen Sie einfach alle zusätzlichen Daten hinzu, die Sie als versteckte Eingaben übermitteln müssen, und stellen Sie sicher, dass sie an das Formular angehängt werden.

James
quelle
1
Ich glaube, diese Lösung ist stark unterbewertet. Es ist einfach, sauber und funktioniert perfekt.
Yura Fedoriv
6

Ich würde Token zum Download generieren.

Stellen Sie innerhalb von Angular eine authentifizierte Anforderung, um ein temporäres Token zu erhalten (z. B. eine Stunde), und fügen Sie es dann der URL als get-Parameter hinzu. Auf diese Weise können Sie Dateien nach Belieben herunterladen (window.open ...)

Fred
quelle
2
Dies ist die Lösung, die ich momentan verwende, aber ich bin nicht zufrieden damit, weil es ziemlich viel Arbeit ist und ich hoffe, dass es "da draußen" eine bessere Lösung gibt ...
Marco Righele
3
Ich denke, dies ist die sauberste verfügbare Lösung und ich kann dort nicht viel Arbeit sehen. Aber ich würde entweder eine kleinere Gültigkeitsdauer des Tokens wählen (z. B. 3 Minuten) oder es zu einem einmaligen Token machen, indem ich eine Liste der Token auf dem Server führe und gebrauchte Token lösche (ohne Token zu akzeptieren, die nicht auf meiner Liste stehen ).
Nabinca
5

Eine zusätzliche Lösung: Verwendung der Basisauthentifizierung. Obwohl das Backend ein wenig Arbeit erfordert, werden Token in Protokollen nicht angezeigt und es muss keine URL-Signatur implementiert werden.


Client-Seite

Eine Beispiel-URL könnte sein:

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

Beispiel mit Dummy-Token:

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

Sie können dies dann einschieben <a href="...">oder window.open("...")- der Browser erledigt den Rest.


Serverseite

Die Implementierung hier liegt bei Ihnen und hängt von Ihrem Server-Setup ab - es unterscheidet sich nicht allzu sehr von der Verwendung des ?token=Abfrageparameters.

Mit Laravel ging ich den einfachen Weg und wandelte das grundlegende Authentifizierungskennwort in den JWT- Authorization: Bearer <...>Header um, sodass die normale Authentifizierungs-Middleware den Rest erledigte:

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.');
    }
}
AlbinoDrought
quelle
Dieser Ansatz scheint vielversprechend, aber ich sehe keine Möglichkeit, auf diese Weise auf das JWT-Token zuzugreifen. Können Sie mich auf eine Ressource verweisen, wie der Server diese seltsame URL analysiert und wo auf den JWT-Token-Wert zugegriffen werden kann?
Jiri Vetyska
1
@JiriVetyska LOL VERSPRECHEN? Der Token ist noch klarer als das Übergeben in Headern ahahahha
Liquid Core