Como lidar com downloads de arquivos com autenticação baseada em JWT?

116

Estou escrevendo um webapp em Angular em que a autenticação é controlada por um token JWT, o que significa que cada solicitação tem um cabeçalho de "Autenticação" com todas as informações necessárias.

Isso funciona bem para chamadas REST, mas não entendo como devo lidar com links de download para arquivos hospedados no back-end (os arquivos residem no mesmo servidor onde os serviços da web estão hospedados).

Não posso usar <a href='...'/>links regulares, pois eles não carregam nenhum cabeçalho e a autenticação falhará. O mesmo vale para os vários encantamentos de window.open(...).

Algumas soluções que pensei:

  1. Gerar um link de download não seguro temporário no servidor
  2. Passe as informações de autenticação como um parâmetro de url e lide manualmente com o caso
  3. Obtenha os dados por meio de XHR e salve o arquivo do lado do cliente.

Todos os itens acima são menos que satisfatórios.

1 é a solução que estou usando agora. Não gosto disso por dois motivos: primeiro não é o ideal em termos de segurança, segundo funciona, mas requer bastante trabalho, especialmente no servidor: para baixar algo, preciso chamar um serviço que gera um novo "aleatório "url, armazena em algum lugar (possivelmente no banco de dados) por algum tempo e o devolve ao cliente. O cliente obtém o url e usa window.open ou semelhante com ele. Quando solicitado, o novo url deve verificar se ainda é válido e, em seguida, retornar os dados.

2 parece pelo menos tanto trabalho.

3 parece muito trabalhoso, mesmo usando as bibliotecas disponíveis, e muitos problemas potenciais. (Eu precisaria fornecer minha própria barra de status de download, carregar o arquivo inteiro na memória e então pedir ao usuário para salvar o arquivo localmente).

A tarefa parece bastante básica, então estou me perguntando se há algo muito mais simples que eu possa usar.

Não estou necessariamente procurando uma solução "do jeito Angular". Javascript regular seria bom.

Marco Righele
fonte
Remoto, você quer dizer que os arquivos para download estão em um domínio diferente do aplicativo Angular? Você controla o controle remoto (tem acesso para modificar o backend) ou não?
robertjd
Quero dizer que os dados do arquivo não estão no cliente (navegador); o arquivo está hospedado no mesmo domínio e eu tenho controle do back-end. Vou atualizar a pergunta para torná-la menos ambígua.
Marco Righele
A dificuldade da opção 2 depende do seu back-end. Se você puder dizer ao seu back-end para verificar a string de consulta, além do cabeçalho de autorização para o JWT quando ele passar pela camada de autenticação, está feito. Qual back-end você está usando?
Tecnécio,

Respostas:

47

Esta é uma maneira de baixá-lo no cliente usando o atributo download , a API fetch e URL.createObjectURL . Você deve buscar o arquivo usando seu JWT, converter a carga útil em um blob, colocar o blob em um objectURL, definir a origem de uma tag âncora para esse objectURL e clicar nesse objectURL em 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);
    });

O valor do downloadatributo será o nome do arquivo eventual. Se desejar, você pode extrair um nome de arquivo pretendido do cabeçalho de resposta de disposição de conteúdo, conforme descrito em outras respostas .

Tecnécio
fonte
1
Eu fico me perguntando por que ninguém considera essa resposta. É simples e como estamos vivendo em 2017, o suporte da plataforma é bastante bom.
Rafal Pastuszak
1
Mas o suporte iosSafari para o atributo de download parece bem vermelho :(
Martin Cremer
1
Isso funcionou bem para mim no cromo. Para o firefox funcionou depois que adicionei a âncora ao documento: document.body.appendChild (anchor); Não encontrou nenhuma solução para Edge ...
Tompi
11
Esta solução funciona, mas ela lida com questões de UX com arquivos grandes? Se às vezes eu precisar baixar um arquivo de 300 MB, pode levar algum tempo para fazer o download antes de clicar no link e enviá-lo para o gerenciador de download do navegador. Poderíamos nos esforçar para usar a API fetch-progress e construir nossa própria IU de progresso do download ... mas também há a prática questionável de carregar um arquivo de 300 MB no js-land (na memória?) Para simplesmente transferi-lo para o download Gerente.
scvnc
1
@Tompi Eu também não consegui fazer isso funcionar para o Edge e o IE
zappa
34

Técnica

Com base neste conselho de Matias Woloski de Auth0, conhecido evangelista do JWT, resolvi o problema gerando um pedido assinado com Hawk .

Citando Woloski:

A maneira de resolver isso é gerando uma solicitação assinada como a AWS, por exemplo.

Aqui você tem um exemplo dessa técnica, usada para links de ativação.

Processo interno

Eu criei uma API para assinar meus urls de download:

Solicitação:

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

Resposta:

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

Com um URL assinado, podemos obter o arquivo

Solicitação:

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

Resposta:

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

frontend (por jojoyuji )

Dessa forma, você pode fazer tudo com um único clique do usuário:

function clickedOnDownloadButton() {

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

}
Ezequias Dinella
fonte
2
Isso é legal, mas não entendo como é diferente, de uma perspectiva de segurança, da opção # 2 do OP (token como parâmetro de string de consulta). Na verdade, posso imaginar que a solicitação assinada poderia ser mais restritiva, ou seja, apenas com permissão para acessar um determinado endpoint. Mas o OP # 2 parece mais fácil / menos etapas, o que há de errado nisso?
Tyler Collier
4
Dependendo do seu servidor web, o URL completo pode ser registrado em seus arquivos de log. Você pode não querer que seu pessoal de TI tenha acesso a todos os tokens.
Ezequias Dinella
2
Além disso, o URL com a string de consulta seria salvo no histórico do usuário, permitindo que outros usuários da mesma máquina acessassem o URL.
Ezequias Dinella
1
Finalmente, o que torna isso muito inseguro é que a URL é enviada no cabeçalho Referer de todas as solicitações de qualquer recurso, mesmo de recursos de terceiros. Portanto, se estiver usando o Google Analytics, por exemplo, você enviará ao Google o token de URL e tudo para eles.
Ezequias Dinella
1
Este texto foi retirado daqui: stackoverflow.com/questions/643355/…
Ezequias Dinella
10

Uma alternativa às abordagens existentes "fetch / createObjectURL" e "download-token" já mencionadas é um Form POST padrão que visa uma nova janela . Assim que o navegador ler o cabeçalho do anexo na resposta do servidor, ele fechará a nova guia e iniciará o download. Essa mesma abordagem também funciona bem para exibir um recurso como um PDF em uma nova guia.

Isso oferece melhor suporte para navegadores mais antigos e evita a necessidade de gerenciar um novo tipo de token. Isso também terá um suporte de longo prazo melhor do que a autenticação básica no URL, uma vez que o suporte para nome de usuário / senha no url está sendo removido pelos navegadores .

No lado do cliente , usamostarget="_blank" para evitar a navegação mesmo em casos de falha, o que é particularmente importante para SPAs (aplicativos de página única).

A principal ressalva é que a validação do JWT do lado do servidor deve obter o token dos dados POST e não do cabeçalho . Se sua estrutura gerencia o acesso aos roteadores de rota automaticamente usando o cabeçalho de autenticação, pode ser necessário marcar seu manipulador como não autenticado / anônimo para que possa validar manualmente o JWT para garantir a autorização adequada.

O formulário pode ser criado dinamicamente e imediatamente destruído para que seja devidamente limpo (observação: isso pode ser feito em JS simples, mas JQuery é usado aqui para maior clareza) -

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

Basta adicionar quaisquer dados extras que você precisa enviar como entradas ocultas e certifique-se de que eles sejam anexados ao formulário.

James
fonte
1
Acredito que essa solução é muito desvalorizada. É fácil, limpo e funciona perfeitamente.
Yura Fedoriv
6

Gostaria de gerar tokens para download.

No angular, faça uma solicitação autenticada para obter um token temporário (digamos uma hora) e adicione-o ao url como um parâmetro get. Desta forma, você pode baixar arquivos da maneira que quiser (window.open ...)

Fred
fonte
2
Esta é a solução que estou usando agora, mas não estou satisfeito com ela porque é muito trabalhoso e espero que haja uma solução melhor "lá fora" ...
Marco Righele
3
Acho que esta é a solução mais limpa disponível e não consigo ver muito trabalho lá. Mas eu escolheria um menor tempo de validade do token (por exemplo, 3 minutos) ou torná-lo um token único, mantendo uma lista dos tokens no servidor e excluindo os tokens usados ​​(não aceitando tokens que não estão na minha lista )
nabinca
5

Uma solução adicional: usando autenticação básica. Embora exija um pouco de trabalho no back-end, os tokens não serão visíveis nos logs e nenhuma assinatura de URL terá que ser implementada.


Cliente

Um exemplo de URL poderia ser:

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

Exemplo com token fictício:

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

Você pode então enfiar isso <a href="...">ou window.open("...")- o navegador cuida do resto.


Lado do Servidor

A implementação aqui depende de você e depende da configuração do seu servidor - não é muito diferente de usar o ?token=parâmetro de consulta.

Usando o Laravel, peguei o caminho mais fácil e transformei a senha de autenticação básica no Authorization: Bearer <...>cabeçalho JWT , deixando o middleware de autenticação normal cuidar do 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.');
    }
}
AlbinoDrought
fonte
Essa abordagem parece promissora, mas não vejo uma maneira de obter acesso ao token JWT dessa forma. Você pode me indicar algum recurso de como o servidor analisa esse URL estranho e onde acessar o valor do token jwt?
Jiri Vetyska
1
@JiriVetyska LOL PROMISING? O token é ainda mais claro do que transmiti-lo nos cabeçalhos ahahahha
Liquid Core