下载文件时如何传递身份验证令牌?

How can I pass an auth token when downloading a file?

我有一个 Web 应用程序,其中 Angular (7) front-end 与服务器上的 REST API 通信,并使用 OpenId Connect (OIDC) 进行身份验证。我正在使用 HttpInterceptor 向我的 HTTP 请求添加 Authorization header 以向服务器提供身份验证令牌。到目前为止,还不错。

然而,除了传统的 JSON 数据,我的 back-end 还负责生成文档 on-the-fly。在添加身份验证之前,我可以简单地向这些文档添加 link,如:

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

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

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

我拼凑了一些 "works on my machine" 部分基于 this answer 和其他类似的东西 - 尽管我的努力是 "Angular-ized" 被打包为 re-usable 指令.没什么大不了的(大部分代码都是 grunt-work 根据服务器发送的 content-disposition header 来确定文件名应该是什么)。

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

    /* Whosebug 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 请求中,因为我的 all HttpClient 调用已经解决了这个问题HttpInterceptor 实施(未显示)。要在没有拦截器的情况下做到这一点,只需在请求中添加一个 header(在我的例子中,一个 Authorization header)。

另外值得一提的是,如果被调用的网络 API 位于使用 CORS 的服务器上,它可能会阻止 client-side 代码访问 content-disposition响应 header。要允许访问此 header,您可以让服务器发送适当的 access-control-allow-headers header.

Angular (7) front-end communicates with a REST API on the server

然后:

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

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

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

这只是 URL 语义,并没有真正改变任何东西,但是当你开始将事物混合成一个混合体时,你确实倾向于 运行 进入这些类型的问题 API.

However, now that I've added authentication, this no longer works, because the browser does not include the auth token in the request when fetching the document.

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

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

使用 createObjectURL() 将响应突变为新的 window 是一种具有其他副作用的 hack。例如弹出窗口拦截器、浏览器历史记录突变以及用户无法查看下载、中止下载或在浏览器的下载历史记录中查看下载。您还必须考虑下载在浏览器中消耗的所有内存,切换到 base64 只会增加内存消耗。

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

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

混合 RESTful API 值得使用混合身份验证方法。