客户端协商

客户端和服务器之间的第一个请求是协商请求。 使用自承载 SignalR 时,请使用请求在客户端和服务器之间建立连接。 使用 Azure SignalR 服务时,客户端将连接到该服务而不是应用程序服务器。 本文分享有关协商协议的概念和自定义协商终结点的方法。

重要

本文中出现的原始连接字符串仅用于演示目的。

连接字符串包括应用程序访问 Azure SignalR 服务所需的授权信息。 连接字符串中的访问密钥类似于服务的根密码。 在生产环境中,请始终保护访问密钥。 使用 Azure 密钥保管库安全地管理和轮换密钥,使用 Microsoft Entra ID 保护连接字符串,并使用 Microsoft Entra ID 授权访问

避免将访问密钥分发给其他用户、对其进行硬编码或将其以纯文本形式保存在其他人可以访问的任何位置。 如果你认为访问密钥可能已泄露,请轮换密钥。

什么是协商?

POST [endpoint-base]/negotiate请求的响应包含三种类型的响应之一:

  • 包含connectionId的响应,用于确定服务器上的连接以及服务器支持的传输列表:

    {
      "connectionToken":"05265228-1e2c-46c5-82a1-6a5bcc3f0143",
      "connectionId":"807809a5-31bf-470d-9e23-afaee35d8a0d",
      "negotiateVersion":1,
      "availableTransports":[
        {
          "transport": "WebSockets",
          "transferFormats": [ "Text", "Binary" ]
        },
        {
          "transport": "ServerSentEvents",
          "transferFormats": [ "Text" ]
        },
        {
          "transport": "LongPolling",
          "transferFormats": [ "Text", "Binary" ]
        }
      ]
    }
    

    此终结点返回的有效负载提供以下数据:

    • LongPollingServerSentEvents传输需要connectionId值以关联发送和接收。
    • negotiateVersion 值是在服务器与客户端之间使用的协商协议版本,请参阅传输协议
      • negotiateVersion: 0 仅返回 connectionId,客户端应使用 connectionId 的值作为连接请求中的 id
      • negotiateVersion: 1 返回 connectionIdconnectionToken,客户端应使用 connectionToken 的值作为连接请求中的 id
    • availableTransports列表描述服务器支持的传输。 对于每个传输,有效负载列出了传输名称 (transport),以及传输支持 (transferFormats) 的传输格式列表。
  • 重定向响应,告知客户端要用作结果的 URL 和(可选)访问令牌:

    {
      "url": "https://<Server endpoint>/<Hub name>",
      "accessToken": "<accessToken>"
    }
    

    此终结点返回的有效负载提供以下数据:

    • url值是客户端应连接到的 URL。
    • accessToken值是用于访问指定 URL 的可选持有者令牌。
  • 包含应停止连接尝试的error条目的响应:

    {
      "error": "This connection is not allowed."
    }
    

    此终结点返回的有效负载提供以下数据:

    • error字符串提供有关协商失败原因的详细信息。

使用 Azure SignalR 服务时,客户端将连接到该服务而不是应用服务器。 在客户端与 Azure SignalR 服务之间建立持久性连接需要执行三个步骤:

  1. 客户端向应用服务器发送协商请求。

  2. 应用服务器使用 Azure SignalR 服务 SDK 返回包含 Azure SignalR 服务 URL 和访问令牌的重定向响应。

    对于 ASP.NET Core SignalR,典型的重定向响应如以下示例所示:

    {
        "url":"https://<SignalR name>.service.signalr.azure.cn/client/?hub=<Hub name>&...",
        "accessToken":"<accessToken>"
    }
    
  3. 客户端在收到重定向响应后,它将使用 URL 和访问令牌连接到 SignalR 服务。 然后,服务将客户端路由到应用服务器。

重要

在自承载 SignalR 中,当客户端仅支持 WebSocket 并保存往返进行协商时,一些用户可能会选择跳过客户端协商。 但是,使用 Azure SignalR 服务时,客户端应始终要求受信任的服务器或受信任的身份验证中心生成访问令牌。 因此,不要SkipNegotiation设置为客户端上的trueSkipNegotiation意味着客户端需要自行生成访问令牌。 此设置会带来客户端可能对服务终结点执行任何操作的安全风险。

在谈判期间,可以做什么?

客户端连接的自定义设置

可以限制客户端连接,以自定义安全或业务需求的设置。 例如:

  • 使用简短AccessTokenLifetime值以保护安全性。
  • 仅传递来自客户端声明的必要信息。
  • 为业务需求添加自定义声明。
services.AddSignalR().AddAzureSignalR(options =>
    {
        //  Pass only necessary information in the negotiation step
        options.ClaimsProvider = context => new[]
        {
            new Claim(ClaimTypes.NameIdentifier, context.Request.Query["username"]),
            new Claim("<Custom Claim Name>", "<Custom Claim Value>")
        };
        options.AccessTokenLifetime = TimeSpan.FromMinutes(5);
    });

服务器粘性

如果有多个应用服务器,则不能保证执行协商的服务器和获取中心调用的服务器相同(默认情况下)。 在一些情况下,你可能希望在应用服务器上本地维护客户端状态信息。

例如,使用服务器端 Blazor 时,UI 状态会保留在服务器端。 因此,你希望所有客户端请求都转到同一服务器,包括 SignalR 连接。 然后,需要在协商期间启用服务器粘滞模式以Required

services.AddSignalR().AddAzureSignalR(options => {
    options.ServerStickyMode = ServerStickyMode.Required;
});

多个终结点中的自定义路由

自定义协商的另一种方法是在多个终结点中。 由于应用服务器提供服务 URL 作为协商响应,因此应用服务器可以确定要返回到客户端的终结点,以提高负载均衡和通信效率。 也就是说,可以让客户端连接到最近的服务终结点,以节省流量成本。

// Sample of a custom router
private class CustomRouter : EndpointRouterDecorator
{    
    public override ServiceEndpoint GetNegotiateEndpoint(HttpContext context, IEnumerable<ServiceEndpoint> endpoints)
    {
        // Override the negotiation behavior to get the endpoint from the query string
        var endpointName = context.Request.Query["endpoint"];
        if (endpointName.Count == 0)
        {
            context.Response.StatusCode = 400;
            var response = Encoding.UTF8.GetBytes("Invalid request");
            context.Response.Body.Write(response, 0, response.Length);
            return null;
        }

        return endpoints.FirstOrDefault(s => s.Name == endpointName && s.Online) // Get the endpoint with the name that matches the incoming request
               ?? base.GetNegotiateEndpoint(context, endpoints); // Fall back to the default behavior to randomly select one from primary endpoints, or fall back to secondary when no primary ones are online
    }
}

此外,将路由器注册到依赖项注入。

本文中出现的原始连接字符串仅用于演示目的。 在生产环境中,请始终保护访问密钥。 使用 Azure Key Vault 安全地管理和轮换密钥,使用 Microsoft Entra ID 保护连接字符串,并使用 Microsoft Entra ID 授权访问

// Sample of configuring multiple endpoints and dependency injection
services.AddSingleton(typeof(IEndpointRouter), typeof(CustomRouter));
services.AddSignalR().AddAzureSignalR(
    options => 
    {
        options.Endpoints = new ServiceEndpoint[]
        {
            new ServiceEndpoint(name: "east", connectionString: "<connectionString1>"),
            new ServiceEndpoint(name: "west", connectionString: "<connectionString2>"),
            new ServiceEndpoint("<connectionString3>")
        };
    });

如何在无服务器模式下添加客户端协商终结点?

在无服务器 (Serverless) 模式下,服务器不接受 SignalR 客户端。 为了帮助保护连接字符串,需要将 SignalR 客户端从协商终结点重定向到 Azure SignalR 服务,而不是向所有 SignalR 客户端提供连接字符串。

最佳做法是托管协商终结点。 然后,可以将 SignalR 客户端用于此终结点,并提取服务 URL 和访问令牌。

Azure SignalR 服务管理 SDK

可以使用管理 SDK进行协商。

可以使用ServiceHubContext实例为 SignalR 客户端生成终结点 URL 和相应的访问令牌,从而连接到 Azure SignalR 服务:

var negotiationResponse = await serviceHubContext.NegotiateAsync(new (){ UserId = "<Your User Id>" });

假设中心终结点为http://<Your Host Name>/<Your Hub Name>。 然后,协商终结点为http://<Your Host Name>/<Your Hub Name>/negotiate。 托管协商终结点后,可以使用 SignalR 客户端连接到中心:

var connection = new HubConnectionBuilder().WithUrl("http://<Your Host Name>/<Your Hub Name>").Build();
await connection.StartAsync();

可以在GitHub上找到有关如何使用管理 SDK 将 SignalR 客户端重定向到的 Azure SignalR 服务的完整示例。

Azure SignalR 服务函数扩展

使用 Azure 函数应用时,可以使用函数扩展。 下面是在 C# 隔离辅助角色模型中使用 SignalRConnectionInfo 帮助生成协商响应的示例:

[Function(nameof(Negotiate))]
public static string Negotiate([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequestData req,
    [SignalRConnectionInfoInput(HubName = "serverless")] string connectionInfo)
{
    // The serialization of the connection info object is done by the framework. It should be camel case. The SignalR client respects the camel case response only.
    return connectionInfo;
}

警告

为简单起见,我们省略了此示例中的身份验证和授权部分。 因此,无需任何限制即可公开访问此终结点。 为了确保协商终结点的安全性,应根据特定要求实施适当的身份验证和授权机制。 有关保护 HTTP 终结点的指导,请参阅以下文章:

然后,客户端可以请求函数终结点https://<Your Function App Name>.chinacloudsites.cn/api/negotiate以获取服务 URL 和访问令牌。 可以在GitHub上找到完整的示例。

有关其他语言的 SignalRConnectionInfo 输入绑定示例,请参阅 Azure Functions SignalR 服务输入绑定

自行公开 /negotiate 终结点

如果使用其他语言,则还可以在自己的服务器中公开协商终结点并自行返回协商响应。

使用 ConnectionString

下面是 JavaScript 中的伪代码,展示了如何实现集线器 chat 的协商终结点并从 Azure SignalR 连接字符串生成访问令牌。

import express from 'express';
const connectionString = '<your-connection-string>';
const hub = 'chat';
let app = express();
app.post('/chat/negotiate', (req, res) => {
  let endpoint = /Endpoint=(.*?);/.exec(connectionString)[1];
  let accessKey = /AccessKey=(.*?);/.exec(connectionString)[1];
  let url = `${endpoint}/client/?hub=${hub}`;
  let token = jwt.sign({ aud: url }, accessKey, { expiresIn: 3600 });
  res.json({ url: url, accessToken: token });
});
app.listen(8080, () => console.log('server started'));

然后,JavaScript SignalR 客户端与 URL /chat 连接:

let connection = new signalR.HubConnectionBuilder().withUrl('/chat').build();
connection.start();

使用 Microsoft Entra ID

Azure SignalR 还提供 REST API POST /api/hubs/${hub}/:generateToken?api-version=2022-11-01&userId=${userId}&minutesToExpire=${minutesToExpire},可以在你使用 Microsoft Entra ID 时为你生成客户端访问令牌。

步骤如下:

  1. 按照添加角色分配中的说明将角色 SignalR REST API OwnerSignalR Service Owner 分配给标识,使标识有权调用 REST API 来生成客户端访问令牌。
  2. 使用 Azure 标识客户端库提取范围为 https://signalr.azure.cn/.default 的 Microsoft Entra ID 令牌
  3. 使用此令牌访问生成令牌 REST API
  4. 在协商响应中返回客户端访问令牌。

下面是 JavaScript 中的伪代码,显示如何实现集线器 chat 的协商终结点并使用 Microsoft Entra ID 和 REST API /generateToken 获取访问令牌。

import express from "express";
import axios from "axios";
import { DefaultAzureCredential } from "@azure/identity";

const endpoint = "https://<your-service>.signalr.azure.cn";
const hub = "chat";
const generateTokenUrl = `${endpoint}/api/hubs/${hub}/:generateToken?api-version=2022-11-01`;
let app = express();
app.get("/chat/negotiate", async (req, res) => {
  // use DefaultAzureCredential to get the Entra ID token to call the Azure SignalR REST API
  const credential = new DefaultAzureCredential();
  const entraIdToken = await credential.getToken("https://signalr.azure.cn/.default");
  const token = (
    await axios.post(generateTokenUrl, undefined, {
      headers: {
        "content-type": "application/json",
        Authorization: `Bearer ${entraIdToken.token}`,
      },
    })
  ).data.token;
  let url = `${endpoint}/client/?hub=${hub}`;
  res.json({ url: url, accessToken: token });
});
app.listen(8080, () => console.log("server started"));

后续步骤

要详细了解如何使用默认模式和无服务器模式,请参阅以下文章: