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

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

    通知负载中的资源数据

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

    • 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 更改通知的发布者。

      • 检查令牌中的 属性是否 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. 将对称密钥与高级加密标准 (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 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.