【问题标题】:How can I pass an auth token when downloading a file?下载文件时如何传递身份验证令牌?
【发布时间】:2019-07-12 04:13:35
【问题描述】:

我有一个 Web 应用程序,其中 Angular (7) 前端与服务器上的 REST API 通信,并使用 OpenId Connect (OIDC) 进行身份验证。我正在使用HttpInterceptorAuthorization 标头添加到我的HTTP 请求中,以向服务器提供身份验证令牌。到目前为止,一切顺利。

但是,除了传统的 JSON 数据,我的后端还负责动态生成文档。在添加身份验证之前,我可以简单地链接到这些文档,如下所示:

<a href="https://my-server.com/my-api/document?id=3">Download</a>

但是,现在我已经添加了身份验证,这不再有效,因为浏览器在获取文档时在请求中不包含身份验证令牌 - 因此我从服务器收到了 401-Unathorized 响应。

所以,我不能再依赖普通的 HTML 链接——我需要创建自己的 HTTP 请求,并明确添加身份验证令牌。但是,如何确保用户体验与用户单击链接时相同?理想情况下,我希望使用服务器建议的文件名而不是通用文件名来保存文件。

【问题讨论】:

    标签: angular http-headers openid-connect auth-token


    【解决方案1】:

    我已经拼凑了一些“在我的机器上工作”的东西,部分基于this answer 和其他类似的东西——尽管我的努力是通过被打包为一个可重用的指令来“Angular-ized”的。没什么大不了的(大部分代码都在做繁琐的工作,根据服务器发送的content-disposition 标头来确定文件名应该是什么)。

    download-file.directive.ts

    import { Directive, HostListener, Input } from '@angular/core';
    import { HttpClient, HttpHeaders } from '@angular/common/http';
    
    @Directive({
      selector: '[downloadFile]'
    })
    export class DownloadFileDirective {
      constructor(private readonly httpClient: HttpClient) {}
    
      private downloadUrl: string;
    
      @Input('downloadFile')
      public set url(url: string) {
        this.downloadUrl = url;
      };
    
      @HostListener('click')
      public async onClick(): Promise<void> {
    
        // Download the document as a blob
        const response = await this.httpClient.get(
          this.downloadUrl,
          { responseType: 'blob', observe: 'response' }
        ).toPromise();
    
        // Create a URL for the blob
        const url = URL.createObjectURL(response.body);
    
        // Create an anchor element to "point" to it
        const anchor = document.createElement('a');
        anchor.href = url;
    
        // Get the suggested filename for the file from the response headers
        anchor.download = this.getFilenameFromHeaders(response.headers) || 'file';
    
        // Simulate a click on our anchor element
        anchor.click();
    
        // Discard the object data
        URL.revokeObjectURL(url);
      }
    
      private getFilenameFromHeaders(headers: HttpHeaders) {
        // The content-disposition header should include a suggested filename for the file
        const contentDisposition = headers.get('Content-Disposition');
        if (!contentDisposition) {
          return null;
        }
    
        /* StackOverflow is full of RegEx-es for parsing the content-disposition header,
        * but that's overkill for my purposes, since I have a known back-end with
        * predictable behaviour. I can afford to assume that the content-disposition
        * header looks like the example in the docs
        * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
        *
        * In other words, it'll be something like this:
        *    Content-Disposition: attachment; filename="filename.ext"
        *
        * I probably should allow for single and double quotes (or no quotes) around
        * the filename. I don't need to worry about character-encoding since all of
        * the filenames I generate on the server side should be vanilla ASCII.
        */
    
        const leadIn = 'filename=';
        const start = contentDisposition.search(leadIn);
        if (start < 0) {
          return null;
        }
    
        // Get the 'value' after the filename= part (which may be enclosed in quotes)
        const value = contentDisposition.substring(start + leadIn.length).trim();
        if (value.length === 0) {
          return null;
        }
    
        // If it's not quoted, we can return the whole thing
        const firstCharacter = value[0];
        if (firstCharacter !== '\"' && firstCharacter !== '\'') {
          return value;
        }
    
        // If it's quoted, it must have a matching end-quote
        if (value.length < 2) {
          return null;
        }
    
        // The end-quote must match the opening quote
        const lastCharacter = value[value.length - 1];
        if (lastCharacter !== firstCharacter) {
          return null;
        }
    
        // Return the content of the quotes
        return value.substring(1, value.length - 1);
      }
    }
    

    这个用法如下:

    <a downloadFile="https://my-server.com/my-api/document?id=3">Download</a>
    

    ...或者,当然:

    <a [downloadFile]="myUrlProperty">Download</a>
    

    请注意,我没有在这段代码中明确地将身份验证令牌添加到 HTTP 请求中,因为我的 HttpInterceptor 实现已经处理了 all HttpClient 调用(未显示)。要在没有拦截器的情况下执行此操作,只需在请求中添加一个标头(在我的例子中是一个 Authorization 标头)。

    还有一点值得一提的是,如果正在调用的 Web API 位于使用 CORS 的服务器上,它可能会阻止客户端代码访问 content-disposition 响应标头。要允许访问此标头,您可以让服务器发送适当的 access-control-allow-headers 标头。

    【讨论】:

    • 不错的答案,但document.createElement('a') 不会在您每次单击链接时创建一个新元素?使用 Angular 原生解决方案会不会比弄乱 DOM 更好?我正在考虑使用 ElementRef 什么的。
    • @ShamPooSham:创建的元素永远不会附加到文档,因此一旦超出范围,它将被丢弃。如果您有 Angular 原生解决方案,我会全力以赴!
    • 在我的例子中,content-dispositionattachment; filename=xy.jpeg; filename*=UTF-8''xy.jpeg,所以我不得不将 return value; 更改为 return value.split(';')[0].trim();
    【解决方案2】:

    Angular (7) 前端与服务器上的 REST API 通信

    然后:

    <a href="https://my-server.com/my-api/document?id=3">Download</a>
    

    这告诉我你的 RESTful API 并不是真正的 RESTful。

    原因是上面的 GET 请求不是 RESTful API 范式的一部分。这是一个产生非 JSON 内容类型响应的基本 HTTP GET 请求,并且该响应不代表 RESTful 资源的状态。

    这只是 URL 语义,并没有真正改变任何东西,但是当您开始将内容混合到混合 API 中时,您确实会遇到这类问题。

    但是,现在我已经添加了身份验证,这不再有效,因为浏览器在获取文档时没有在请求中包含身份验证令牌。

    不,它工作正常。产生401 未授权响应的是服务器

    我明白你在说什么。 &lt;a&gt; 标记不再允许您下载 URL,因为该 URL 现在需要身份验证。话虽如此,在无法提供任何内容的上下文中,服务器要求对 GET 请求进行 HEADER 身份验证有点奇怪。这不是您的经验所独有的问题,因为我经常看到这种情况发生。这是切换到 JWT 令牌并认为这可以解决所有问题的心态。

    使用createObjectURL() 将响应更改为新窗口是一种具有其他副作用的黑客行为。例如弹出窗口阻止程序、浏览器历史记录突变以及用户无法查看下载、中止下载或在浏览器的下载历史记录中查看。您还必须想知道下载在浏览器中消耗的所有内存,切换到 base64 只会使内存消耗激增。

    您应该通过修复服务器的身份验证来解决此问题。

    <a href="https://my-server.com/my-api/document?id=3&auth=XXXXXXXXXXXXXXXXXXXX">Download</a>
    

    混合 RESTful API 应该采用混合身份验证方法。

    【讨论】:

    • “这告诉我你的 RESTful API 并不是真正的 RESTful”——实际上,它告诉你的是我的示例 URL 选择不当 :-) 它与我的实际 API 不匹配(我也不拥有my-server.com,为了记录)。
    • “不,它工作正常。它是产生 401 未授权响应的服务器。” ——显然我应该更准确地使用我的语言。我的意思并不是在 Internet 损坏的意义上暗示“不起作用”,而只是在 401 对用户没有用的意义上暗示“不起作用”。我知道服务器和客户端之间存在分歧,但感谢您的检查。
    • “服务器要求在无法提供任何内容的上下文中对 GET 请求进行 HEADER 身份验证,这有点奇怪”——我控制着这种通信的两端,所以我 可以按照您的建议使用查询参数实现此资源的授权。但是,在安全方面,我总是讨厌“自己动手”,因为这通常是错误的做法。目前,虽然我不得不稍微搞砸以使其“工作”,但我既没有更改客户端也没有更改服务器机制来处理身份验证。您会注意到我的回答中没有与安全相关的代码。
    • “使用 createObjectURL() 将响应更改为新窗口是一种具有其他副作用的 hack。” ——好吧,这部分让我笑了,但只是因为我不能同意更多。整个事情 - 包括 OIDC 客户端库 - 是一英里高的 Jenga 黑客塔。在我看来,这并不比它所在的所有其他黑客更糟糕。无论如何,您提到的所有缺点都没有真正发生在我的环境中(这是已知浏览器等的内部环境) - 下载与普通文件下载完全相同。
    • 永远不要使用查询参数公开您的 JWT 令牌。
    猜你喜欢
    • 2021-08-17
    • 2014-06-18
    • 2011-02-06
    • 2019-02-16
    • 2021-04-13
    • 1970-01-01
    • 2014-02-01
    • 2020-03-25
    • 2017-02-08
    相关资源
    最近更新 更多