客户谈判

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

重要

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

"连接字符串包含应用程序访问 Azure SignalR 服务所需的授权信息。" connection string中的访问密钥类似于服务的根密码。 在生产环境中,请始终保护访问密钥。 使用 Azure Key Vault 来安全地管理和轮换您的密钥,并使用 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" ]
        }
      ]
    }
    

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

    • connectionIdLongPolling传输需要ServerSentEvents值以关联发送和接收。
    • 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 Service时,客户端将连接到服务而不是应用服务器。 在客户端与Azure SignalR Service之间建立持久连接有三个步骤:

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

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

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

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

重要

在自托管 SignalR 中,某些用户可能会在客户端仅支持 WebSocket 的情况下,选择跳过客户端协商以节省往返时间。 但是,在使用Azure SignalR Service时,客户端应始终要求受信任的服务器或受信任的身份验证中心生成访问令牌。 因此,不要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 保护您的连接字符串和授权访问。

// 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

可以通过使用 Management SDK 来进行协商。

可以使用 ServiceHubContext 实例生成 SignalR 客户端连接到 Azure SignalR 服务的端点 URL 和相关的访问令牌:

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();

可以找到有关如何使用管理 SDK 将 SignalR 客户端重定向到 GitHubAzure SignalR Service 的完整示例。

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 Service 输入绑定

自我暴露 /negotiate 终结点

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

使用 ConnectionString

下面是 JavaScript 中的伪代码,演示如何实现中心chat的协商终结点,并从 Azure SignalR connection string生成访问令牌。

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 中的伪码,演示如何实现 hubchat 的协商终结点,并使用 Microsoft Entra 身份认证和 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"));

后续步骤

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