¿Cómo manejar las descargas de archivos con autenticación basada en JWT?

116

Estoy escribiendo una aplicación web en Angular donde la autenticación es manejada por un token JWT, lo que significa que cada solicitud tiene un encabezado de "Autenticación" con toda la información necesaria.

Esto funciona bien para las llamadas REST, pero no entiendo cómo debo manejar los enlaces de descarga para los archivos alojados en el backend (los archivos residen en el mismo servidor donde están alojados los servicios web).

No puedo usar <a href='...'/>enlaces regulares ya que no llevarán ningún encabezado y la autenticación fallará. Lo mismo ocurre con los diversos encantamientos de window.open(...).

Algunas soluciones en las que pensé:

  1. Genere un enlace de descarga temporal no seguro en el servidor
  2. Pase la información de autenticación como un parámetro de URL y maneje manualmente el caso
  3. Obtenga los datos a través de XHR y guarde el archivo del lado del cliente.

Todo lo anterior es menos que satisfactorio.

1 es la solución que estoy usando en este momento. No me gusta por dos razones: primero, no es ideal en cuanto a seguridad, segundo, funciona pero requiere bastante trabajo, especialmente en el servidor: para descargar algo, necesito llamar a un servicio que genera un nuevo "aleatorio "url, lo almacena en algún lugar (posiblemente en la base de datos) durante un tiempo y lo devuelve al cliente. El cliente obtiene la URL y usa window.open o similar con ella. Cuando se solicite, la nueva URL debe verificar si aún es válida y luego devolver los datos.

2 parece al menos tanto trabajo.

3 parece mucho trabajo, incluso usando bibliotecas disponibles, y muchos problemas potenciales. (Necesitaría proporcionar mi propia barra de estado de descarga, cargar todo el archivo en la memoria y luego pedirle al usuario que guarde el archivo localmente).

Sin embargo, la tarea parece bastante básica, así que me pregunto si hay algo mucho más simple que pueda usar.

No estoy buscando necesariamente una solución "al estilo Angular". Javascript regular estaría bien.

Marco Righele
fuente
¿Por remoto quiere decir que los archivos descargables están en un dominio diferente al de la aplicación Angular? ¿Controlas el control remoto (tienes acceso para modificar su backend) o no?
robertjd
Quiero decir que los datos del archivo no están en el cliente (navegador); el archivo está alojado en el mismo dominio y yo tengo el control del backend. Actualizaré la pregunta para que sea menos ambigua.
Marco Righele
La dificultad de la opción 2 depende de su backend. Si puede decirle a su backend que verifique la cadena de consulta además del encabezado de autorización para el JWT cuando pasa por la capa de autenticación, ya está. ¿Qué backend estás usando?
Technecio

Respuestas:

47

Aquí hay una forma de descargarlo en el cliente utilizando el atributo de descarga , la API de recuperación y URL.createObjectURL . Buscaría el archivo usando su JWT, convertiría la carga útil en un blob, colocaría el blob en un objectURL, establecería el origen de una etiqueta de anclaje en ese objectURL y haría clic en ese 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);
    });

El valor del downloadatributo será el eventual nombre del archivo. Si lo desea, puede extraer un nombre de archivo deseado del encabezado de respuesta de disposición de contenido como se describe en otras respuestas .

Tecnecio
fuente
1
Me sigo preguntando por qué nadie considera esta respuesta. Es simple y como vivimos en 2017, el soporte de la plataforma es bastante bueno.
Rafal Pastuszak
1
Pero el soporte de iosSafari para el atributo de descarga parece bastante rojo :(
Martin Cremer
1
Esto funcionó bien para mí en Chrome. Para Firefox funcionó después de que agregué el ancla al documento: document.body.appendChild (anchor); No encontré ninguna solución para Edge ...
Tompi
11
Esta solución funciona, pero ¿maneja esta solución los problemas de UX con archivos grandes? Si a veces necesito descargar un archivo de 300 MB, podría llevar algún tiempo descargarlo antes de hacer clic en el enlace y enviarlo al administrador de descargas del navegador. Podríamos gastar el esfuerzo en usar la api fetch-progress y construir nuestra propia interfaz de usuario de progreso de descarga ... pero también existe la práctica cuestionable de cargar un archivo de 300mb en js-land (¿en memoria?) Para simplemente entregarlo a la descarga gerente.
scvnc
1
@Tompi yo tampoco pude hacer que esto funcione para Edge e IE
zappa
34

Técnica

Basado en este consejo de Matias Woloski de Auth0, conocido evangelista de JWT, lo resolví generando una solicitud firmada con Hawk .

Citando a Woloski:

La forma de resolver esto es generando una solicitud firmada como lo hace AWS, por ejemplo.

Aquí tienes un ejemplo de esta técnica, utilizada para enlaces de activación.

backend

Creé una API para firmar mis URL de descarga:

Solicitud:

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

Respuesta:

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

Con una URL firmada, podemos obtener el archivo.

Solicitud:

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

Respuesta:

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

frontend (por jojoyuji )

De esta manera, puede hacerlo todo con un solo clic de usuario:

function clickedOnDownloadButton() {

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

}
Ezequias Dinella
fuente
2
Esto es genial, pero no entiendo cómo es diferente, desde una perspectiva de seguridad, a la opción # 2 del OP (token como parámetro de cadena de consulta). En realidad, puedo imaginar que la solicitud firmada podría ser más restrictiva, es decir, solo se le permitiría acceder a un punto final en particular. Pero el número 2 del OP parece más fácil / menos pasos, ¿qué hay de malo en eso?
Tyler Collier
4
Dependiendo de su servidor web, la URL completa puede registrarse en sus archivos de registro. Es posible que no desee que su personal de TI tenga acceso a todos los tokens.
Ezequias Dinella
2
Además, la URL con la cadena de consulta se guardaría en el historial de su usuario, lo que permitiría a otros usuarios de la misma máquina acceder a la URL.
Ezequias Dinella
1
Finalmente, y lo que hace que esto sea muy inseguro es que la URL se envía en el encabezado Referer de todas las solicitudes de cualquier recurso, incluso recursos de terceros. Entonces, si usa Google Analytics, por ejemplo, le enviará a Google el token de URL y todo a ellos.
Ezequias Dinella
1
Este texto fue tomado de aquí: stackoverflow.com/questions/643355/…
Ezequias Dinella
10

Una alternativa a los enfoques existentes "fetch / createObjectURL" y "download-token" ya mencionados es un formulario POST estándar que apunta a una nueva ventana . Una vez que el navegador lea el encabezado del archivo adjunto en la respuesta del servidor, cerrará la nueva pestaña y comenzará la descarga. Este mismo enfoque también funciona bien para mostrar un recurso como un PDF en una nueva pestaña.

Esto tiene un mejor soporte para navegadores más antiguos y evita tener que administrar un nuevo tipo de token. Esto también tendrá un mejor soporte a largo plazo que la autenticación básica en la URL, ya que los navegadores están eliminando la compatibilidad con el nombre de usuario / contraseña en la URL .

En el lado del cliente , utilizamos target="_blank"para evitar la navegación incluso en casos de falla, lo cual es particularmente importante para las SPA (aplicaciones de una sola página).

La principal advertencia es que la validación de JWT del lado del servidor debe obtener el token de los datos POST y no del encabezado . Si su marco gestiona el acceso a los controladores de ruta automáticamente mediante el encabezado Autenticación, es posible que deba marcar su controlador como no autenticado / anónimo para poder validar manualmente el JWT para garantizar la autorización adecuada.

El formulario se puede crear dinámicamente y destruir de inmediato para que se limpie correctamente (nota: esto se puede hacer en JS simple, pero JQuery se usa aquí para mayor claridad) -

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

Simplemente agregue cualquier dato adicional que necesite enviar como entradas ocultas y asegúrese de que estén adjuntos al formulario.

James
fuente
1
Creo que esta solución está muy poco votada. Es fácil, limpio y funciona perfectamente.
Yura Fedoriv
6

Generaría tokens para descargar.

Dentro de angular, haga una solicitud autenticada para obtener un token temporal (digamos una hora) y luego agréguelo a la URL como un parámetro de obtención. De esta manera, puede descargar archivos de la forma que desee (window.open ...)

Fred
fuente
2
Esta es la solución que estoy usando por ahora, pero no estoy satisfecho con ella porque es bastante trabajo y espero que haya una mejor solución "por ahí" ...
Marco Righele
3
Creo que esta es la solución más limpia disponible y no veo mucho trabajo allí. Pero elegiría un tiempo de validez más pequeño del token (por ejemplo, 3 minutos) o lo convertiría en un token único al mantener una lista de los tokens en el servidor y eliminar los tokens usados ​​(no aceptar tokens que no están en mi lista ).
Nabinca
5

Una solución adicional: usar autenticación básica. Aunque requiere un poco de trabajo en el backend, los tokens no serán visibles en los registros y no será necesario implementar la firma de URL.


Lado del cliente

Una URL de ejemplo podría ser:

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

Ejemplo con token ficticio:

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

A continuación, puede introducir esto <a href="...">o window.open("..."), el navegador se encarga del resto.


Lado del servidor

La implementación aquí depende de usted y depende de la configuración de su servidor; no es muy diferente de usar el ?token=parámetro de consulta.

Usando Laravel, tomé la ruta fácil y transformé la contraseña de autenticación básica en el Authorization: Bearer <...>encabezado JWT , dejando que el middleware de autenticación normal se encargara del resto:

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.');
    }
}
AlbinoLa Sequía
fuente
Este enfoque parece prometedor, pero no veo una forma de obtener acceso al token JWT de esta manera. ¿Puede indicarme algún recurso sobre cómo el servidor analiza esta URL extraña y dónde acceder al valor del token jwt?
Jiri Vetyska
1
@JiriVetyska LOL PROMETEDOR? El token es aún más claro que pasarlo en encabezados ahahahha
Liquid Core