使用资源数据设置Microsoft Graph 更改通知

Microsoft Graph 使应用能够订阅和接收有关资源更改的通知。 本文介绍如何设置 丰富通知,这些通知直接包括通知有效负载中的资源数据。

丰富的通知无需额外的 API 调用即可获取更新的资源,从而更快、更轻松地运行业务逻辑。

支持的资源

丰富通知可用于以下资源。

注意

使用星号 (*) 标记的终结点订阅的丰富通知仅在终结点上 /beta 可用。

资源 支持的资源路径 限制
Copilot aiInteraction 特定用户所属的 Copilot AI 交互: copilot/users/{userId}/interactionHistory/getAllEnterpriseInteractions

在组织中协作处理 AI 交互: copilot/interactionHistory/getAllEnterpriseInteractions
最大订阅配额:
  • 每个应用和租户组合 (,用于跟踪跨租户的 AI 交互的订阅) :1
  • 每个应用和用户组合 (用于跟踪 AI 交互的订阅,特定用户是) 的一部分: 1
  • 跟踪 AI 交互的订阅的每个用户 (特定用户是) 的一部分:10 个订阅。
  • 每个组织:总共 10,000 个订阅。
  • Outlook 事件 对用户邮箱中所有事件的更改: /users/{id}/events $select需要仅返回丰富通知中的一部分属性。 有关详细信息,请参阅 Outlook 资源的更改通知
    Outlook 邮件 对用户邮箱中所有邮件的更改: /users/{id}/messages

    对用户收件箱中邮件的更改: /users/{id}/mailFolders/{id}/messages
    $select需要仅返回丰富通知中的一部分属性。 有关详细信息,请参阅 Outlook 资源的更改通知
    Outlook 个人联系人 对用户邮箱中所有个人联系人的更改: /users/{id}/contacts

    对用户 contactFolder 中所有个人联系人的更改: /users/{id}/contactFolders/{id}/contacts
    $select需要仅返回丰富通知中的一部分属性。 有关详细信息,请参阅 Outlook 资源的更改通知
    Teams callRecording 组织中的所有录制内容: communications/onlineMeetings/getAllRecordings

    特定会议的所有录制内容: communications/onlineMeetings/{onlineMeetingId}/recordings

    在由特定用户组织的会议中可用的通话记录: users/{id}/onlineMeetings/getAllRecordings

    在安装了特定 Teams 应用的会议中可用的通话记录: appCatalogs/teamsApps/{id}/installedToOnlineMeetings/getAllRecordings *
    最大订阅配额:
  • 每个应用和联机会议组合:1
  • 每个应用和用户组合:1
  • 每个用户 (,用于跟踪由用户组织的所有 onlineMeeting 中的记录的订阅) :10 个订阅。
  • 每个组织:总共 10,000 个订阅。
  • Teams callTranscript 组织中的所有脚本: communications/onlineMeetings/getAllTranscripts

    特定会议的所有脚本: communications/onlineMeetings/{onlineMeetingId}/transcripts

    在由特定用户组织的会议中可用的通话记录: users/{id}/onlineMeetings/getAllTranscripts

    在安装了特定 Teams 应用的会议中可用的通话记录: appCatalogs/teamsApps/{id}/installedToOnlineMeetings/getAllTrancripts *
    最大订阅配额:
  • 每个应用和联机会议组合:1
  • 每个应用和用户组合:1
  • 按用户 (订阅跟踪由用户组织的所有 onlineMeeting 中的脚本) :10 个订阅。
  • 每个组织:总共 10,000 个订阅。
  • Teams 频道 更改所有团队中的频道: /teams/getAllChannels

    对特定团队中的频道所做的更改: /teams/{id}/channels
    -
    Teams 聊天 对租户中任何聊天的更改: /chats

    对特定聊天的更改: /chats/{id}
    -
    Teams chatMessage 对所有团队所有频道中聊天消息的更改: /teams/getAllMessages

    对特定频道中的聊天消息的更改: /teams/{id}/channels/{id}/messages

    更改所有聊天中的聊天消息: /chats/getAllMessages

    对特定聊天中聊天消息的更改: /chats/{id}/messages

    对特定用户的所有聊天中聊天消息的更改是以下部分的一部分: /users/{id}/chats/getAllMessages
    不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。
    Teams conversationMember 对特定团队中成员身份的更改: /teams/{id}/members

    对租户中所有团队成员身份的更改: /teams/getAllMembers

    更改特定团队下的所有频道的成员身份: /teams/{id}/channels/getAllMembers

    对整个租户中所有通道的成员身份的更改: /teams/getAllChannels/getAllMembers

    对特定聊天中成员身份的更改: /chats/{id}/members

    更改所有 Teams 聊天的成员身份: /chats/getAllMembers
    不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。
    Teams onlineMeeting * 对联机会议的更改: /communications/onlineMeetings(joinWebUrl='{encodedJoinWebUrl}')/meetingCallEvents * 不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。 每个联机会议每个应用程序允许一个订阅。 有关详细信息,请参阅 获取Microsoft Teams 会议呼叫事件更新的更改通知
    Teams 状态 对单个用户状态的更改: /communications/presences/{id}

    对多个用户状态的更改: /communications/presences?$filter=id in ({id},{id}...)
    多用户状态的订阅限制为 650 个不同的用户。 不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。 每个委派用户允许每个应用程序一个订阅。 有关详细信息,请参阅 在 Microsoft Teams 中获取状态更新的更改通知
    Teams 团队 对租户中任何团队的更改: /teams

    对特定团队的更改: /teams/{id}
    -

    通知负载中的资源数据

    丰富通知包括包含以下详细信息的资源数据:

    • 已更改的资源实例的 ID 和类型,位于 resourceData 属性中。
    • 资源实例的所有属性值(在订阅中指定的加密)在 encryptedContent 属性中找到。
    • 资源的特定属性,具体取决于资源,或者( $select 如果使用订阅 的资源 URL 中的参数请求)。

    创建订阅

    若要设置丰富通知,请遵循与 基本更改通知相同的步骤,但包含以下必需属性:

    • includeResourceData:将此设置为 true 以请求资源数据。
    • encryptionCertificate:提供 graph Microsoft用于加密资源数据的公钥。 有关详细信息,请参阅 从更改通知解密资源数据
    • encryptionCertificateId:提供证书的标识符,以将通知与正确的解密密钥匹配。

    通知终结点验证中所述,验证两个终结点。 如果对两个终结点使用相同的 URL,则会收到并应响应两个验证请求。

    示例:订阅请求

    此示例为 Microsoft Teams 中的频道消息创建订阅。

    POST https://graph.microsoft.com/v1.0/subscriptions
    Content-Type: application/json
    
    {
      "changeType": "created,updated",
      "notificationUrl": "https://webhook.azurewebsites.net/api/resourceNotifications",
      "resource": "/teams/{id}/channels/{id}/messages",
      "includeResourceData": true,
      "encryptionCertificate": "{base64encodedCertificate}",
      "encryptionCertificateId": "{customId}",
      "expirationDateTime": "2019-09-19T11:00:00.0000000Z",
      "clientState": "{secretClientState}"
    }
    

    订阅响应

    HTTP/1.1 201 Created
    Content-Type: application/json
    
    {
      "changeType": "created,updated",
      "notificationUrl": "https://webhook.azurewebsites.net/api/resourceNotifications",
      "resource": "/teams/{id}/channels/{id}/messages",
      "includeResourceData": true,
      "encryptionCertificateId": "{customId}",
      "expirationDateTime": "2019-09-19T11:00:00.0000000Z",
      "clientState": "{secretClientState}"
    }
    

    订阅生命周期通知

    事件可能会中断订阅中的更改通知流。 生命周期通知告知你要采取哪些作来保持流不中断。 与资源更改通知不同,生命周期通知侧重于订阅的状态。

    若要了解详细信息,请参阅 减少缺少的订阅和更改通知

    验证通知的真实性

    在处理更改通知之前,请始终验证其真实性。 这可以防止应用使用来自第三方的虚假通知触发不正确的业务逻辑。

    对于基本通知,请使用 clientState 值验证它们,如 处理更改通知中所述。 对于丰富的通知,请执行其他验证步骤。

    更改通知中的验证令牌

    丰富通知包括 validationTokens 属性,该属性包含 JSON Web 令牌 数组 (JWT) 。 每个令牌对于应用和租户对是唯一的。 更改通知可能包含使用同一 notificationUrl 订阅的各种应用和租户的混合项。

    注意

    Microsoft Graph 不会为通过 Azure 事件中心 传递的更改通知发送验证令牌,因为订阅服务不需要验证事件中心的 notificationUrl

    在以下示例中,更改通知包含同一应用和两个不同租户的两个项目,因此 validationTokens 数组包含两个需要验证的令牌。

    {
        "value": [
            {
                "subscriptionId": "76619225-ff6b-4489-96ca-4ef547e78b22",
                "tenantId": "aaaabbbb-0000-cccc-1111-dddd2222eeee",
                "changeType": "created",
                ...
            },
            {
                "subscriptionId": "5cfe2387-163c-4006-81bb-1b5e1e060afe",
                "tenantId": "bbbbcccc-1111-dddd-2222-eeee3333ffff",
                "changeType": "created",
                ...
            }
        ],
        "validationTokens": [
            "eyJ0eXAiOiJKV1QiLCJhb...",
            "cGlkYWNyIjoiMiIsImlkc..."
        ]
    }
    

    更改通知对象位于 changeNotificationCollection 资源类型的结构中。

    如何验证

    使用 Microsoft身份验证库 (MSAL) 或第三方库来验证令牌。 请按照下列步骤操作:

    请注意以下原则:

    • 立即使用 HTTP 202 Accepted 状态代码响应通知。
    • 在验证更改通知之前进行响应,即使稍后验证失败。 收到更改通知后立即响应,无论你是将通知存储在队列中以便稍后进行处理,还是动态处理它们。
    • 接受和响应更改通知可防止不必要的传递重试,并隐藏潜在攻击者的验证结果。 收到无效的更改通知后,始终可以忽略它。

    具体而言,针对 validationTokens 集合中的各个 JWT 令牌进行验证。 如果任何令牌失败,请考虑更改通知可疑并进一步调查。

    按照以下步骤验证令牌以及生成令牌的应用:

    1. 验证令牌是否未过期。

    2. 验证Microsoft 标识平台是否颁发了令牌,并且令牌未被篡改。

      • 从公用配置终结点获取签名密钥:https://login.microsoftonline.com/common/.well-known/openid-configuration。 应用可以缓存此配置一段时间。 该配置会频繁更新,因为签名密钥是每天轮换的。
      • 使用这些密钥验证 JWT 令牌的签名。

      不接受任何其他机构颁发的令牌。

    3. 确认已为应用颁发令牌。

      下列步骤是 JWT 令牌库中标准验证逻辑的一部分,通常可作为单个函数调用执行。

      • 在与应用程序ID匹配的令牌中验证“受众”。
      • 如果有多个应用收到更改通知,请务必检查是否有多个 ID。
    4. 验证令牌的 azp 属性是否与 的预期值 0bf30f3b-4a52-48df-9a82-234910c4a086匹配,该值代表 Microsoft Graph 更改通知发布者。

    JWT 令牌示例

    以下示例显示了验证所需的 JWT 令牌中的属性。

    {
      // aud is your app's id
      "aud": "925bff9f-f6e2-4a69-b858-f71ea2b9b6d0",
      "iss": "https://login.microsoftonline.com/9f4ebab6-520d-49c0-85cc-7b25c78d4a93/v2.0",
      "iat": 1624649764,
      "nbf": 1624649764,
      "exp": 1624736464,
      "aio": "E2ZgYGjnuFglnX7mtjJzwR5lYaWvAA==",
      // azp represents the notification publisher and must always be the same value of 0bf30f3b-4a52-48df-9a82-234910c4a086
      "azp": "0bf30f3b-4a52-48df-9a82-234910c4a086",
      "azpacr": "2",
      "oid": "1e7d79fa-7893-4d50-bdde-164260d9c5ba",
      "rh": "0.AX0AtrpOnw1SwEmFzHslx41KkzsP8wtSSt9ImoIjSRDEoIZ9AAA.",
      "sub": "1e7d79fa-7893-4d50-bdde-164260d9c5ba",
      "tid": "9f4ebab6-520d-49c0-85cc-7b25c78d4a93",
      "uti": "mIB4QKCeZE6hK71XUHJ3AA",
      "ver": "2.0"
    }
    

    示例:对验证令牌进行验证

    // add Microsoft.IdentityModel.Protocols.OpenIdConnect and System.IdentityModel.Tokens.Jwt nuget packages to your project
    public async Task<bool> ValidateToken(string token, string tenantId, IEnumerable<string> appIds)
    {
        var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
            "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
            new OpenIdConnectConfigurationRetriever());
        var openIdConfig = await configurationManager.GetConfigurationAsync();
        var handler = new JwtSecurityTokenHandler();
        try
        {
        handler.ValidateToken(token, new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true,
            ValidIssuer = $"https://sts.windows.net/{tenantId}/",
            ValidAudiences = appIds,
            IssuerSigningKeys = openIdConfig.SigningKeys
        }, out _);
        return true;
        }
        catch (Exception ex)
        {
        Trace.TraceError($"{ex.Message}:{ex.StackTrace}");
        return false;
        }
    }
    

    解密更改通知资源数据

    更改通知中的 resourceData 属性包括资源实例的基本 ID 和类型信息。 encryptedData 属性具有完整的资源数据,Microsoft Graph 使用订阅中提供的公钥进行加密。 此属性还含有验证和解密所需的数值。 此加密是为了提高通过更改通知访问的客户数据的安全性。 保护私钥,确保第三方无法解密客户数据,即使他们拦截了原始更改通知。

    在本部分中,你将了解以下概念:

    管理加密密钥

    1. 获取包含一对非对称密钥的证书。

      • 可以使用自签名证书,因为 Microsoft Graph 不会验证证书颁发者,并且仅使用公钥进行加密。

      • 使用 Azure 密钥保管库创建、轮换和安全管理证书。 确保密钥符合下列条件:

        • 键的类型必须为 RSA
        • 密钥大小必须介于 2,048 位和 4,096 位之间。
    2. 以 Base64 编码的 X.509 格式导出证书 ,并仅包含公钥

    3. 创建订阅时:

      • 使用导出证书的 Base64 编码内容,在 encryptionCertificate 属性中提供证书。

      • encryptionCertificateId 属性中提供自己的标识符。

        此标识符能够将你的证书与接收的更改通知匹配,并从证书存储中检索证书。 标识符最长 128 个字符。

    4. 安全地管理私钥,以便更改通知处理代码可以访问私钥来解密资源数据。

    轮换密钥

    定期更改非对称密钥,以最大程度地降低私钥泄露的风险。 请按照以下步骤介绍一对新密钥:

    1. 使用新非对称密钥对获取新证书。 将其用于创建的所有新订阅。

    2. 使用新的证书密钥更新现有订阅。

      • 使此更新成为定期订阅续订的一部分。
      • 或者,枚举所有订阅并提供密钥。 使用订阅修补程序操作并更新encryptionCertificateencryptionCertificateId属性。
    3. 请记住以下原则:

      • 旧证书可能仍用于加密一段时间。 应用程序必须具有访问新旧证书的权限,以能够对内容进行解密。
      • 使用各更改通知中的 encryptionCertificateId 属性来确定要使用的正确密钥。
      • 仅当看不到引用旧证书的最近更改通知时,才放弃旧证书。

    解密资源数据

    为优化性能,Microsoft Graph 使用两步加密过程:

    • 它生成一个一次性对称密钥,并使用它来加密资源数据。
    • 它使用公共非对称密钥(订阅时提供)加密对称密钥,并将之包含在订阅的各更改通知中。

    假设更改通知中的每个项的对称密钥都不同。

    若要对资源数据进行解密,应用应使用各更改通知 encryptedContent 下的属性执行反向操作:

    1. 使用 encryptionCertificateId 属性标识正确的证书。

    2. 使用私钥初始化 RSA 加密组件。 初始化 RSA 组件的一种简单方法是将 RSACertificateExtensions.GetRSAPrivateKey (X509Certificate2) 方法X509Certificate2 实例结合使用,该实例包含 管理加密密钥中所述的私钥。

    3. 使用私钥解密更改通知中每个项的 dataKey 属性中的对称密钥。 使用最佳非对称加密填充 (OAEP) 作为解密算法。

    4. 使用对称密钥计算 数据中值的 HMAC-SHA256 签名。 将其与 dataSignature中的值进行比较。 如果不匹配,则假定有效负载被篡改,并且不要解密它。

    5. 使用具有高级加密的对称密钥解密数据 proeprty Standard (AES) ,例如 .NET Aes

      • 将以下解密参数用于 AES 算法:

        • 填充:PKCS7。
        • 密码模式:CBC。
      • 通过复制用于解密的对称密钥的前16个字节来设置 "初始化向量"。

    解密的数据将是表示资源的 JSON 字符串。

    示例:解密资源数据

    以下 JSON 示例显示了一个更改通知,其中包含通道消息中 chatMessage 实例的加密属性值。 值 @odata.id 指定 实例。

    {
      "value": [
        {
          "subscriptionId": "76222963-cc7b-42d2-882d-8aaa69cb2ba3",
          "changeType": "created",
          // Other properties typical in a resource change notification
          "resource": "teams('d29828b8-c04d-4e2a-b2f6-07da6982f0f0')/channels('19:f127a8c55ad949d1a238464d22f0f99e@thread.skype')/messages('1565045424600')/replies('1565047490246')",
          "resourceData": {
            "id": "1565293727947",
            "@odata.type": "#Microsoft.Graph.ChatMessage",
            "@odata.id": "teams('88cbc8fc-164b-44f0-b6a6-b59b4a1559d3')/channels('19:8d9da062ec7647d4bb1976126e788b47@thread.tacv2')/messages('1565293727947')/replies('1565293727947')"
          },
          "encryptedContent": {
            "data": "{encrypted data that produces a full resource}",
            "dataSignature": "<HMAC-SHA256 hash>",
            "dataKey": "{encrypted symmetric key from Microsoft Graph}",
            "encryptionCertificateId": "MySelfSignedCert/DDC9651A-D7BC-4D74-86BC-A8923584B0AB",
            "encryptionCertificateThumbprint": "07293748CC064953A3052FB978C735FB89E61C3D"
          }
        }
      ],
      "validationTokens": [
        "eyJ0eXAiOiJKV1QiLCJhbGciOiJSU..."
      ]
    }
    

    有关传递更改通知时发送的数据的完整说明,请参阅 changeNotificationCollection 资源类型

    解密对称密钥

    本节包含一些有用的代码片段,它们针对解密的各个阶段使用C# 和NET。

    // Initialize with the private key that matches the encryptionCertificateId.
    X509Certificate2 certificate = <instance of X509Certificate2 matching the encryptionCertificateId property>;
    RSA rsa = certificate.GetRSAPrivateKey();
    byte[] encryptedSymmetricKey = Convert.FromBase64String(<value from dataKey property>);
    
    // Decrypt using OAEP padding.
    byte[] decryptedSymmetricKey = rsa.Decrypt(encryptedSymmetricKey, RSAEncryptionPadding.OaepSHA1);
    
    // Can now use decryptedSymmetricKey with the AES algorithm.
    

    使用 HMAC-SHA256 比较数据签名

    byte[] decryptedSymmetricKey = <the aes key decrypted in the previous step>;
    byte[] encryptedPayload = <the value from the data property, still encrypted>;
    byte[] expectedSignature = <the value from the dataSignature property>;
    byte[] actualSignature;
    
    using (HMACSHA256 hmac = new HMACSHA256(decryptedSymmetricKey))
    {
        actualSignature = hmac.ComputeHash(encryptedPayload);
    }
    if (actualSignature.SequenceEqual(expectedSignature))
    {
        // Continue with decryption of the encryptedPayload.
    }
    else
    {
        // Do not attempt to decrypt encryptedPayload. Assume notification payload has been tampered with and investigate.
    }
    

    解密资源数据内容

    Aes aesProvider = Aes.Create();
    aesProvider.Key = decryptedSymmetricKey;
    aesProvider.Padding = PaddingMode.PKCS7;
    aesProvider.Mode = CipherMode.CBC;
    
    // Obtain the initialization vector from the symmetric key itself.
    int vectorSize = 16;
    byte[] iv = new byte[vectorSize];
    Array.Copy(decryptedSymmetricKey, iv, vectorSize);
    aesProvider.IV = iv;
    
    byte[] encryptedPayload = Convert.FromBase64String(<value from data property>);
    
    string decryptedResourceData;
    // Decrypt the resource data content.
    using (var decryptor = aesProvider.CreateDecryptor())
    {
      using (MemoryStream msDecrypt = new MemoryStream(encryptedPayload))
      {
          using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
          {
              using (StreamReader srDecrypt = new StreamReader(csDecrypt))
              {
                  decryptedResourceData = srDecrypt.ReadToEnd();
              }
          }
      }
    }
    
    // decryptedResourceData now contains a JSON string that represents the resource.