设置包含资源数据 (丰富通知的更改通知)

Microsoft Graph 允许应用订阅和接收它们感兴趣的资源的更改通知。 虽然可以订阅基本更改通知,但Microsoft Teams 聊天消息和状态资源等资源支持丰富的通知。

丰富的通知 包括已更改的资源数据,使应用能够运行业务逻辑,而无需进行单独的 API 调用来提取更改的资源。 本文指导你完成在应用程序中设置丰富通知的过程。

支持的资源

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

注意

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

资源 支持的资源路径 限制
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



    对特定聊天中成员身份的更改: /chats/{id}/members
    -
    Teams onlineMeeting * 对联机会议的更改: /communications/onlineMeetings/?$filter=JoinWebUrl eq '{joinWebUrl} * 不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。
    Teams 状态 对单个用户状态的更改: /communications/presences/{id} 不支持使用 $select 仅返回所选属性。 丰富通知包含已更改实例的所有属性。
    Teams 团队 对租户中任何团队的更改: /teams

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

    通知负载中的资源数据

    丰富通知在有效负载中包含以下资源数据:

    • resourceData属性中返回的已更改资源实例的ID和类型。
    • 按照订阅中规定内容加密、在 encryptedContent 属性中返回的资源实例的所有属性值。
    • 或者,具体取决于资源、resourceData属性中返回的特定属性。 若要仅获取特定属性,请使用 $select 参数,将其指定为订阅中的资源URL 的一部分。

    创建订阅

    丰富通知的设置方式与 基本更改通知相同,但 必须 指定以下附加属性:

    • includeResourceData,应设置为 true 以明确请求资源数据。
    • encryptionCertificate ,它仅包含 graph Microsoft用于加密返回到应用的资源数据的公钥。 为了安全起想,Microsoft Graph 会加密在丰富通知中返回的资源数据。 在创建订阅过程中,必须提供公共加密密钥。 有关创建和管理加密密钥的详细信息,请参阅 解密更改通知中的资源数据
    • encryptionCertificateId,是证书的自有标识符。 使用此 ID 在各更改通知中匹配用于解密的证书。

    还必须按照通知终结点验证中所述验证这两个 终结点。 如果选择对两个终结点使用相同的 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": "{custom ID}",
      "expirationDateTime": "2019-09-19T11:00:00.0000000Z",
      "clientState": "{secret client state}"
    }
    

    订阅生命周期通知

    某些事件可能会干扰现有订阅中的更改通知流。 订阅生命周期通知将通知你要采取的操作,以保持流不中断。 与通知资源实例更改的资源更改通知不同,生命周期通知是关于订阅本身及其在生命周期中的当前状态。

    有关如何接收和响应生命周期通知的详细信息,请参阅 减少缺少的订阅和更改通知

    验证通知的真实性

    在基于更改通知中包含的资源数据运行业务逻辑之前,必须先验证每个更改通知的真实性。 否则,第三方可能会通过虚假更改通知欺骗你的应用,并使其运行业务逻辑不正确,这可能会导致安全事件。

    对于不包含资源数据的基本更改通知,只需根据处理更改通知中所述的 clientState 值对其进行验证。 此验证是可以接受的,因为你可以进行后续的受信任Microsoft Graph 调用来访问资源数据,因此任何欺骗尝试的影响是有限的。

    对于丰富通知,请在处理数据之前执行更彻底的验证。

    在本部分中,将探讨以下验证概念:

    更改通知中的验证令牌

    包含资源数据的更改通知包含一个附加属性 validationTokens,其中包含由 Microsoft Graph 生成的 JSON Web 令牌 (JWT) 数组。 Microsoft Graph 为每个不同的应用和租户对生成单个令牌, 值数组中有 一个项。 请记住,更改通知可能包含使用同一 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. 关键:验证生成令牌的应用程序是否代表着 Microsoft Graph 更改通知的发布者。

      • 在与 0bf30f3b-4a52-48df-9a82-234910c4a086期望值匹配的令牌中检查 appid 属性。
      • 这可确保更改通知不会由不Microsoft Graph 的其他应用发送。

    JWT 令牌示例

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

    {
      // aud is your app's id 
      "aud": "8e460676-ae3f-4b1e-8790-ee0fb5d6148f",                           
      "iss": "https://sts.windows.net/84bd8158-6d4d-4958-8b9f-9d6445542f95/",
      "iat": 1565046813,
      "nbf": 1565046813,
      // Expiration date 
      "exp": 1565075913,                                                        
      "aio": "42FgYKhZ+uOZrHa7p+7tfruauq1HAA==",
      // appid represents the notification publisher and must always be the same value of 0bf30f3b-4a52-48df-9a82-234910c4a086 
      "appid": "0bf30f3b-4a52-48df-9a82-234910c4a086",                          
      "appidacr": "2",
      "idp": "https://sts.windows.net/84bd8158-6d4d-4958-8b9f-9d6445542f95/",
      "tid": "84bd8158-6d4d-4958-8b9f-9d6445542f95",
      "uti": "-KoJHevhgEGnN4kwuixpAA",
      "ver": "1.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 Key Vault 创建、轮换和安全管理证书。 确保密钥符合下列条件:

        • 键的类型必须为 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. 将对称密钥与高级加密标准 (AES) ((例如 .NET Aes) )配合使用来解密 数据中的内容。

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

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

    6. 解密值是一个 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, fOAEP: true);
    
    // 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 intialization 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.