如何使用基于JWT的身份验证处理文件下载?

Mar*_*ele 95 javascript jwt angularjs

我正在Angular中编写一个webapp,其中身份验证由JWT令牌处理,这意味着每个请求都有一个带有所有必要信息的"身份验证"标头.

这适用于REST调用,但我不明白我应该如何处理后端托管的文件的下载链接(这些文件驻留在托管webservices的同一服务器上).

我不能使用常规<a href='...'/>链接,因为它们不会携带任何标头,验证将失败.同样的各种咒语window.open(...).

我想到的一些解决方案:

  1. 在服务器上生成临时不安全的下载链接
  2. 将身份验证信息作为url参数传递并手动处理案例
  3. 通过XHR获取数据并保存文件客户端.

以上所有都不太令人满意.

1是我现在使用的解决方案.我不喜欢它有两个原因:首先它不是理想的安全性,其次它可以工作,但它需要相当多的工作,特别是在服务器上:下载一些我需要调用一个生成一个新"随机"的服务"url,将它存储在某个地方(可能在数据库上)一段时间,并将其返回给客户端.客户端获取URL,并使用window.open或类似的.请求时,新URL应检查它是否仍然有效,然后返回数据.

2似乎至少同样多的工作.

3看起来很多工作,甚至使用可用的库,以及许多潜在的问题.(我需要提供自己的下载状态栏,将整个文件加载到内存中,然后要求用户在本地保存文件).

这个任务似乎是一个非常基本的任务,所以我想知道是否有更简单的东西我可以使用.

我不一定在寻找"Angular方式"的解决方案.常规Javascript会没事的.

Tec*_*ium 36

这是使用download属性,fetch APIURL.createObjectURL在客户端下载它的方法.您可以使用JWT获取文件,将有效负载转换为blob,将blob放入objectURL,将锚标记的源设置为该objectURL,然后在javascript中单击该objectURL.

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);
    });
Run Code Online (Sandbox Code Playgroud)

download属性的值将是最终的文件名.如果需要,您可以从内容处置响应标头中挖掘出预期的文件名,如其他答案中所述.

  • 此解决方案有效,但此解决方案是否处理大文件的UX问题?如果我有时需要下载300MB文件,可能需要一些时间才能下载,然后再单击链接并将其发送给浏览器的下载管理器.我们可以花费精力使用fetch-progress api并构建我们自己的下载进度UI ..但是还有一个可疑的做法是将一个300mb的文件加载到js-land(在内存中?),只是把它交给下载经理. (9认同)

Eze*_*lla 30

技术

根据已知的JWT传道者Auth0的Matias Woloski的建议,我通过与Hawk生成签名请求解决了这个问题.

引用沃洛斯基:

您解决此问题的方法是生成像AWS这样的签名请求.

这里有一个这种技术的例子,用于激活链接.

后端

我创建了一个API来签署我​​的下载网址:

请求:

POST /api/sign
Content-Type: application/json
Authorization: Bearer...
{"url": "https://path.to/protected.file"}
Run Code Online (Sandbox Code Playgroud)

响应:

{"url": "https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c"}
Run Code Online (Sandbox Code Playgroud)

使用签名URL,我们可以获取该文件

请求:

GET https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c
Run Code Online (Sandbox Code Playgroud)

响应:

Content-Type: multipart/mixed; charset="UTF-8"
Content-Disposition': attachment; filename=protected.file
{BLOB}
Run Code Online (Sandbox Code Playgroud)

前端(由jojoyuji)

这样您就可以通过单个用户点击完成所有操作:

function clickedOnDownloadButton() {

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

}
Run Code Online (Sandbox Code Playgroud)

  • 取决于您的Web服务器,完整的URL可能会记录在其日志文件中。您可能不希望您的IT人员可以访问所有令牌。 (3认同)
  • 这很酷,但是从安全角度来看,我不理解它与OP的选项#2(作为查询字符串参数的令牌)有何不同。实际上,我可以想象签名的请求可能更具限制性,即仅允许访问特定端点。但是OP的#2似乎更容易/步骤更少,这有什么问题呢? (2认同)
  • 此外,带有查询字符串的URL将保存在用户的历史记录中,允许同一台计算机的其他用户访问该URL. (2认同)
  • 在我为此模式实现的 web api 中,signed.url 仅适用于 1 次访问 (2认同)

Jam*_*mes 23

已经提到的现有“fetch/createObjectURL”和“download-token”方法的替代方法是针对新 window的标准表单 POST。浏览器读取服务器响应中的附件标头后,将关闭新选项卡并开始下载。同样的方法也恰好适用于在新选项卡中显示 PDF 等资源。

这可以更好地支持旧浏览器并避免必须管理新型令牌。这也将比 URL 上的基本身份验证具有更好的长期支持,因为浏览器正在删除对 url 上的用户名/密码的支持

客户端,target="_blank"即使在失败的情况下,我们也使用避免导航,这对于 SPA(单页应用程序)尤其重要。

主要的警告是服务器端JWT 验证必须从POST 数据不是从 header 中获取令牌。如果您的框架使用 Authentication 标头自动管理对路由处理程序的访问,您可能需要将您的处理程序标记为未经身份验证/匿名,以便您可以手动验证 JWT 以确保正确授权。

表单可以动态创建并立即销毁,以便正确清理(注意:这可以在纯 JS 中完成,但为了清晰起见,此处使用 JQuery)-

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();
}
Run Code Online (Sandbox Code Playgroud)

只需添加您需要作为隐藏输入提交的任何额外数据,并确保将它们附加到表单中。

  • 我相信这个解决方案被大大低估了。它简单、干净并且运行完美。 (2认同)

Jor*_*ren 9

James 的回答的纯 JS 版本

function downloadFile (url, token) {
    let form = document.createElement('form')
    form.method = 'post'
    form.target = '_blank'
    form.action = url
    form.innerHTML = '<input type="hidden" name="jwtToken" value="' + token + '">'

    console.log('form:', form)

    document.body.appendChild(form)
    form.submit()
    document.body.removeChild(form)
}
Run Code Online (Sandbox Code Playgroud)


Fre*_*red 6

我会生成令牌进行下载。

在角度范围内,发出经过身份验证的请求以获取临时令牌(例如一个小时),然后将其作为get参数添加到url中。这样,您可以按照自己喜欢的任何方式下载文件(window.open ...)

  • 我认为这是最干净的解决方案,我在那里看不到很多工作。但是我要么选择更短的令牌有效时间(例如3分钟),要么通过在服务器上保留令牌列表并删除已使用的令牌(不接受不在我列表中的令牌)来使其成为一次性令牌)。 (3认同)
  • 这是我目前使用的解决方案,但我对此不满意,因为它需要进行大量工作,并且希望“在那里”有更好的解决方案... (2认同)

Alb*_*ght 6

另一个解决方案:使用基本身份验证。虽然它需要在后端做一些工作,但令牌在日志中是不可见的,并且不需要实施 URL 签名。


客户端

一个示例 URL 可以是:

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

带有虚拟令牌的示例:

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

然后您可以将其推入<a href="...">window.open("...")- 浏览器处理其余部分。


服务器端

此处的实现取决于您,并且取决于您的服务器设置 - 这与使用?token=查询参数没有太大区别。

使用 Laravel,我采用了简单的方法,将基本身份验证密码转换为 JWTAuthorization: Bearer <...>标头,让普通身份验证中间件处理其余部分:

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.');
    }
}
Run Code Online (Sandbox Code Playgroud)

  • @JiriVetyska 哈哈,有前途吗?令牌比在标头中传递它更清晰啊哈哈哈 (2认同)