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


116

我正在Angular中编写一个webapp,其中身份验证是通过JWT令牌处理的,这意味着每个请求都带有一个“ Authentication”标头,其中包含所有必要的信息。

这对于REST调用非常有效,但是我不明白如何处理后端托管的文件的下载链接(这些文件位于托管Web服务的同一服务器上)。

我不能使用常规<a href='...'/>链接,因为它们将不带有任何标题,并且身份验证将失败。同样的咒语window.open(...)

我想到的一些解决方案:

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

以上所有都不令人满意。

1是我现在正在使用的解决方案。我不喜欢它有两个原因:首先,它不是理想的安全方式;其次,它可以工作,但是特别是在服务器上,它需要进行大量工作:要下载某些东西,我需要调用一个生成新的“随机”服务的服务网址,将其存储在某个位置(可能在数据库上)一段时间,然后将其返回给客户端。客户端获取url,并使用window.open或类似的名称。当请求时,新的url应该检查它是否仍然有效,然后返回数据。

2似乎至少要完成同样的工作。

3即使使用可用的库也似乎需要大量工作,并且存在许多潜在问题。(我需要提供自己的下载状态栏,将整个文件加载到内存中,然后要求用户将文件保存在本地)。

但是,该任务似乎是一项非常基本的任务,因此我想知道是否可以使用更简单的方法。

我不一定要寻找“角度方式”的解决方案。普通的Javascript就可以了。


远程是指可下载文件位于与Angular应用程序不同的域中吗?是否控制遥控器(可以修改其后端)?
robertjd 2015年

我的意思是文件数据不在客户端(浏览器)上;该文件托管在同一域中,我可以控制后端。我将更新问题,以减少歧义。
Marco Righele,2015年

选项2的难度取决于您的后端。如果您可以告诉后端在JWT通过身份验证层时除了JWT的授权标头之外还检查查询字符串,那么您就可以完成。您正在使用哪个后端?
Technetium

Answers:


47

这是一种使用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);
    });

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


1
我一直在想为什么没有人考虑这一回应。很简单,因为我们生活在2017年,所以平台支持相当不错。
Rafal Pastuszak

1
但是iosSafari对download属性的支持看起来很红:(
Martin Cremer,

1
这在Chrome中对我来说效果很好。对于firefox,在将锚添加到文档后,它起作用了:document.body.appendChild(anchor); 找不到Edge的任何解决方案...
Tompi

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

1
@Tompi我也无法针对Edge和IE进行此工作
zappa,

34

技术

基于 来自Auth0的Matias Woloski的建议(JWT传播者),我通过与Hawk生成签名请求来解决了这个问题。

引用沃洛斯基:

例如,解决此问题的方法是通过像AWS一样生成签名的请求。

在这里,您有此技术的示例,用于激活链接。

后端

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

请求:

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

响应:

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

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

请求:

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

响应:

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

前端(由jojoyuji撰写

这样,您只需单击一次即可完成所有操作:

function clickedOnDownloadButton() {

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

}

2
这很酷,但是从安全的角度来看,我不理解它与OP的选项#2(作为查询字符串参数的令牌)有何不同。实际上,我可以想象签名的请求可能更具限制性,即仅允许访问特定端点。但是OP的#2似乎更容易/步骤更少,这有什么问题呢?
泰勒·科利尔

4
取决于您的Web服务器,完整的URL可能会记录在其日志文件中。您可能不希望您的IT人员可以访问所有令牌。
Ezequias Dinella

2
此外,带有查询字符串的URL将保存在用户的历史记录中,从而允许同一计算机的其他用户访问该URL。
Ezequias Dinella

1
最后,最不安全的是,URL在所有资源(甚至第三方资源)的所有请求的Referer标头中发送。因此,例如,如果您使用Google Analytics(分析),则将URL令牌以及所有令牌发送给Google。
Ezequias Dinella


10

已经提到的现有“ fetch / createObjectURL”和“下载令牌”方法的替代方法是针对新窗口的标准Form 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();
}

只需添加您需要提交为隐藏输入的任何其他数据,并确保将它们附加到表单即可。


1
我相信此解决方案已被广泛采用。它简单,干净且完美运行。
Yura Fedoriv

6

我会生成供下载的令牌。

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


2
这是我目前正在使用的解决方案,但是我对此并不满意,因为它需要进行大量工作,并且希望“在那里”有更好的解决方案……
Marco Righele

3
我认为这是最干净的解决方案,我在那里看不到很多工作。但是我要么选择更短的令牌有效时间(例如3分钟),要么通过在服务器上保留令牌列表并删除已使用的令牌(不接受不在我列表中的令牌)来使其成为一次性令牌)。
nabinca

5

另一个解决方案:使用基本身份验证。尽管需要在后端进行一些工作,但令牌在日志中将不可见,并且无需实施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=query参数没有太大不同。

使用Laravel,我走了一条简单的路线,并将基本身份验证密码转换为JWT Authorization: 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.');
    }
}

这种方法看起来很有希望,但是我没有看到这样的方法来访问JWT令牌。您能否指出我一些资源,服务器如何解析这个奇怪的URL,以及在何处访问jwt令牌值?
Jiri Vetyska '19

1
@JiriVetyska大声笑吗?该令牌比在标头中传递它更清晰ahahahha
Liquid Core
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.