Nguon: Microsoft Learn · .NET 8.0

Xác thực và phân quyền trong SignalR

Nguồn: SignalR authentication and authorization

Bài viết này mô tả cách xác thực (authentication) và phân quyền (authorization) người dùng trong các ứng dụng ASP.NET Core với SignalR.

Xác thực người dùng kết nối vào SignalR hub

SignalR có thể sử dụng cùng với ASP.NET Core authentication (xác thực) để liên kết một người dùng với mỗi kết nối. Trong một hub, dữ liệu xác thực có thể được truy cập từ thuộc tính HubConnectionContext.User. Xác thực cho phép hub gọi các phương thức trên tất cả các kết nối liên kết với một người dùng. Để biết thêm thông tin, xem Manage users and groups in SignalR. Nhiều kết nối có thể được liên kết với một người dùng duy nhất.

Dưới đây là ví dụ sử dụng SignalR và ASP.NET Core authentication:

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

Lưu ý: Nếu một token hết hạn trong vòng đời của kết nối, theo mặc định kết nối tiếp tục hoạt động. Các kết nối LongPollingServerSentEvent sẽ thất bại ở các request tiếp theo nếu chúng không gửi access token mới. Để đóng kết nối khi token xác thực hết hạn, hãy đặt tùy chọn CloseOnAuthenticationExpiration.

Trong ứng dụng dựa trên trình duyệt, cookie authentication cho phép thông tin xác thực người dùng hiện có tự động được truyền đến các kết nối SignalR. Khi sử dụng browser client (client trình duyệt), không cần cấu hình thêm. Nếu người dùng đã đăng nhập vào ứng dụng, kết nối SignalR sẽ tự động kế thừa xác thực này.

Cookie là cách dành riêng cho trình duyệt để gửi access token, nhưng các client không phải trình duyệt cũng có thể gửi chúng. Khi sử dụng .NET client, thuộc tính Cookies có thể được cấu hình trong lệnh gọi .WithUrl để cung cấp cookie. Tuy nhiên, việc sử dụng cookie authentication từ .NET client đòi hỏi ứng dụng phải cung cấp một API để trao đổi dữ liệu xác thực lấy cookie.

Xác thực bằng Bearer token

Client có thể cung cấp access token thay vì dùng cookie. Server xác thực token và dùng nó để nhận dạng người dùng. Việc xác thực này chỉ được thực hiện khi kết nối được thiết lập. Trong suốt vòng đời kết nối, server không tự động xác thực lại để kiểm tra thu hồi token.

Trong JavaScript client, token có thể được cung cấp bằng tùy chọn accessTokenFactory.

typescript
// Kết nối sử dụng token đã lấy được.
this.connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", { accessTokenFactory: () => this.loginToken })
    .build();

Trong .NET client, có thuộc tính AccessTokenProvider tương tự để cấu hình token:

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

Lưu ý: Hàm access token được gọi trước mỗi HTTP request do SignalR thực hiện. Nếu token cần được gia hạn để duy trì kết nối hoạt động, hãy thực hiện việc gia hạn trong hàm này và trả về token đã được cập nhật.

Trong các web API tiêu chuẩn, bearer token được gửi trong HTTP header. Tuy nhiên, SignalR không thể đặt các header này trong trình duyệt khi sử dụng một số transport. Khi WebSockets và Server-Sent Events được sử dụng, token được truyền dưới dạng tham số query string.

Xác thực JWT tích hợp sẵn

Trên server, bearer token authentication được cấu hình bằng JWT Bearer middleware:

csharp
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 đặt Cookie authentication làm mặc định.
    // Tuy nhiên, chúng ta muốn JWT Bearer Auth là mặc định.
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
  {
      // Cấu hình Authority với giá trị mong đợi cho nhà cung cấp xác thực.
      options.Authority = "Authority URL"; // TODO: Cập nhật URL

      // Cần hook sự kiện OnMessageReceived để cho phép JWT authentication handler
      // đọc access token từ query string khi có WebSocket hoặc Server-Sent Events request.
      // Gửi access token qua query string là bắt buộc khi dùng WebSockets hoặc ServerSentEvents
      // do hạn chế trong Browser API.
      options.Events = new JwtBearerEvents
      {
          OnMessageReceived = context =>
          {
              var accessToken = context.Request.Query["access_token"];

              // Nếu request là cho hub của chúng ta...
              var path = context.HttpContext.Request.Path;
              if (!string.IsNullOrEmpty(accessToken) &&
                  (path.StartsWithSegments("/hubs/chat")))
              {
                  // Đọc token từ query string
                  context.Token = accessToken;
              }
              return Task.CompletedTask;
          }
      };
  });

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

// Thay đổi để dùng Name làm user identifier cho SignalR
// CẢNH BÁO: Điều này yêu cầu nguồn JWT token đảm bảo Name claim là duy nhất!
builder.Services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

var app = builder.Build();

// Code đã được rút gọn.

Lưu ý: Query string được sử dụng trên trình duyệt khi kết nối bằng WebSockets và Server-Sent Events do hạn chế của browser API. Khi dùng HTTPS, kết nối TLS bảo mật các giá trị query string. Tuy nhiên, nhiều web server ghi log giá trị query string. Để biết thêm thông tin, xem Security considerations in ASP.NET Core SignalR.

Xác thực JWT qua Identity Server

Khi sử dụng Duende IdentityServer, thêm một service PostConfigureOptions&lt;TOptions&gt; vào project:

csharp
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;
                }
            }
        };
    }
}

Đăng ký service sau khi thêm services cho authentication (với phương thức AddAuthentication) và authentication handler cho Identity Server (với phương thức AddIdentityServerJwt):

csharp
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 đã được rút gọn.

Cookie dành riêng cho trình duyệt. Gửi chúng từ các loại client khác phức tạp hơn so với gửi bearer token. Cookie authentication không được khuyến nghị trừ khi ứng dụng chỉ cần xác thực người dùng từ browser client. Bearer token authentication là phương pháp được khuyến nghị khi sử dụng các client khác ngoài browser client.

Xác thực Windows

Nếu Windows authentication (xác thực Windows) được cấu hình trong ứng dụng, SignalR có thể sử dụng danh tính đó để bảo mật hub. Tuy nhiên, để gửi tin nhắn đến từng người dùng riêng lẻ, cần thêm một custom User ID provider. Hệ thống Windows authentication không cung cấp claim "Name Identifier". SignalR sử dụng claim này để xác định tên người dùng.

Thêm một lớp mới triển khai IUserIdProvider và lấy một trong các claim từ người dùng để dùng làm identifier (định danh). Ví dụ, để sử dụng claim "Name" (là tên người dùng Windows ở dạng [Domain]/[Username]), tạo lớp sau:

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

Thay vì ClaimTypes.Name, bạn có thể dùng bất kỳ giá trị nào từ User, chẳng hạn như Windows SID identifier.

Lưu ý: Giá trị được chỉ định phải là duy nhất trong toàn bộ hệ thống. Nếu không, tin nhắn dành cho một người dùng có thể đến được một người dùng khác.

Đăng ký component này trong file Program.cs:

csharp
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 đã được rút gọn.

Trong .NET client, Windows authentication phải được bật bằng cách đặt thuộc tính UseDefaultCredentials:

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

Windows authentication được hỗ trợ trong Microsoft Edge, nhưng không phải trên tất cả trình duyệt. Ví dụ, trên Chrome và Safari, việc sử dụng Windows authentication và WebSockets sẽ thất bại. Khi Windows authentication thất bại, client sẽ cố gắng dùng các transport khác, có thể sẽ hoạt động được.

Sử dụng Claims để tùy chỉnh xử lý danh tính

Ứng dụng xác thực người dùng có thể lấy SignalR user ID từ user claims (đặc điểm người dùng). Để chỉ định cách SignalR tạo user ID, hãy triển khai IUserIdProvider và đăng ký triển khai đó.

Đoạn code mẫu dưới đây minh họa cách sử dụng claims để chọn địa chỉ email của người dùng làm thuộc tính định danh.

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

Việc đăng ký tài khoản thêm một claim với kiểu ClaimsTypes.Email vào cơ sở dữ liệu ASP.NET identity.

csharp
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
    // ...
    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);

    // Thêm email claim và giá trị cho người dùng này.
    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, Input.Email));

    // Code còn lại đã được rút gọn.

Đăng ký component trong file Program.cs:

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

Phân quyền người dùng truy cập hubs và hub methods

Mặc định, người dùng chưa xác thực có thể gọi tất cả các phương thức trong hub. Để yêu cầu xác thực, áp dụng thuộc tính AuthorizeAttribute lên hub:

csharp
[Authorize]
public class ChatHub: Hub
{
}

Các đối số constructor và thuộc tính của [Authorize] có thể được dùng để giới hạn quyền truy cập chỉ cho những người dùng phù hợp với authorization policies (chính sách phân quyền) cụ thể. Ví dụ, với chính sách phân quyền tùy chỉnh tên MyAuthorizationPolicy, chỉ những người dùng phù hợp với chính sách đó mới có thể truy cập hub:

csharp
[Authorize("MyAuthorizationPolicy")]
public class ChatPolicyHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        await Clients.All.SendAsync("ReceiveSystemMessage", 
                                    $"{Context.UserIdentifier} joined.");
        await base.OnConnectedAsync();
    }
    // Code đã được rút gọn.

Thuộc tính [Authorize] cũng có thể được áp dụng cho từng hub method riêng lẻ. Nếu người dùng hiện tại không phù hợp với policy áp dụng cho phương thức, sẽ trả về lỗi cho caller:

csharp
[Authorize]
public class ChatHub : Hub
{
    public async Task Send(string message)
    {
        // ... Gửi tin nhắn đến tất cả người dùng ...
    }

    [Authorize("Administrators")]
    public void BanUser(string userName)
    {
        // ... Cấm người dùng khỏi phòng chat (chỉ Administrators mới làm được) ...
    }
}

Sử dụng authorization handlers để tùy chỉnh phân quyền hub method

SignalR cung cấp một resource tùy chỉnh cho authorization handlers khi một hub method yêu cầu phân quyền. Resource là một instance của HubInvocationContext. HubInvocationContext bao gồm HubCallerContext, tên của hub method đang được gọi, và các đối số truyền vào hub method.

Hãy xét ví dụ về một phòng chat cho phép nhiều tổ chức đăng nhập qua Microsoft Entra ID. Bất kỳ ai có tài khoản Microsoft đều có thể đăng nhập vào chat, nhưng chỉ thành viên của tổ chức sở hữu mới có thể cấm người dùng hoặc xem lịch sử chat. Ngoài ra, có thể cần giới hạn một số chức năng đối với một số người dùng nhất định. Trong tình huống này, DomainRestrictedRequirement đóng vai trò là IAuthorizationRequirement tùy chỉnh. Vì tham số resource HubInvocationContext được truyền vào, logic nội bộ có thể kiểm tra ngữ cảnh trong đó hub đang được gọi, và đưa ra quyết định cho phép người dùng thực thi từng hub method riêng lẻ:

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

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

    [Authorize("DomainRestricted")]
    public void ViewUserHistory(string username)
    {
    }
}
csharp
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));
    }
}

Trong file Program.cs, thêm policy mới với DomainRestrictedRequirement tùy chỉnh làm tham số để tạo policy DomainRestricted:

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

Trong ví dụ trên, lớp DomainRestrictedRequirement vừa là IAuthorizationRequirement vừa là AuthorizationHandler của chính nó cho requirement đó. Hoàn toàn có thể tách hai component này thành các lớp riêng biệt để phân tách mối quan tâm. Lợi ích của cách tiếp cận này là không cần inject AuthorizationHandler trong quá trình khởi động vì requirement và handler là cùng một thứ.