Microsoft 标识平台中的签名密钥滚动更新

本文介绍了你需要了解的有关 Microsoft 标识平台用来为安全令牌签名的公钥的信息。 请务必注意,这些密钥会定期滚动更新,并且在紧急情况下可立即滚动更新。 所有使用 Microsoft 标识平台的应用程序都应该能以编程方式处理密钥滚动更新过程。 你将了解密钥的工作原理,如何评估滚动更新对应用程序的影响。 你还将了解如何更新应用程序或建立定期手动滚动更新过程,以在必要时处理密钥滚动更新。

Microsoft 标识平台中签名密钥的概述

Microsoft 标识平台使用基于行业标准构建的公钥加密,在它自己和使用它的应用程序之间建立信任关系。 实际上,它的工作原理如下所述:Microsoft 标识平台使用签名密钥,该密钥由公钥和私钥对组成。 当用户登录到使用 Microsoft 标识平台进行身份验证的应用程序时,Microsoft 标识平台会创建一个包含用户相关信息的安全令牌。 此令牌由 Microsoft 标识平台使用其私钥进行签名,然后再发送回应用程序。 若要验证该令牌是否有效且来自 Microsoft 标识平台,应用程序必须使用由 Microsoft 标识平台公开的公钥(包含在租户的 OpenID Connect 发现文档或 SAML/WS-Fed 联合元数据文档中)来验证令牌的签名。

出于安全考虑,Microsoft 标识平台的签名密钥会定期更新,且紧急情况下,可立即滚动更新。 这些密钥滚动更新之间未设置时间或没有确切的时间。 任何与 Microsoft 标识平台集成的应用程序都应该做好处理密钥变换事件的准备,无论该事件可能发生的频率如何。 如果应用程序不处理突然刷新,并且尝试使用过期的密钥验证令牌上的签名,则应用程序将错误地拒绝令牌。 建议使用标准库来确保密钥元数据已正确刷新并保持最新状态。 如果未使用标准库,请确保实现遵循最佳做法部分。

OpenID Connect 发现文档和联合元数据文档中始终提供多个有效密钥。 应用程序应该做好使用文档中指定的任何以及所有密钥的准备,因为一个密钥可能很快会滚动更新,另一个密钥可能就会取而代之,依此类推。 由于我们支持新平台、新云或新的身份验证协议,目前的密钥数量随时间的推移,可能会根据 Microsoft 标识平台的内部体系结构发生变化。 JSON 响应中的密钥顺序和它们的公开顺序均不应被视为对应用程序有意义。 若要了解有关 JSON Web 密钥数据结构的详细信息,可以参考 RFC7517

只支持一个签名密钥或需要手动更新签名密钥的应用程序,在本质上不太安全可靠。 应将它们更新为使用标准库,以确保它们始终使用最新的签名密钥,这是应采取的最佳做法之一。

密钥元数据缓存和验证的最佳做法

  • 使用特定于租户的终结点发现密钥,如 OpenID Connect (OIDC)联合元数据中所述
  • 即使应用程序跨多个租户部署,也建议始终独立发现和缓存应用程序所服务的每个租户的密钥(使用特定于租户的终结点)。 目前在租户之间通用的密钥将来可能在租户之间变得不同。
  • 使用以下缓存算法确保缓存具有复原能力而且是安全的

秘钥元数据缓存算法:

我们的标准库实现密钥的可复原和安全缓存。 建议使用它们来避免实现中的细微缺陷。 对于自定义实现,下面是粗略的算法:

一般注意事项:

  • 验证令牌的服务应具有能够存储许多不同密钥(10-1000 个)的缓存。
  • 应使用密钥 ID(OIDC 密钥元数据规范中的“kid”)将秘钥单独缓存为缓存密钥。
  • 缓存中密钥的生存时间应配置为 24 小时,每小时刷新一次。 这可确保系统能够快速响应正在删除的密钥,但有足够的缓存持续时间,这样就不会受到密钥提取过程中所出现的问题的影响。
  • 应在以下情况下刷新密钥:
    • 进程启动时或缓存为空时
    • 定期(建议每 1 小时)作为后台作业刷新
    • 如果收到的令牌是使用未知密钥签名的(标头中有未知的 kidtid),则动态地刷新

KeyRefresh 过程(IdentityModel 的概念算法)

  1. 初始化

    Configuration Manager 设置了一个特定地址来提取配置数据,并设置了必要的接口来检索和验证此数据。

  2. 配置检查

    在提取新数据之前,系统首先根据预定义刷新间隔检查现有数据是否仍然有效。

  3. 数据检索 如果数据已过时或缺失,系统会锁定以确保只有一个线程提取新数据,从而避免重复(和线程耗尽)。 然后,系统尝试从指定的终结点检索最新配置数据。

  4. 验证

    检索新数据后,系统会对其进行验证,从而确保它满足所需标准且未损坏。 只有当使用新密钥成功验证传入请求时,才会接受元数据。

  5. 错误处理

    如果在数据检索期间发生任何错误,都会记录下来。 如果无法提取新数据,系统将继续使用上次已知良好的配置运行

  6. 自动更新 系统会根据刷新间隔(建议为 12 小时,抖动为正负 1 小时)定期检查和更新配置数据。 如果需要,它还可以手动请求更新,确保数据始终保持最新。

  7. 使用新密钥验证令牌 如果令牌到达时带有配置中尚不知道的签名密钥,系统将尝试通过热路径上的同步调用提取配置,以处理常规预期更新之外的元数据中的新密钥(但频率不超过 5 分钟)

此方法可确保系统始终使用最新且有效的配置数据,同时妥善处理错误并避免冗余操作。

此算法的 .NET 实现可从 BaseConfigurationManager 获取。 它可能会根据复原能力和安全评估而发生变化。 另请参阅此处的说明

KeyRefresh 过程(伪代码):

此过程使用全局(lastSuccessfulRefreshTime 时间戳)来防止过于频繁地刷新秘钥的条件。

KeyRefresh(issuer)
{
  // Store cache entries and last successful refresh timestamp per distinct 'issuer'
 
  if (LastSuccessfulRefreshTime is set and more recent than 5 minutes ago) 
    return // without refreshing
 
  // Load keys URI using the tenant-specific OIDC configuration endpoint ('issuer' is the input parameter)
  oidcConfiguration = download JSON from "{issuer}/.well-known/openid-configuration"
 
  // Load list of keys from keys URI
  keyList = download JSON from jwks_uri property of oidcConfiguration
 
  foreach (key in keyList) 
  {
    cache entry = lookup in cache by kid property of key
    if (cache entry found) 
      set expiration of cache entry to now + 24h 
    else 
      add key to cache with expiration set to now + 24h
  }
 
  set LastSuccessfulRefreshTime to now // current timestamp
}

服务启动过程:

  • 用于更新密钥的 KeyRefresh
  • 启动每小时调用 KeyRefresh 的后台作业

用于验证密钥的 TokenValidation 过程(伪代码):

ValidateToken(token)
{
  kid = token.header.kid // get key id from token header
  issuer = token.body.iss // get issuer from 'iss' claim in token body
 
  key = lookup in cache by issuer and kid
  if (key found)
  {
     validate token with key and return
  }
  else // key is not found in the cache
  {
    call KeyRefresh(issuer) // to opportunistically refresh the keys for the issuer
    key = lookup in cache by issuer and kid
    if (key found)
    {
      validate token with key and return
    }
    else // key is not found in the cache even after refresh
    {
      return token validation error
    }
  }
}

如何评估应用程序是否会受到影响以及如何应对

应用程序如何处理密钥滚动更新取决于各种变量,例如应用程序类型或所使用的标识协议和库。 以下部分评估了最常见类型的应用程序是否受密钥滚动更新的影响,并提供有关如何更新应用程序以支持自动滚动更新或手动更新密钥的指南。

本指南适用于:

  • 从 Microsoft Entra 应用程序库(包括自定义)添加的应用程序具有单独的签名密钥指导。 详细信息
  • 通过应用程序代理发布的本地应用程序无需担心签名密钥。

访问资源的本机客户端应用程序

只访问资源的应用程序(例如,Microsoft Graph、KeyVault、Outlook API 和其他 Microsoft API)将仅获取一个令牌并将其传递给资源所有者。 鉴于它们不保护任何资源且不检查令牌,因此无需确保为其正确签名。

本机客户端应用程序(不管是桌面还是移动应用程序)都属于此类别,因此不受滚动更新的影响。

访问资源的 Web 应用程序/API

只访问资源的应用程序(例如,Microsoft Graph、KeyVault、Outlook API 和其他 Microsoft API)将仅获取一个令牌并将其传递给资源所有者。 鉴于它们不保护任何资源且不检查令牌,因此无需确保为其正确签名。

使用“仅限应用”流(客户端凭据/客户端证书)来请求令牌的 Web 应用程序和 Web API 属于此类别,因而不受滚动更新的影响。

保护资源的和使用 Azure 应用服务构建的 Web 应用程序/API

Azure App Services 的身份验证/授权 (EasyAuth) 功能已包含自动处理密钥滚动更新所需要的逻辑。

使用 ASP.NET OWIN OpenID Connect、WS-Fed 或 WindowsAzureActiveDirectoryBearerAuthentication 中间件保护资源的 Web 应用程序/API

如果应用程序使用 ASP.NET OWIN OpenID Connect、WS-Fed 或 WindowsAzureActiveDirectoryBearerAuthentication 中间件,则它已包含必要的逻辑来自动处理密钥滚动更新。

可以通过查看应用程序的 Startup.cs 或 Startup.Auth.cs 文件中的以下代码片段,来确认应用程序是否正在使用上述任何中间件。

app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        // ...
    });
app.UseWsFederationAuthentication(
    new WsFederationAuthenticationOptions
    {
        // ...
    });
app.UseWindowsAzureActiveDirectoryBearerAuthentication(
    new WindowsAzureActiveDirectoryBearerAuthenticationOptions
    {
        // ...
    });

使用 .NET Core OpenID Connect 或 JwtBearerAuthentication 中间件保护资源的 Web 应用程序/API

如果应用程序使用 ASP.NET OWIN OpenID Connect 或 JwtBearerAuthentication 中间件,则它已包含必要的逻辑来自动处理密钥滚动更新。

可以通过查看应用程序的 Startup.cs 或 Startup.Auth.cs 中的以下代码片段,来确认应用程序是否正在使用上述任何中间件

app.UseOpenIdConnectAuthentication(
     new OpenIdConnectAuthenticationOptions
     {
         // ...
     });
app.UseJwtBearerAuthentication(
    new JwtBearerAuthenticationOptions
    {
     // ...
     });

使用 Node.js passport-azure-ad 模块保护资源的 Web 应用程序/API

如果应用程序使用 Node.js passport-ad 模块,则它已包含必要的逻辑来自动处理密钥滚动更新。

可以通过搜索应用程序的 app.js 中的以下代码片段,来确认应用程序是否正在使用 passport-ad

var OIDCStrategy = require('passport-azure-ad').OIDCStrategy;

passport.use(new OIDCStrategy({
    //...
));

保护资源的和使用 Visual Studio 2015 或更高版本创建的 Web 应用程序/API

如果应用程序通过 Visual Studio 2015 或更高版本中的 Web 应用程序模板构建,且从“更改身份验证” 菜单中选择了“工作或学校帐户” ,则应用程序已包含自动处理密钥滚动更新所需要的逻辑。 此逻辑嵌入在 OWIN OpenID Connect 中间件中,可检索和缓存来自 OpenID Connect 发现文档中的密钥并定期刷新它们。

如果已手动将身份验证添加到解决方案,则应用程序可能不包含必要的密钥滚动更新逻辑。 你可以自行编写该逻辑,或遵循使用任何其他库或手动实现任何受支持协议的 Web 应用程序/API 中的步骤。

保护资源的和使用 Visual Studio 2013 创建的 Web 应用程序

如果应用程序通过 Visual Studio 2013 中的 Web 应用程序模板生成,并且在“更改身份验证”菜单中选择了“组织帐户”,则应用程序已包含自动处理密钥滚动更新所需的逻辑 。 此逻辑将组织的唯一标识符和签名密钥信息存储到与项目关联的两个数据库表中。 可以在项目的 Web.config 文件中找到数据库的连接字符串。

如果已手动将身份验证添加到解决方案,则应用程序可能不包含必要的密钥滚动更新逻辑。 你需要自行编写该逻辑,或遵循使用任何其他库或手动实现任何受支持协议的 Web 应用程序/API 中的步骤。

以下步骤有助于验证该逻辑是否在应用程序中正常工作。

  1. 在 Visual Studio 2013 中,打开解决方案,并选择右侧窗口上的“服务器资源管理器”选项卡。
  2. 依次展开“数据连接”、“DefaultConnection”和“表” 。 找到“IssuingAuthorityKeys”表,右键单击它,然后选择“显示表数据”。
  3. IssuingAuthorityKeys 表中至少有一行与密钥的指纹值相对应。 删除该表中的所有行。
  4. 右键单击“Tenants”表,然后选择“显示表数据”
  5. Tenants 表中,至少有一行与唯一的目录租户标识符相对应。 删除该表中的所有行。 如果未同时删除 TenantsIssuingAuthorityKeys 表中的行,则运行时会出现错误。
  6. 生成并运行应用程序。 登录到帐户后,可以停止应用程序。
  7. 返回“服务器资源管理器”,查看“IssuingAuthorityKeys”和“Tenants”表中的值 。 可以看到系统已自动使用联合元数据文档中的相应信息对这两个表进行重新填充。

保护资源的和使用 Visual Studio 2013 创建的 Web API

如果在 Visual Studio 2013 中使用 Web API 模板创建了 Web API 应用程序,然后在“更改身份验证”菜单中选择了“组织帐户”,则应用程序中已包含必需的逻辑 。

如果是手动配置的身份验证,请参阅下面的说明,了解如何将 Web API 配置为自动更新其密钥信息。

以下代码片段演示如何从联合元数据文档获取最新密钥,并使用 JWT 令牌处理程序来验证令牌。 该代码片段假设你使用自己的缓存机制来持久保存密钥(以便验证将来从 Microsoft 标识平台获取的令牌),无论是将它保存在数据库中、配置文件中,还是保存在其他位置。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.IdentityModel.Tokens;
using System.Configuration;
using System.Security.Cryptography.X509Certificates;
using System.Xml;
using System.IdentityModel.Metadata;
using System.ServiceModel.Security;
using System.Threading;

namespace JWTValidation
{
    public class JWTValidator
    {
        private string MetadataAddress = "[Your Federation Metadata document address goes here]";

        // Validates the JWT Token that's part of the Authorization header in an HTTP request.
        public void ValidateJwtToken(string token)
        {
            JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler()
            {
                // Do not disable for production code
                CertificateValidator = X509CertificateValidator.None
            };

            TokenValidationParameters validationParams = new TokenValidationParameters()
            {
                AllowedAudience = "[Your App ID URI goes here]",
                ValidIssuer = "[The issuer for the token goes here, such as https://sts.windows.net/aaaabbbb-0000-cccc-1111-dddd2222eeee/]",
                SigningTokens = GetSigningCertificates(MetadataAddress)

                // Cache the signing tokens by your desired mechanism
            };

            Thread.CurrentPrincipal = tokenHandler.ValidateToken(token, validationParams);
        }

        // Returns a list of certificates from the specified metadata document.
        public List<X509SecurityToken> GetSigningCertificates(string metadataAddress)
        {
            List<X509SecurityToken> tokens = new List<X509SecurityToken>();

            if (metadataAddress == null)
            {
                throw new ArgumentNullException(metadataAddress);
            }

            using (XmlReader metadataReader = XmlReader.Create(metadataAddress))
            {
                MetadataSerializer serializer = new MetadataSerializer()
                {
                    // Do not disable for production code
                    CertificateValidationMode = X509CertificateValidationMode.None
                };

                EntityDescriptor metadata = serializer.ReadMetadata(metadataReader) as EntityDescriptor;

                if (metadata != null)
                {
                    SecurityTokenServiceDescriptor stsd = metadata.RoleDescriptors.OfType<SecurityTokenServiceDescriptor>().First();

                    if (stsd != null)
                    {
                        IEnumerable<X509RawDataKeyIdentifierClause> x509DataClauses = stsd.Keys.Where(key => key.KeyInfo != null && (key.Use == KeyType.Signing || key.Use == KeyType.Unspecified)).
                                                             Select(key => key.KeyInfo.OfType<X509RawDataKeyIdentifierClause>().First());

                        tokens.AddRange(x509DataClauses.Select(token => new X509SecurityToken(new X509Certificate2(token.GetX509RawData()))));
                    }
                    else
                    {
                        throw new InvalidOperationException("There is no RoleDescriptor of type SecurityTokenServiceType in the metadata");
                    }
                }
                else
                {
                    throw new Exception("Invalid Federation Metadata document");
                }
            }
            return tokens;
        }
    }
}

保护资源的和使用 Visual Studio 2012 创建的 Web 应用程序

如果应用程序是在 Visual Studio 2012 中生成的,则你可能已使用标识和访问工具配置了应用程序。 还可能会用到验证颁发者名称注册表 (VINR)。 VINR 负责维护受信任标识提供程序(Microsoft 标识平台)的相关信息以及用于验证其颁发的令牌的密钥。 使用 VINR 还可轻松地自动更新存储在 Web.config 文件中的密钥信息,具体方法是:下载与用户的目录关联的最新联合元数据文档,使用最新文档检查配置是否过期,并根据需要更新应用程序以使用新密钥。

如果是使用 Microsoft 提供的代码示例或演练文档创建的应用程序,则密钥滚动更新逻辑已包含在项目中。 你会注意到下面的代码已存在于项目中。 如果应用程序尚未包含该逻辑,请按照下面的步骤添加该逻辑,并验证该逻辑是否正常工作。

  1. 在“解决方案资源管理器”中,添加对相应项目的 System.IdentityModel 程序集的引用 。
  2. 打开 Global.asax.cs 文件并添加以下 using 指令:
    using System.Configuration;
    using System.IdentityModel.Tokens;
    
  3. Global.asax.cs 文件中添加以下方法:
    protected void RefreshValidationSettings()
    {
     string configPath = AppDomain.CurrentDomain.BaseDirectory + "\\" + "Web.config";
     string metadataAddress =
                   ConfigurationManager.AppSettings["ida:FederationMetadataLocation"];
     ValidatingIssuerNameRegistry.WriteToConfig(metadataAddress, configPath);
    }
    
  4. 在 Global.asax.cs 中的 Application_Start() 方法内调用 RefreshValidationSettings() 方法,如下所示:
    protected void Application_Start()
    {
     AreaRegistration.RegisterAllAreas();
     ...
     RefreshValidationSettings();
    }
    

执行这些步骤后,系统使用联合元数据文档中的最新信息(包括最新密钥)更新应用程序的 Web.config。 每次在 IIS 中回收应用程序池时,都会进行此更新;默认情况下,IIS 设置为每 29 个小时回收一次应用程序。

遵循以下步骤验证密钥滚动更新逻辑是否正常工作。

  1. 确认应用程序正在使用上面的代码后,打开 Web.config 文件并导航到 <issuerNameRegistry> 块,注意查找以下几行
    <issuerNameRegistry type="System.IdentityModel.Tokens.ValidatingIssuerNameRegistry, System.IdentityModel.Tokens.ValidatingIssuerNameRegistry">
         <authority name="https://sts.windows.net/aaaabbbb-0000-cccc-1111-dddd2222eeee/">
           <keys>
             <add thumbprint="AA11BB22CC33DD44EE55FF66AA77BB88CC99DD00" />
           </keys>
    
  2. 在 <add thumbprint=""> 设置中,通过将任何字符替换为不同的字符来更改指纹值。 保存 Web.config 文件。
  3. 生成并运行应用程序。 如果你能完成登录过程,则应用程序会通过从你的目录的联合元数据文档下载所需的信息来成功地更新密钥。 如果在登录时遇到问题,请阅读使用 Microsoft 标识平台将登录名添加到 Web 应用程序一文,或下载并检查代码示例用于 Microsoft Entra 的多租户云应用程序,以确保应用程序中的更改是正确的。

使用任何其他库保护资源或手动实现任何受支持协议的 Web 应用程序/API

如果正在使用其他某个库或手动实现任何受支持的协议,则需要检查该库或实现,以确保正在从 OpenID Connect 发现文档或联合元数据文档检索密钥。 进行此项检查的方法之一是在代码或库的代码中执行搜索,以找到对 OpenID 发现文档或联合元数据文档的任何调用。

如果密钥存储在某处或在应用程序中进行了硬编码,则可按照本指南文档末尾的说明执行手动滚动更新,手动检索密钥并进行相应更新。 强烈建议使用本文中所述的任何方法增强应用程序以支持自动滚动更新,从而避免将来在 Microsoft 标识平台增大其滚动更新频率或发生紧急带外滚动更新时出现中断和开销。

如何测试应用程序以确定它是否会受影响

可以使用以下 PowerShell 脚本来验证应用程序是否支持自动密钥滚动更新。

若要使用 PowerShell 检查和更新签名密钥,需要 MSIdentityTools PowerShell 模块。

  1. 安装 MSIdentityTools PowerShell 模块:

    Install-Module -Name MSIdentityTools
    
  2. 使用 Connect-MgGraph 命令和管理员帐户登录,以同意所需范围:

     Connect-MgGraph -Scope "Application.ReadWrite.All"
    
  3. 获取可用的签名密钥指纹列表:

    Get-MsIdSigningKeyThumbprint
    
  4. 选取任意密钥指纹,并配置 Microsoft Entra ID,使其将该密钥用于应用程序(从 Microsoft Entra 管理中心获取应用 ID):

    Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -KeyThumbprint <Thumbprint>
    
  5. 通过登录以获取新令牌来测试 Web 应用程序。 密钥更新更改是即时的,但请确保使用新的浏览器会话(例如,Internet Explorer 的“InPrivate”、“Chrome”的“Incognito”或 Firefox 的“Private”模式),以确保颁发了新令牌。

  6. 对于每个返回的签名密钥指纹,运行 Update-MsIdApplicationSigningKeyThumbprint cmdlet 并测试 Web 应用程序登录过程。

  7. 如果 Web 应用程序正确登录,则它支持自动滚动更新。 如果没有,请修改应用程序以支持手动滚动更新。 有关详细信息,请参阅建立手动滚动更新过程

  8. 运行以下脚本以还原到正常行为:

    Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -Default
    

如果应用程序不支持自动滚动更新,如何执行手动滚动更新

如果应用程序不支持自动滚动更新,则需要建立一个定期监视 Microsoft 标识平台签名密钥的过程,并手动执行相应滚动更新。

若要通过 PowerShell 检查和更新签名密钥,需要 MSIdentityTools PowerShell 模块。

  1. 安装 MSIdentityTools PowerShell 模块:

    Install-Module -Name MSIdentityTools
    
  2. 获取最新的签名密钥(从 Microsoft Entra 管理中心获取租户 ID):

    Get-MsIdSigningKeyThumbprint -Tenant <tenandId> -Latest
    
  3. 将此密钥与应用程序当前硬编码或配置为使用的密钥进行比较。

  4. 如果最新密钥不同于你的应用程序使用的密钥,请下载最新的签名密钥:

    Get-MsIdSigningKeyThumbprint -Latest -DownloadPath <DownloadFolderPath>
    
  5. 更新应用程序的代码或配置以使用新密钥。

  6. 配置 Microsoft Entra ID,使其将该密钥用于应用程序(从 Microsoft Entra 管理中心获取应用 ID):

    Get-MsIdSigningKeyThumbprint -Latest | Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId>
    
  7. 通过登录以获取新令牌来测试 Web 应用程序。 密钥更新更改是即时的,但请确保使用新的浏览器会话来确保为你颁发新令牌。 例如,使用 Microsoft Edge 的“InPrivate”、Chrome 的“Incognito”或 Firefox 的“Private”模式。

  8. 如果遇到任何问题,请还原到以前使用的密钥,并与 Azure 支持部门联系:

    Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -KeyThumbprint <PreviousKeyThumbprint>
    
  9. 更新应用程序以支持手动滚动更新后,还原为正常行为:

    Update-MsIdApplicationSigningKeyThumbprint -ApplicationId <ApplicationId> -Default