ASP.NET Core SignalR 中的身份验证和授权

本文介绍如何使用 SignalR 在 ASP.NET Core 应用程序中对用户进行身份验证和授权。

对连接到 SignalR 中心的用户进行身份验证

SignalR 可与 ASP.NET Core 身份验证配合使用,将用户与每个连接关联。 在中心,可以从 HubConnectionContext.User 属性访问身份验证数据。 身份验证允许中心对与用户关联的所有连接调用方法。 有关详细信息,请参阅在 SignalR 中管理用户和组。 多个连接可以与单个用户相关联。

以下代码是使用 SignalR 和 ASP.NET Core 身份验证的示例:

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SignalRAuthenticationSample.Data;
using SignalRAuthenticationSample.Hubs;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
app.MapHub<ChatHub>("/chat");

app.Run();

Note

如果令牌在连接生存期内过期,默认情况下连接会继续工作。 如果后续请求不发送新的访问令牌,LongPolling 和 ServerSentEvents 连接将失败。 若要在身份验证令牌过期时关闭连接,请设置 CloseOnAuthenticationExpiration 选项。

连接生存期内的用户和角色更改

SignalR 在建立连接时捕获经过身份验证的用户,并在连接的生存期内缓存该用户。 缓存主体通过 Context.User 属性向中心方法公开,HubCallerContext.User用于授权中心方法调用。 SignalR 无论身份验证方案如何,都不会在连接期间自动重新验证用户。 此行为适用于所有方案,包括 cookie 身份验证和持有者令牌身份验证。

对建立连接后发生的用户的标识、角色或声明的更改不会反映在现有连接上。 即使基础传输发出新的 HTTP 请求(例如 LongPolling 和 ServerSentEvents 传输),此行为也适用。 尽管 ASP.NET Core身份验证中间件会重新验证其中每个 HTTP 请求,但SignalR继续使用建立连接时缓存的主体。

请考虑以下情况:

  • 应用使用 cookie 身份验证和 LongPolling 传输。 相同的行为适用于持有者令牌身份验证和 ServerSentEvents 传输。
  • 用户以 Editor 角色登录,并具有打开的 SignalR 连接。
  • 该应用会移除该用户的 Editor 角色。

在下一个长时间轮询请求中,身份验证中间件可能会对没有 Editor 角色的用户进行身份验证。 例如,如果角色是从数据存储中加载的,或者 cookie 被刷新,就可能会发生这种情况。 但是,Hub 仍会授权用户对 [Authorize(Roles = "Editor")] Hub 方法的调用,因为 Context.User(HubCallerContext.User)仍持有在角色被移除之前缓存的主体。 在连接关闭之前,用户可以继续调用仅限 Editor 的 Hub 方法。

若要对活动连接强制实施更新的授权,请采用以下方法之一:

  • 关闭受影响的连接,使客户端重新连接并重新进行身份验证。 对于持有者令牌身份验证, CloseOnAuthenticationExpiration 选项在身份验证令牌过期时关闭连接。
  • 在中心方法中对当前数据(如用户的当前角色或数据存储中的声明)执行授权检查,而不是仅依赖缓存的主体。

在 .NET 11 及更高版本中,客户端可以在不重新连接的情况下刷新活动连接的凭据。 当客户端提供更新后的令牌时,服务器会重新对其进行身份验证,并就地替换缓存的 Context.User,因此后续的 Hub 方法调用会根据更新后的角色和声明进行授权。 刷新主体必须映射到同一 SignalR 用户,因此刷新会更新角色和声明,但不会更改连接的用户标识或路由。 有关详细信息,请参阅 身份验证刷新。

在基于浏览器的应用中,cookie 身份验证允许现有用户凭据自动流向 SignalR 连接。 使用浏览器客户端时,无需额外配置。 如果用户登录到应用,则 SignalR 连接会自动继承此身份验证。

Cookies 是发送访问令牌的特定于浏览器的方法,但非浏览器客户端可以发送它们。 使用 .NET 客户端时,可以在 Cookies 调用中配置 .WithUrl 属性,以提供 cookie。 但是,从 .NET 客户端使用 cookie 身份验证时,应用必须提供 API 来交换 cookie 的身份验证数据。

重要

从 ASP.NET Core 10 开始,启用 cookie 身份验证时,已知的 API 终结点将不再重定向到登录页面。 而是返回 401/403 状态代码。 有关详细信息,请参阅 ASP.NET Core 中的 API 终结点身份验证行为。

Bearer 令牌身份验证

客户端可以提供访问令牌,而不是使用 cookie。 服务器验证令牌并使用它来标识用户。 对于发出多个 HTTP 请求的传输(例如 LongPolling ,和 ServerSentEvents),身份验证在每个请求上运行,但 SignalR 会在连接的生存期内缓存生成的主体。 与 cookie 身份验证一样,SignalR 不会在连接的整个生命周期内自动重新验证用户,以检查令牌是否已被吊销,或用户的角色或声明是否已发生变化。 有关详细信息,请参阅 连接生存期内的用户和角色更改。

在 JavaScript 客户端中,可以使用 accessTokenFactory 选项提供令牌。

// Connect, using the token we got.
this.connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", { accessTokenFactory: () => this.loginToken })
    .build();

在 .NET 客户端中,有一个类似的 AccessTokenProvider 属性可用于配置令牌:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chathub", options =>
    { 
        options.AccessTokenProvider = () => Task.FromResult(_myAccessToken);
    })
    .Build();

Note

访问令牌函数会在由发出的SignalR HTTP 请求之前调用。 如果需要续订令牌才能使连接保持活动状态,请从此函数中执行续订并返回更新的令牌。 令牌可能需要续订,以便在连接期间不会过期。

在标准 Web API 中,持有者令牌在 HTTP 标头中发送。 但是,浏览器不能在 WebSocket 和 Server-Sent 事件 API 上设置自定义标头,例如 Authorization。 由于浏览器的这一限制,当客户端在浏览器中运行时,SignalR 会针对这些传输方式将令牌作为查询字符串参数发送。

查询字符串回退是浏览器 API 的约束,而不是 SignalR 或 WebSocket 协议的限制。 非浏览器客户端(如 .NET 客户端)会在 Authorization 标头中发送令牌。 反向代理(例如位于 backend-for-frontend (BFF) 前面的 YARP)也可以向经代理的 WebSocket 请求添加 Authorization 标头,这样后端便可使用该标头而不是查询字符串进行身份验证。

内置JWT身份验证

在服务器上,持有者令牌身份验证是使用 JSON Web 令牌 (JWT) Bearer 中间件配置的:

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using SignalRAuthenticationSample.Data;
using SignalRAuthenticationSample.Hubs;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using SignalRAuthenticationSample;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddAuthentication(options =>
{
    // Identity made Cookie authentication the default.
    // However, we want JWT Bearer Auth to be the default.
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
  {
      // Configure the Authority to the expected value for
      // the authentication provider. This ensures the token
      // is appropriately validated.
      options.Authority = "Authority URL"; // TODO: Update URL

      // We have to hook the OnMessageReceived event in order to
      // allow the JWT authentication handler to read the access
      // token from the query string when a WebSocket or 
      // Server-Sent Events request comes in.

      // Sending the access token in the query string is required when using WebSockets or ServerSentEvents
      // due to a limitation in Browser APIs. We restrict it to only calls to the
      // SignalR hub in this code.
      // See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
      // for more information about security considerations when using
      // the query string to transmit the access token.
      options.Events = new JwtBearerEvents
      {
          OnMessageReceived = context =>
          {
              var accessToken = context.Request.Query["access_token"];

              // If the request is for our hub...
              var path = context.HttpContext.Request.Path;
              if (!string.IsNullOrEmpty(accessToken) &&
                  (path.StartsWithSegments("/hubs/chat")))
              {
                  // Read the token out of the query string
                  context.Token = accessToken;
              }
              return Task.CompletedTask;
          }
      };
  });

builder.Services.AddRazorPages();
builder.Services.AddSignalR();

// Change to use Name as the user identifier for SignalR
// WARNING: This requires that the source of your JWT token 
// ensures that the Name claim is unique!
// If the Name claim isn't unique, users could receive messages 
// intended for a different user!
builder.Services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

// Change to use email as the user identifier for SignalR
// builder.Services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

// WARNING: use *either* the NameUserIdProvider *or* the 
// EmailBasedUserIdProvider, but do not use both. 

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
app.MapHub<ChatHub>("/chatHub");

app.Run();

Note

由于存在浏览器 API 限制,当连接 WebSocket 和服务器发送的事件时,将在浏览器上使用查询字符串。 使用 HTTPS 时,TLS 连接会保护查询字符串值。 但是,许多服务器会记录查询字符串值。 有关详细信息,请参阅 ASP.NET Core SignalR 中的安全注意事项。 SignalR 使用标头在支持它们的环境中传输令牌,例如 .NET 和 Java 客户端。

Identity 服务器 JWT 身份验证

使用 Duende IdentityServer 时,向项目添加 PostConfigureOptions<TOptions> 服务:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
public class ConfigureJwtBearerOptions : IPostConfigureOptions<JwtBearerOptions>
{
    public void PostConfigure(string name, JwtBearerOptions options)
    {
        var originalOnMessageReceived = options.Events.OnMessageReceived;
        options.Events.OnMessageReceived = async context =>
        {
            await originalOnMessageReceived(context);

            if (string.IsNullOrEmpty(context.Token))
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
            }
        };
    }
}

在添加用于身份验证的服务(使用 AddAuthentication 方法)和服务器的身份验证处理程序 Identity (使用 AddIdentityServerJwt 方法)后注册服务:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SignalRAuthenticationSample.Hubs;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication()
    .AddIdentityServerJwt();
builder.Services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>,
        ConfigureJwtBearerOptions>());

builder.Services.AddRazorPages();

var app = builder.Build();

// Code removed for brevity.

身份验证刷新

SignalR连接的存续时间可能长于用于建立该连接的访问令牌。 启用 CloseOnAuthenticationExpiration 后,服务器会在令牌过期后关闭连接,客户端必须重新连接才能继续。 在间隙期间发送的消息丢失,组和用户路由会中断,直到客户端重新连接。

身份验证刷新可在 .NET 11 及更高版本中使用,允许客户端在不重新连接的情况下更新活动连接的凭据。 只要刷新后的主体映射到同一ClaimsPrincipal用户,服务器就会通过正常的端点授权管道重新验证刷新请求,并原地替换该连接的SignalR。

在服务器上启用身份验证刷新

在中心的 选项中,通过将 设置为 来启用身份验证刷新。 将它与 CloseOnAuthenticationExpiration 一起启用,以便在连接的令牌过期且未能及时刷新时关闭该连接,而不是让其继续以过期凭据保持开启状态:

app.MapHub<ChatHub>("/chat", options =>
{
    options.CloseOnAuthenticationExpiration = true;
    options.EnableAuthenticationRefresh = true;
});

启用身份验证刷新并且身份验证票证过期时,协商响应会报告剩余的令牌生存期,以便客户端可以计划刷新。

将 EnableAuthenticationRefresh 设置为 true 会向应用的路由表添加一个 /refresh 终结点,并与 hub 现有的 /negotiate 终结点并存。 对于映射到 /chat 的中心,终结点为 /chat/refresh。 当 EnableAuthenticationRefresh 为 false 时,不会注册该终结点,因此它只会出现在选择启用的中心的路由表中。

终结点的行为如下所示:

  • 它仅接受 POST 。 任何其他 HTTP 方法返回 405 状态代码。
  • 客户端使用查询字符串中的 id 连接令牌标识其连接,这与发送、轮询和删除请求的方式相同。
  • 请求在到达终结点之前由应用的常规身份验证和授权中间件进行身份验证,中心授权元数据将应用于该请求。
  • 成功的刷新返回包含 JSON 正文的 200 状态代码。 当刷新的票证具有过期时间时,正文中会包含一个 tokenLifetimeSeconds 属性,客户端使用该属性重新安排自动刷新。
  • 被拒绝或无效的刷新请求会返回一个包含 error 属性的 JSON 正文。 状态代码指示原因:
    • 403:连接的身份验证或 OnAuthenticationRefresh 拒绝了刷新请求。
    • 404:连接令牌与已知连接不匹配。
    • 400:请求中缺少连接令牌、连接协商的协议版本为 0,或者连接的当前用户或刷新后的主体是 Windows 标识。

在应用前端配置反向代理、防火墙或 Web 应用程序防火墙时,以及在编写基于路由范围的授权或 CORS 策略时,请包含 /refresh 路由。 转发 /negotiate 但阻止 /refresh 的代理会导致每次刷新尝试都失败;如果启用了 CloseOnAuthenticationExpiration,连接会在其令牌过期时关闭。

若要检查或拒绝刷新,请设置 OnAuthenticationRefresh 回调。 它在刷新请求经过身份验证后运行,但在替换连接的用户之前运行。 返回 false 以拒绝刷新,在这种情况下,终结点使用 HTTP 403 状态代码进行响应,并且连接会保留其当前用户。 该回调是在内置验证基础上的额外检查,用于确认刷新后的主体映射到同一 SignalR 用户。 它可以拒绝刷新,但无法批准未通过内置检查的刷新:

app.MapHub<ChatHub>("/chat", options =>
{
    options.CloseOnAuthenticationExpiration = true;
    options.EnableAuthenticationRefresh = true;
    options.OnAuthenticationRefresh = context =>
    {
        if (!context.NewUser.HasClaim("tenant", "contoso"))
        {
            return Task.FromResult(false);
        }

        return Task.FromResult(true);
    };
});

该回调接收一个具有以下属性的 AuthenticationRefreshContext:

  • PreviousUser:当前与该连接关联的 ClaimsPrincipal。 将其与 NewUser 进行比较以检测声明更改,例如租户或范围的更改。
  • NewUser:通过对刷新请求进行身份验证所生成的主体。 仅当回调返回true且内置标识检查通过时,此主体才会替换PreviousUser。
  • NewExpiration:如果接受了刷新,则为刷新后的身份验证票证采用的过期时间;如果该票证未设置过期时间,则为 null。 MaximumAuthenticationExpiration 会在回调运行之前应用。
  • ConnectionId:正在刷新的连接的 ID。
  • HttpContext:刷新请求的HttpContext。

该回调抛出的异常不会被 SignalR 捕获。 异常从刷新请求中传播出来,连接会保留其当前用户。

刷新的主体必须映射到与该连接相同的 SignalR 用户。 如果它映射到其他用户 ID,则会拒绝刷新:终结点使用 HTTP 403 状态代码进行响应,连接会保留其当前用户并保持连接状态。 即使刷新成功,也不会更改连接的 Context.UserIdentifier,或对通过 Clients.User 发送的消息重新进行路由。 连接启动时,路由标识符是固定的。 若要更改它,请重新连接客户端。

若要限制刷新操作可将连接的身份验证到期时间延长多久,请设置 MaximumAuthenticationExpiration。 即使令牌报告的有效期更长,刷新后的过期时间从当前时间起也最长不超过此时长。 只要启用了身份验证刷新功能,此上限均适用,包括令牌本身未设置过期时间的情况。 在这种情况下,该上限设置会使连接具有明确的过期时间,因此协商响应会返回令牌有效期,客户端即可安排自动刷新。 该值必须大于零,并且不适用于 Windows 身份验证,因为后者从不被跟踪或刷新。

从 .NET 客户端刷新身份验证

.NET 客户端使用连接上配置的 AccessTokenProvider 刷新凭据。 每次刷新调用 AccessTokenProvider 以提取新的访问令牌,而不是在连接启动时重用缓存的令牌。

若要显式刷新,请调用 RefreshAuthenticationAsync,这将返回服务器报告的新令牌生存期:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chat", options =>
    {
        options.AccessTokenProvider = GetAccessTokenAsync;
    })
    .Build();

await connection.StartAsync();

TimeSpan? newLifetime = await connection.RefreshAuthenticationAsync();

默认情况下启用自动刷新。 每当服务器报告令牌有效期时,客户端都会在所报告的过期时间之前安排刷新。 若要更改计时或关闭自动刷新,请调用 WithAuthenticationRefresh 并配置 AuthenticationRefreshOptions:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chat", options =>
    {
        options.AccessTokenProvider = GetAccessTokenAsync;
    })
    .WithAuthenticationRefresh(options =>
    {
        options.RefreshBeforeExpiration = TimeSpan.FromMinutes(2);
    })
    .Build();

AuthenticationRefreshOptions 提供以下设置:

  • EnableAutoRefresh:在令牌过期之前启用自动刷新。 默认值为 true. 客户端仅在服务器报告令牌生存期时计划刷新。 如果服务器未报告生存期,则不会安排自动刷新,并且仍可手动调用 RefreshAuthenticationAsync。
  • RefreshBeforeExpiration:应在所报告的到期时间前提前多久刷新。 默认为 5 分钟。

若要监视刷新,请处理连接上的 AuthenticationRefreshed 和 AuthenticationRefreshFailed 事件。 自动刷新和手动刷新都引发以下事件:

connection.AuthenticationRefreshed += context =>
{
    Console.WriteLine(
        $"Authentication refreshed. New lifetime: {context.NewTokenLifetime}");
    return Task.CompletedTask;
};

connection.AuthenticationRefreshFailed += context =>
{
    Console.WriteLine(
        $"Authentication refresh failed: {context.Exception}");
    return Task.CompletedTask;
};

从 JavaScript 客户端刷新身份验证

JavaScript 客户端使用连接上配置的 accessTokenFactory 刷新凭据。 每次刷新调用 accessTokenFactory 以获取新的访问令牌。

若要显式刷新,请调用 refreshAuthentication,该调用会解析为服务器报告的新令牌有效期(以秒为单位):

const newLifetimeInSeconds = await connection.refreshAuthentication();

若要在令牌过期之前自动刷新,请调用 withAuthenticationRefresh 并选择配置 IAuthenticationRefreshOptions。 使用 onAuthenticationRefreshed 和 onAuthenticationRefreshFailed 处理刷新结果:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chat", {
        accessTokenFactory: () => getAccessToken()
    })
    .withAuthenticationRefresh({
        refreshBeforeExpirationInMilliseconds: 120000
    })
    .build();

connection.onAuthenticationRefreshed(context => {
    console.log(
        `Authentication refreshed. New lifetime: ${context.newTokenLifetimeInSeconds}`);
});

connection.onAuthenticationRefreshFailed(context => {
    console.log(`Authentication refresh failed: ${context.error}`);
});

IAuthenticationRefreshOptions 提供以下设置:

  • enableAutoRefresh:在令牌过期之前启用自动刷新。 默认值为 true. 与.NET客户端一样,仅当服务器报告令牌生存期时,才会计划自动刷新。
  • refreshBeforeExpirationInMilliseconds:在报告的过期时间之前刷新的距离(以毫秒为单位)。 默认值为 300,000 (5 分钟)。

传递给 onAuthenticationRefreshed 的上下文提供:

  • connection:已刷新的 HubConnection。
  • newTokenLifetimeInSeconds:服务器报告的新令牌生存期;如果服务器未报告,则为 undefined。
  • refreshedAt:刷新完成的时间。

传递给 onAuthenticationRefreshFailed 的上下文提供了 connection 和 error。 自动刷新和手动刷新都会调用这些处理程序,而对 refreshAuthentication 的调用失败时,也会因同样的错误而被拒绝。

当服务器响应除 200 以外的任何状态代码时,刷新将失败。 这包括在刷新的主体映射到其他 SignalR 用户时,或在 OnAuthenticationRefresh 拒绝刷新时返回的 HTTP 403 状态代码,这属于策略性决定,而非暂时性错误。 失败的刷新不会自行关闭连接,也不会自动重试。 连接将继续使用其现有凭据,直到令牌过期;如果启用了 CloseOnAuthenticationExpiration,连接随后将被关闭。 处理 onAuthenticationRefreshFailed 以提示用户再次登录,并在应用获取新凭据后调用 refreshAuthentication 。

响应中心内的刷新操作

替代中心内的 OnAuthenticationRefreshedAsync,以便在将已刷新的主体应用到连接后运行代码。 Context.User 会反映已刷新的主体。 如前所述, Context.UserIdentifierSignalR 刷新时用户路由不会更改:

public class ChatHub : Hub
{
    public override Task OnAuthenticationRefreshedAsync()
    {
        return Clients.Caller.SendAsync(
            "AuthenticationRefreshed", Context.UserIdentifier);
    }
}

一个已在运行的中心方法会保留其启动时使用的 Context.User。 后续调用将看到更新后的 Context.User。 有关如何缓存经过身份验证的用户的详细信息 SignalR ,请参阅 连接生存期内的用户和角色更改。

身份验证刷新需要已协商使用协议版本 1 或更高版本的连接。 仅当服务器报告了令牌有效期时,才会安排自动刷新;这通常出现在会设置过期时间的身份验证方案中,例如 Bearer 令牌。 Windows 身份验证不会报告过期信息,并且此功能不会跟踪或刷新它。

Cookies 与持有者令牌

Cookies 是浏览器特有的。 从其他类型的客户端发送它们相比于发送持有者令牌要更加复杂。 除非应用只需从浏览器客户端对用户进行身份验证,否则不建议使用 Cookie 身份验证。 Bearer 使用浏览器客户端以外的客户端时,建议使用令牌身份验证。

Windows 身份验证

如果在应用中配置 了 Windows 身份验证 , SignalR 则可以使用该标识来保护中心。 但是,若要向单个用户发送消息,需要添加自定义用户 ID 提供程序。 Windows 身份验证系统不提供“名称标识符”声明。 SignalR 使用该声明来确定用户名。

添加一个新类,该类实现 IUserIdProvider 并检索用户中的一个声明以用作标识符。 例如,若要使用“名称”声明(即 [Domain]/[Username] 形式的 Windows 用户名),请创建以下类:

public class NameUserIdProvider : IUserIdProvider
{
    public string GetUserId(HubConnectionContext connection)
    {
        return connection.User?.Identity?.Name;
    }
}

使用 ClaimTypes.Name 中的任何值(例如Windows SID 标识符等),而不是 User。

Note

指定的值在系统中的所有用户中必须是唯一的。 否则,面向一个用户的消息最终可能会到达其他用户。

在 Program.cs 文件中注册此组件:

using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.SignalR;
using SignalRAuthenticationSample;

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;

services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
   .AddNegotiate();

services.AddAuthorization(options =>
{
    options.FallbackPolicy = options.DefaultPolicy;
});
services.AddRazorPages();

services.AddSignalR();
services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

var app = builder.Build();

// Code removed for brevity.

在.NET客户端中,必须通过设置 UseDefaultCredentials 属性来启用Windows 身份验证:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chathub", options =>
    {
        options.UseDefaultCredentials = true;
    })
    .Build();

Microsoft Edge 支持 Windows 身份验证,但并非所有浏览器都支持。 例如,在 Chrome 和 Safari 中,尝试使用 Windows 身份验证和 WebSocket 会失败。 当 Windows 身份验证失败时,客户端会尝试回退到其他传输方式,而这些方式可能会奏效。

使用声明自定义标识处理

进行身份验证的应用可以从用户声明中派生用户 ID SignalR。 若要指定 SignalR 如何创建用户 ID,请实现 IUserIdProvider 并注册该实现。

示例代码演示了如何使用声明来选择用户的电子邮件地址作为标识属性。

Note

指定的值在系统中的所有用户中必须是唯一的。 否则,面向一个用户的消息最终可能会到达其他用户。

public class EmailBasedUserIdProvider : IUserIdProvider
{
    public virtual string GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst(ClaimTypes.Email)?.Value!;
    }
}

帐户注册将类型为 ClaimsTypes.Email 的声明添加到 ASP.NET 身份数据库中。

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
    returnUrl ??= Url.Content("~/");
    ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync())
                                                                          .ToList();
    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
        var result = await _userManager.CreateAsync(user, Input.Password);

        // Add the email claim and value for this user.
        await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, Input.Email));

        // Remaining code removed for brevity.

在 Program.cs 文件中注册此组件:

builder.Services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

授权用户访问集线器和其方法

默认情况下,未经身份验证的用户可以调用中心中的所有方法。 若要要求进行身份验证,请将 AuthorizeAttribute 特性应用于中心:

[Authorize]
public class ChatHub: Hub
{
}

可使用 [Authorize] 特性的构造函数参数和属性将访问权限仅限于匹配特定授权策略的用户。 例如,使用调用 MyAuthorizationPolicy自定义授权策略时,只有匹配该策略的用户才能使用以下代码访问中心:

[Authorize("MyAuthorizationPolicy")]
public class ChatPolicyHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        await Clients.All.SendAsync("ReceiveSystemMessage", 
                                    $"{Context.UserIdentifier} joined.");
        await base.OnConnectedAsync();
    }
    // Code removed for brevity.

[Authorize] 特性可应用于单个中心方法。 如果当前用户与应用于方法的策略不匹配,则会向调用方返回错误:

[Authorize]
public class ChatHub : Hub
{
    public async Task Send(string message)
    {
        // ... Send a message to all users ...
    }

    [Authorize("Administrators")]
    public void BanUser(string userName)
    {
        // ... Ban a user from the chat room (something only Administrators can do) ...
    }
}

使用授权处理程序自定义集线器方法授权

当中心方法要求授权时,SignalR 会为授权处理程序提供一个自定义资源。 资源是 HubInvocationContext 的一个实例。 HubInvocationContext 包括 HubCallerContext、正在调用的中心方法的名称以及中心方法的参数。

请考虑聊天室的示例,该聊天室允许多个组织通过Microsoft Entra ID登录。 拥有 Microsoft 帐户的任何人都可以登录聊天,但只有所属组织的成员才能阻止用户或查看用户的聊天历史记录。 此外,可能需要限制特定用户的某些功能。 在此场景中,请注意 DomainRestrictedRequirement 如何充当自定义IAuthorizationRequirement。 由于传入了 HubInvocationContext 资源参数,因此内部逻辑可以检查调用 Hub 时的上下文,并决定是否允许用户执行各个 Hub 方法:

[Authorize]
public class ChatHub : Hub
{
    public void SendMessage(string message)
    {
    }

    [Authorize("DomainRestricted")]
    public void BanUser(string username)
    {
    }

    [Authorize("DomainRestricted")]
    public void ViewUserHistory(string username)
    {
    }
}
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace SignalRAuthenticationSample;

public class DomainRestrictedRequirement :
    AuthorizationHandler<DomainRestrictedRequirement, HubInvocationContext>,
    IAuthorizationRequirement
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        DomainRestrictedRequirement requirement,
        HubInvocationContext resource)
    {
        if (context.User.Identity != null &&
          !string.IsNullOrEmpty(context.User.Identity.Name) && 
          IsUserAllowedToDoThis(resource.HubMethodName,
                               context.User.Identity.Name) &&
          context.User.Identity.Name.EndsWith("@microsoft.com"))
        {
                context.Succeed(requirement);
            
        }
        return Task.CompletedTask;
    }

    private bool IsUserAllowedToDoThis(string hubMethodName,
        string currentUsername)
    {
        return !(currentUsername.Equals("asdf42@microsoft.com") &&
            hubMethodName.Equals("banUser", StringComparison.OrdinalIgnoreCase));
    }
}

在 Program.cs 文件中,添加新策略,提供自定义 DomainRestrictedRequirement 要求作为参数来创建 DomainRestricted 策略:

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SignalRAuthenticationSample;
using SignalRAuthenticationSample.Data;
using SignalRAuthenticationSample.Hubs;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var services = builder.Services;

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
services.AddDatabaseDeveloperPageExceptionFilter();

services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

services.AddAuthorization(options =>
   {
       options.AddPolicy("DomainRestricted", policy =>
       {
           policy.Requirements.Add(new DomainRestrictedRequirement());
       });
   });

services.AddRazorPages();

var app = builder.Build();

// Code removed for brevity.

在前面的示例中,DomainRestrictedRequirement 类既是 IAuthorizationRequirement,也是该要求的 AuthorizationHandler。 可以将这两个组件拆分为不同的类,以分离关注事项。 此示例中的方法提供了无需在启动期间注入 AuthorizationHandler 的好处,因为要求和处理程序是相同的。

除了注册 DomainRestricted 策略并使用 [Authorize("DomainRestricted")] 引用该策略这一做法外,还可以将 IAuthorizationRequirementData 属性直接应用于中心或中心方法。 SignalR 将属性的要求合并到方法调用的有效策略中。 有关详细信息,请参阅 具有“IAuthorizationRequirementData”的自定义授权策略。

其他资源

查看或下载示例代码(如何下载)

对连接到 SignalR 中心的用户进行身份验证

SignalR 可与 ASP.NET Core 身份验证配合使用,将用户与每个连接关联。 在中心,可以从 HubConnectionContext.User 属性访问身份验证数据。 身份验证允许中心对与用户关联的所有连接调用方法。 有关详细信息,请参阅在 SignalR 中管理用户和组。 单个用户可以关联多个连接。

以下是使用 Startup.Configure 和 ASP.NET Core 身份验证的 SignalR 的示例:

public void Configure(IApplicationBuilder app)
{
    ...

    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHub<ChatHub>("/chat");
        endpoints.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
    });
}

Note

如果令牌在连接生存期内过期,连接将继续工作。 如果后续请求不发送新的访问令牌,LongPolling 和 ServerSentEvents 连接将失败。

在基于浏览器的应用中,cookie 身份验证允许现有用户凭据自动流向 SignalR 连接。 使用浏览器客户端时,无需进行其他配置。 如果用户已登录到你的应用,SignalR 连接会自动继承此身份验证。

Cookies 是发送访问令牌的特定于浏览器的方法,但非浏览器客户端可以发送它们。 使用 .NET 客户端时,可以在 Cookies 调用中配置 .WithUrl 属性以提供 cookie。 但是,从 .NET 客户端使用 cookie 身份验证时,应用必须提供 API 来交换 cookie 的身份验证数据。

Bearer 令牌身份验证

客户端可以提供访问令牌,而不是使用 cookie。 服务器验证令牌并使用它来标识用户。 此验证仅在建立连接时执行。 在连接生存期内,服务器不会自动重新验证来检查令牌吊销情况。

在 JavaScript 客户端中,可以使用 accessTokenFactory 选项提供令牌。

// Connect, using the token we got.
this.connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", { accessTokenFactory: () => this.loginToken })
    .build();

在 .NET 客户端中,有一个类似的 AccessTokenProvider 属性可用于配置令牌:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chathub", options =>
    { 
        options.AccessTokenProvider = () => Task.FromResult(_myAccessToken);
    })
    .Build();

Note

你提供的访问令牌函数在每个由 发出的 HTTP 请求之前被调用。 如果需要续订令牌,以使连接保持活动状态(因为它可能在连接期间过期),请在此函数中执行此操作并返回更新后的令牌。

在标准 Web API 中,持有者令牌在 HTTP 标头中发送。 但是,浏览器不能在 WebSocket 和 Server-Sent 事件 API 上设置自定义标头,例如 Authorization。 由于浏览器的这一限制,当客户端在浏览器中运行时,SignalR 会针对这些传输方式将令牌作为查询字符串参数发送。

查询字符串回退是浏览器 API 的约束,而不是 SignalR 或 WebSocket 协议的限制。 非浏览器客户端(如 .NET 客户端)会在 Authorization 标头中发送令牌。 反向代理(例如位于 backend-for-frontend (BFF) 前面的 YARP)也可以向经代理的 WebSocket 请求添加 Authorization 标头,这样后端便可使用该标头而不是查询字符串进行身份验证。

内置JWT身份验证

在服务器上,持有者令牌身份验证是使用 JWT 持有者中间件配置的:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddIdentity<ApplicationUser, IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

    services.AddAuthentication(options =>
        {
            // Identity made Cookie authentication the default.
            // However, we want JWT Bearer Auth to be the default.
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(options =>
        {
            // Configure the Authority to the expected value for your authentication provider
            // This ensures the token is appropriately validated
            options.Authority = /* TODO: Insert Authority URL here */;

            // We have to hook the OnMessageReceived event in order to
            // allow the JWT authentication handler to read the access
            // token from the query string when a WebSocket or 
            // Server-Sent Events request comes in.

            // Sending the access token in the query string is required when using WebSockets or ServerSentEvents
            // due to a limitation in Browser APIs. We restrict it to only calls to the
            // SignalR hub in this code.
            // See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
            // for more information about security considerations when using
            // the query string to transmit the access token.
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    var accessToken = context.Request.Query["access_token"];

                    // If the request is for our hub...
                    var path = context.HttpContext.Request.Path;
                    if (!string.IsNullOrEmpty(accessToken) &&
                        (path.StartsWithSegments("/hubs/chat")))
                    {
                        // Read the token out of the query string
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;
                }
            };
        });

    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    services.AddSignalR();

    // Change to use Name as the user identifier for SignalR
    // WARNING: This requires that the source of your JWT token 
    // ensures that the Name claim is unique!
    // If the Name claim isn't unique, users could receive messages 
    // intended for a different user!
    services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

    // Change to use email as the user identifier for SignalR
    // services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

    // WARNING: use *either* the NameUserIdProvider *or* the 
    // EmailBasedUserIdProvider, but do not use both. 
}

Note

由于存在浏览器 API 限制,当连接 WebSocket 和服务器发送的事件时,将在浏览器上使用查询字符串。 使用 HTTPS 时,查询字符串值受 TLS 连接保护。 但是,许多服务器会记录查询字符串值。 有关详细信息,请参阅 ASP.NET Core SignalR 中的安全注意事项。 SignalR 使用标头在支持令牌的环境(例如 .NET 和 Java 客户端)中传输令牌。

Identity 服务器 JWT 身份验证

使用 Identity Server 时,将 PostConfigureOptions<TOptions> 服务添加到项目中:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
public class ConfigureJwtBearerOptions : IPostConfigureOptions<JwtBearerOptions>
{
    public void PostConfigure(string name, JwtBearerOptions options)
    {
        var originalOnMessageReceived = options.Events.OnMessageReceived;
        options.Events.OnMessageReceived = async context =>
        {
            await originalOnMessageReceived(context);

            if (string.IsNullOrEmpty(context.Token))
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken) && 
                    path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
            }
        };
    }
}

添加用于身份验证的服务 (Startup.ConfigureServices) 和用于 AddAuthentication Server 的身份验证处理程序 (Identity) 后,在 AddIdentityServerJwt 中注册服务:

services.AddAuthentication()
    .AddIdentityServerJwt();
services.TryAddEnumerable(
    ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, 
        ConfigureJwtBearerOptions>());

Cookies 与持有者令牌

Cookies 是浏览器特有的。 从其他类型的客户端发送它们相比于发送持有者令牌要更加复杂。 因此,除非应用只需从浏览器客户端对用户进行身份验证,否则不建议使用 cookie 身份验证。 Bearer 使用浏览器客户端以外的客户端时,建议使用令牌身份验证。

Windows 身份验证

如果 应用中配置了 Windows 身份验证 , SignalR 则可以使用该标识来保护中心。 但是,若要向单个用户发送消息,你需要添加自定义用户 ID 提供程序。 Windows 身份验证系统不提供“名称标识符”声明。 SignalR 使用该声明来确定用户名。

添加一个实现 IUserIdProvider 的新类,并从用户那里检索一个声明以用作标识符。 例如,若要使用“名称”声明(即 [Domain]\[Username] 形式的 Windows 用户名),请创建以下类:

public class NameUserIdProvider : IUserIdProvider
{
    public string GetUserId(HubConnectionContext connection)
    {
        return connection.User?.Identity?.Name;
    }
}

除了 ClaimTypes.Name,你可以使用 User 中的任何值(例如 Windows SID 标识符等)。

Note

所选值对于系统中的所有用户必须是唯一的。 否则,发送给某个用户的消息可能最终会发送给另一个用户。

在 Startup.ConfigureServices 方法中注册此组件。

public void ConfigureServices(IServiceCollection services)
{
    // ... other services ...

    services.AddSignalR();
    services.AddSingleton<IUserIdProvider, NameUserIdProvider>();
}

在 .NET 客户端中,必须通过设置 UseDefaultCredentials 属性来启用 Windows 身份验证:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chathub", options =>
    {
        options.UseDefaultCredentials = true;
    })
    .Build();

Internet Explorer 和 Microsoft Edge 支持 Windows 身份验证,但并非所有浏览器都支持。 例如,在 Chrome 和 Safari 中,尝试使用 Windows 身份验证和 WebSocket 会失败。 当 Windows 身份验证失败时,客户端会尝试回退到其他可能有效的传输方式。

使用声明自定义标识处理

进行身份验证的应用可以从用户声明中派生用户 ID SignalR。 若要指定 SignalR 如何创建用户 ID,请实现 IUserIdProvider 并注册该实现。

示例代码演示了如何使用声明来选择用户的电子邮件地址作为标识属性。

Note

所选值对于系统中的所有用户必须是唯一的。 否则,发送给某个用户的消息可能最终会发送给另一个用户。

public class EmailBasedUserIdProvider : IUserIdProvider
{
    public virtual string GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst(ClaimTypes.Email)?.Value;
    }
}

帐户注册将类型为 ClaimsTypes.Email 的声明添加到 ASP.NET 身份数据库中。

// create a new user
var user = new ApplicationUser { UserName = Input.Email, Email = Input.Email };
var result = await _userManager.CreateAsync(user, Input.Password);

// add the email claim and value for this user
await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, Input.Email));

在 Startup.ConfigureServices 中注册此组件。

services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

授权用户访问集线器和其方法

默认情况下,未经身份验证的用户可以调用中心内的所有方法。 若要要求进行身份验证,请将 AuthorizeAttribute 特性应用于中心:

[Authorize]
public class ChatHub: Hub
{
}

可使用 [Authorize] 特性的构造函数参数和属性将访问权限仅限于匹配特定授权策略的用户。 例如,如果有一个名为 MyAuthorizationPolicy 的自定义授权策略,可以使用以下代码确保仅匹配该策略的用户才能访问中心:

[Authorize("MyAuthorizationPolicy")]
public class ChatHub : Hub
{
}

各个集线器方法也可以使用 [Authorize] 特性。 如果当前用户与应用于方法的策略不匹配,则会向调用方返回错误:

[Authorize]
public class ChatHub : Hub
{
    public async Task Send(string message)
    {
        // ... send a message to all users ...
    }

    [Authorize("Administrators")]
    public void BanUser(string userName)
    {
        // ... ban a user from the chat room (something only Administrators can do) ...
    }
}

使用授权处理程序自定义集线器方法授权

当中心方法要求授权时,SignalR 会为授权处理程序提供一个自定义资源。 资源是 HubInvocationContext 的一个实例。 HubInvocationContext 包括 HubCallerContext、正在调用的中心方法的名称以及中心方法的参数。

考虑允许通过 Microsoft Entra ID 进行多次组织登录的聊天室示例。 拥有 Microsoft 帐户的任何人都可以登录聊天,但只有所属组织的成员才能阻止用户或查看用户的聊天历史记录。 而且,我们可能希望禁止某些用户使用某些功能。 借助 ASP.NET Core 3.0 中的更新功能,这是完全有可能实现的。 注意 DomainRestrictedRequirement 如何充当自定义 IAuthorizationRequirement。 现在正在传入 HubInvocationContext 资源参数,内部逻辑可以检查调用中心的上下文,并决定是否允许用户执行单个中心方法。

[Authorize]
public class ChatHub : Hub
{
    public void SendMessage(string message)
    {
    }

    [Authorize("DomainRestricted")]
    public void BanUser(string username)
    {
    }

    [Authorize("DomainRestricted")]
    public void ViewUserHistory(string username)
    {
    }
}

public class DomainRestrictedRequirement : 
    AuthorizationHandler<DomainRestrictedRequirement, HubInvocationContext>, 
    IAuthorizationRequirement
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        DomainRestrictedRequirement requirement, 
        HubInvocationContext resource)
    {
        if (IsUserAllowedToDoThis(resource.HubMethodName, context.User.Identity.Name) && 
            context.User.Identity.Name.EndsWith("@microsoft.com"))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }

    private bool IsUserAllowedToDoThis(string hubMethodName,
        string currentUsername)
    {
        return !(currentUsername.Equals("asdf42@microsoft.com") && 
            hubMethodName.Equals("banUser", StringComparison.OrdinalIgnoreCase));
    }
}

在 Startup.ConfigureServices 中,添加新策略,并提供自定义 DomainRestrictedRequirement 要求作为参数来创建 DomainRestricted 策略。

public void ConfigureServices(IServiceCollection services)
{
    // ... other services ...

    services
        .AddAuthorization(options =>
        {
            options.AddPolicy("DomainRestricted", policy =>
            {
                policy.Requirements.Add(new DomainRestrictedRequirement());
            });
        });
}

在前面的示例中,DomainRestrictedRequirement 类既是 IAuthorizationRequirement,也是该要求的 AuthorizationHandler。 可以将这两个组件拆分为不同的类,以分离关注事项。 该示例方法的一个好处是无需在启动期间注入 AuthorizationHandler,因为要求和处理程序是同一个类。

其他资源