你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

使用 Azure API 管理保护单页应用程序中的访问令牌

Azure API 管理
Microsoft Entra ID
Azure 静态 Web 应用

本指南介绍如何使用 Azure API 管理为 JavaScript 单页应用程序实现无状态体系结构,该体系结构不会在浏览器会话中存储令牌。 这样做有助于保护访问令牌免受跨站点脚本 (XSS) 攻击,并防止恶意代码在浏览器中运行。

此体系结构使用 API 管理 来:

  • 为前端实现 后端 模式,该模式从 Microsoft Entra ID 获取 OAuth2 访问令牌。
  • 使用高级加密标准 AES 加密和解密访问令牌。
  • 将令牌存储在 HttpOnly cookie 中。
  • 代理所有需要授权的 API 调用。

由于后端处理令牌获取,因此在单页应用程序中不需要 Microsoft JavaScript 身份验证库(MSAL.js)等其他代码或库。 使用此设计时,浏览器会话或本地存储中不会存储任何令牌。 在 HttpOnly cookie 中加密和存储访问令牌有助于防止其受到 XSS 攻击。 将其范围限定为 API 域并将 SameSite 设置为 Strict 可确保使用所有代理 API 第一方请求自动发送 Cookie。

建筑

关系图,该图显示了不在浏览器中存储令牌的体系结构。

下载此体系结构的 Visio 文件

工作流

  1. 用户在单页应用程序中选择 登录
  2. 单页应用程序通过重定向到 Microsoft Entra 授权终结点调用授权代码流。
  3. 用户自行进行身份验证。
  4. 授权代码流响应将重定向到 API 管理回调终结点。
  5. API 管理策略通过调用 Microsoft Entra 令牌终结点来交换访问令牌的授权代码。
  6. Azure API 管理策略会重定向到应用程序,并将加密访问令牌置于 HttpOnly cookie 中。
  7. 用户通过 API 管理代理终结点从应用程序调用外部 API 调用。
  8. API 管理策略接收 API 请求、解密 Cookie 并发出下游 API 调用,并将访问令牌添加为 Authorization 标头。

组件

  • Microsoft Entra ID 跨 Azure 工作负荷提供标识服务、单一登录和多重身份验证。
  • API 管理 是跨所有环境的 API 的混合多云管理平台。 API 管理为现有后端服务创建一致的新式 API 网关。
  • Azure 静态 Web 应用 是一项服务,可自动从代码存储库生成全堆栈 Web 应用并将其部署到 Azure。 部署由对 GitHub 或 Azure DevOps 存储库中的应用程序源代码所做的更改触发。

方案详细信息

单页应用程序是用 JavaScript 编写的,在客户端浏览器的上下文中运行。 在此实现中,用户可以访问浏览器中运行的任何代码。 在浏览器或 XSS 攻击中运行的恶意代码还可以访问数据。 可以访问存储在浏览器会话或本地存储中的数据,因此敏感数据(如访问令牌)可用于模拟用户。

此处所述的体系结构通过将令牌获取和存储移动到后端以及使用加密 HttpOnly Cookie 来存储访问令牌来提高应用程序的安全性。 访问令牌无需存储在浏览器会话或本地存储中,并且无法通过浏览器中运行的恶意代码访问它们。

在此体系结构中,API 管理策略处理获取访问令牌以及 Cookie 的加密和解密。 策略 是在 API 的请求或响应上按顺序运行的语句集合,这些语句由 XML 元素和 C# 脚本组成。

将 Cookie 存储在 HttpOnly Cookie 有助于保护令牌免受 XSS 攻击,并确保 JavaScript 无法访问该令牌。 将 Cookie 范围限定为 API 域并将 SameSite 设置为 Strict 可确保使用所有代理 API 第一方请求自动发送 Cookie。 此设计使访问令牌能够自动添加到后端从单页应用程序进行的所有 API 调用的 Authorization 标头。

由于此体系结构使用 SameSite=Strict Cookie,因此 API 管理网关的域必须与单页应用程序的域相同。 这是因为仅当 API 请求来自同一域中的站点时,Cookie 才会发送到 API 管理网关。 如果域不同,则不会将 Cookie 添加到 API 请求中,并且代理的 API 请求保持未经身份验证。

可以在不对 API 管理实例和静态 Web 应用使用自定义域的情况下配置此体系结构,但随后需要为 Cookie 设置使用 SameSite=None。 此实现会导致不太安全的实现,因为 Cookie 会添加到 API 管理网关的任何实例的所有请求。 有关详细信息,请参阅 SameSite cookie

若要详细了解如何将自定义域用于 Azure 资源,请参阅 使用 Azure 静态 Web 应用 的自定义域,为 Azure API 管理实例配置自定义域名。 有关为自定义域配置 DNS 记录的详细信息,请参阅 如何在 Azure 门户中管理 DNS 区域

身份验证流

此过程使用 OAuth2 授权代码流。 若要获取允许单页应用程序访问 API 的访问令牌,用户必须先自行进行身份验证。 通过将用户重定向到 Microsoft Entra 授权终结点来调用身份验证流。 需要在 Microsoft Entra ID 中配置重定向 URI。 此重定向 URI 必须是 API 管理回调终结点。 系统会提示用户使用 Microsoft Entra ID 进行身份验证,并使用授权代码重定向回 API 管理回调终结点。 然后,API 管理策略通过调用 Microsoft Entra 令牌终结点来交换访问令牌的授权代码。 下图显示了此流的事件序列。

显示身份验证流的 关系图。

该流包含以下步骤:

  1. 若要获取访问令牌以允许单页应用程序访问 API,用户必须先自行进行身份验证。 用户通过选择将流重定向到Microsoft标识平台授权终结点的按钮来调用流。 redirect_uri 设置为 API 管理网关的 /auth/callback API 终结点。

  2. 系统会提示用户自行进行身份验证。 如果身份验证成功,Microsoft标识平台会使用重定向进行响应。

  3. 浏览器重定向到 redirect_uri,即 API 管理回调终结点。 授权代码将传递给回调终结点。

  4. 调用回调终结点的入站策略。 策略通过调用 Microsoft Entra 令牌终结点来交换访问令牌的授权代码。 它传递所需的信息,例如客户端 ID、客户端密码和授权代码:

    <send-request ignore-error="false" timeout="20" response-variable-name="response" mode="new">
     <set-url>https://login.microsoftonline.com/{{tenant-id}}/oauth2/v2.0/token</set-url>
     <set-method>POST</set-method>
     <set-header name="Content-Type" exists-action="override">
         <value>application/x-www-form-urlencoded</value>
     </set-header>
     <set-body>@($"grant_type=authorization_code&code={context.Request.OriginalUrl.Query.GetValueOrDefault("code")}&client_id={{client-id}}&client_secret={{client-secret}}&redirect_uri=https://{context.Request.OriginalUrl.Host}/auth/callback")</set-body>
    </send-request>
    
  5. 访问令牌返回并存储在名为 token的变量中:

    <set-variable name="token" value="@((context.Variables.GetValueOrDefault<IResponse>("response")).Body.As<JObject>())" />
    
  6. 访问令牌使用 AES 加密进行加密,并存储在名为 cookie的变量中:

    <set-variable name="cookie" value="@{
        var rng = new RNGCryptoServiceProvider();
        var iv = new byte[16];
        rng.GetBytes(iv);
        byte[] tokenBytes = Encoding.UTF8.GetBytes((string)(context.Variables.GetValueOrDefault<JObject>("token"))["access_token"]);
        byte[] encryptedToken = tokenBytes.Encrypt("Aes", Convert.FromBase64String("{{enc-key}}"), iv);
        byte[] combinedContent = new byte[iv.Length + encryptedToken.Length];
        Array.Copy(iv, 0, combinedContent, 0, iv.Length);
        Array.Copy(encryptedToken, 0, combinedContent, iv.Length, encryptedToken.Length);
        return System.Net.WebUtility.UrlEncode(Convert.ToBase64String(combinedContent));
     }" />
    
  7. 调用回调终结点的出站策略以重定向到单页应用程序。 它在 HttpOnly cookie 中设置加密访问令牌,该 cookie SameSite 设置为 Strict,其范围限定为 API 管理网关的域。 由于未设置显式过期日期,因此 Cookie 将创建为会话 Cookie,并在关闭浏览器时过期。

    <return-response>
        <set-status code="302" reason="Temporary Redirect" />
        <set-header name="Set-Cookie" exists-action="override">
            <value>@($"{{cookie-name}}={context.Variables.GetValueOrDefault<string>("cookie")}; Secure; SameSite=Strict; Path=/; Domain={{cookie-domain}}; HttpOnly")</value>
        </set-header>
        <set-header name="Location" exists-action="override">
            <value>{{return-uri}}</value>
        </set-header>
    </return-response>
    

API 调用流

当单页应用程序具有访问令牌时,它可以使用令牌调用下游 API。 由于 Cookie 的范围限定为单页应用程序的域,并且配置了 SameSite=Strict 属性,因此会自动将其添加到请求中。 然后,可以解密访问令牌,以便它可用于调用下游 API。 下图显示了此流的事件序列。

显示 API 调用序列的关系图。

该流包含以下步骤:

  1. 用户选择单页应用程序中的按钮来调用下游 API。 此作调用调用 API 管理网关 /graph/me API 终结点的 JavaScript 函数。

  2. 由于 Cookie 的范围限定为单页应用程序的域,并且 SameSite 设置为 Strict,因此浏览器在将请求发送到 API 时会自动添加 Cookie。

  3. 当 API 管理网关收到请求时,将调用 /graph/me 终结点的入站策略。 该策略从 Cookie 解密访问令牌并将其存储在名为 access_token的变量中:

    <set-variable name="access_token" value="@{
        try {
            string cookie = context.Request.Headers
                .GetValueOrDefault("Cookie")?
                .Split(';')
                .ToList()?
                .Where(p => p.Contains("{{cookie-name}}"))
                .FirstOrDefault()
                .Replace("{{cookie-name}}=", "");
            byte[] encryptedBytes = Convert.FromBase64String(System.Net.WebUtility.UrlDecode(cookie));
            byte[] iv = new byte[16];
            byte[] tokenBytes = new byte[encryptedBytes.Length - 16];
            Array.Copy(encryptedBytes, 0, iv, 0, 16);
            Array.Copy(encryptedBytes, 16, tokenBytes, 0, encryptedBytes.Length - 16);
            byte[] decryptedBytes = tokenBytes.Decrypt("Aes", Convert.FromBase64String("{{enc-key}}"), iv);
            char[] convertedBytesToChar = Encoding.UTF8.GetString(decryptedBytes).ToCharArray();
            return Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(convertedBytesToChar));
        } catch (Exception ex) {
            return null;
        }
    }" />
    
  4. 访问令牌作为 Authorization 标头添加到下游 API 的请求中:

    <choose>
        <when condition="@(!string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("access_token")))">
            <set-header name="Authorization" exists-action="override">
                <value>@($"Bearer {context.Variables.GetValueOrDefault<string>("access_token")}")</value>
            </set-header>
        </when>
    </choose>
    
  5. 请求将代理到下游 API,并将访问令牌添加到 Authorization 标头。

  6. 来自下游 API 的响应将直接返回到单页应用程序。

部署此方案

有关此处所述的策略的完整示例以及 OpenAPI 规范和完整的部署指南,请参阅此 GitHub 存储库

增强

此解决方案无法生产就绪。 它旨在演示如何使用此处所述的服务执行哪些作。 在生产环境中使用解决方案之前,请考虑以下因素。

  • 此示例不实现访问令牌过期或使用刷新或 ID 令牌。
  • 示例中 Cookie 的内容通过 AES 加密进行加密。 密钥作为机密存储在 API 管理实例的 命名值 窗格中。 为了更好地保护此命名值,可以使用对存储在 Azure Key Vault中的机密的引用。 应定期轮换加密密钥作为 密钥管理 策略的一部分。
  • 此示例仅代理对单个下游 API 的调用,因此它只需要一个访问令牌。 此方案允许使用无状态方法。 但是,由于 HTTP Cookie 的大小限制,如果需要代理对多个下游 API 的调用,则需要有状态的方法。 此方法不使用单个访问令牌,而是将访问令牌存储在缓存中,并根据调用的 API 和 Cookie 中提供的密钥检索这些令牌。 可以使用 API 管理 缓存 或外部 Redis 缓存来实现此方法。
  • 由于此示例仅演示了通过 GET 请求检索数据,因此它不提供对 CSRF 攻击的保护。 如果使用其他 HTTP 方法(如 POST、PUT、PATCH 或 DELETE),则需要此保护。

贡献

本文由Microsoft维护。 它最初由以下参与者编写。

主体作者:

其他参与者:

若要查看非公共LinkedIn配置文件,请登录到LinkedIn。

后续步骤

  • 使用应用程序网关和 API 管理 保护 API