1、背景
官网上SignalR
的demo
很详细,但是有个特别的问题,就是没有详细阐述如何给指定的用户发送消息。
2、解决思路
网上整体解决思路有三个:
1、最简单的方案,客户端连接SignalR
的Hub时,只是简单的连接,不包含用户信息。连接完成后,发送一个指定格式的数据包,里面包含用户信息,服务器收到后,完成解析,并将用户信息与connectionID关联起来,用于保存(保存在List或者数据库中均可,看具体应用)。网上这个方案比较多
2、使用Group方案。这个是一个比较取巧的方案,将用户信息放在Group,然后通过Group找到用户,并发送信息。
3、使用用户认证方案。一般有cookie方案和token方案。
因为token方案比较常见,在客户端和网页端都可以使用,所以本篇阐述如何通过用户认证方案(JWT方式),实现SignalR
给指定的用户发送消息的功能
3、具体实现
3.1 基本原理
整体步骤如下:
1、根据API的方法要求,传入用户名、密码
2、认证服务器,将返回一个JWT格式的Token。这儿有个关键,就是第二段(payload)。可以进行自定义。例如:
//payload中的值设定
private static ClaimsIdentity GenerateClaims(User user)
{
var ci = new ClaimsIdentity();
ci.AddClaim(new Claim("id", user.Id.ToString()));
ci.AddClaim(new Claim(ClaimTypes.Name, user.Username));
ci.AddClaim(new Claim(ClaimTypes.GivenName, user.Name));
ci.AddClaim(new Claim(ClaimTypes.Email, user.Email));
ci.AddClaim(new Claim("EmployeeID", user.EmployeeID));
foreach (var role in user.Roles)
ci.AddClaim(new Claim(ClaimTypes.Role, role));
return ci;
}
因此获得的token后,解析payload,就会得到如下的信息。而我们一会儿要用的,就是这些信息。
3、第三步是在SignalR服务器,自定义识别user方法。代码如下:
public class CustomUserIDProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
var returnStr = connection.User?.FindFirst("EmployeeID")?.Value!;
return returnStr;
}
}
我们希望使用员工号(EmployeeID
)进行唯一的识别,因此在第二步中,添加了EmployeeID
的自定义Claim
。
这一步的核心就是,当客户端发起一个SignalR连接时,会一并第2步的token,SignalR中默认会关联connection和userID,而第三步就是确定,用哪个信息能够代表唯一的userID(既方便系统识别,又符合业务逻辑,在本例中,我们使用了员工号)。
实现上述CustomUserIDProvider后,在Program中注册,替换SignalR的默认provider。
builder.Services.AddSingleton<IUserIdProvider, CustomUserIDProvider>();
4、第四步是定义连接,并使用StartAsync()
方法,进行连接。
var _token="eyJhbGciOiJIUzI1NiIsInR.XXXXXXX.YYYYYYYYYY";
//本人使用Android模拟机发起一个SignalR请求
//虽然SignalR服务器在本机电脑上,但Android虚拟机中不能使用localhost,因为会被系统认为是访问Android虚拟机
//本机的地址,在Android虚拟机的对应地址,可以通过adb进行查看
//一般情况是10.0.0.2、10.0.2.2等,但不是绝对的,可以查看相关的配置
connection = new HubConnectionBuilder()
.WithUrl("http://10.0.2.2:5261/signalrtc",
options =>
{
options.AccessTokenProvider = () => Task.FromResult(_token);
}
)
.WithAutomaticReconnect()
.Build();
//Android终端,收到消息后,在界面上进行追加展示
connection.On<string>("ReceiveMessage", (message) =>
{
this.RunOnUiThread(() =>
{
showTextView.Text += message + " ";
});
});
在某个事件中触发StartAsync()
方法,实现连接。
private async void FabOnClickAsync(object sender, EventArgs eventArgs)
{
try
{
await connection.StartAsync();
}
catch (Exception ex)
{
this.RunOnUiThread(() => {
showTextView.Text = ex.Message;
});
}
}
这儿有个关键信息:
In the .NET Client, this access token is passed in as an HTTP “Bearer Authentication” token (Using the Authorization header with a type of Bearer). In the JavaScript client, the access token is used as a Bearer token, except in a few cases where browser APIs restrict the ability to apply headers (specifically, in Server-Sent Events and WebSockets requests). In these cases, the access token is provided as a query string value access_token.
就是在正常情况下,不管是.NET客户端还是在JavaScript端,发送的token信息,都会包到header信息中(header中,key是Authorization,value是"Bearer token的具体值"),只有特殊情况,token会追加到查询字符串中,以access_token的样式,例如:http://xxxxx?access_token=xxxxxx。
而官网上具体例子:
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 =>
{
// 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 =>
{
//【就是这里】,浏览器的方式是可以通过http:xxxxx?access_token=xxx实现的,但是在
//NET客户端中,一直在Header中,因此如果使用客户端连接,则下面的accessToken肯定会一直为null
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;
}
};
});
我们可以通过断点追踪查看,当客户端发起SignalR请求时,routevalues中没有access_token的值,如下图所示。
而token信息,是一直在headers中,如下图所示:
因此在SignalR服务器中,获取token的方式应该如下:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Query.ContainsKey("access_token"))
{
context.Token = context.Request.Query["access_token"];
}
else if (context.Request.Headers.TryGetValue("Authorization", out var value) && value.Count > 0)
{
context.Token = value[0].Substring("Bearer ".Length);
}
return Task.CompletedTask;
}
};
这样,就能够实现token值的获取了。
3.2 实现效果
3.2.1 推送的代码
上面的例子中,通过controller方法,进行了推送
[ApiController]
public class HubPushController : ControllerBase
{
private readonly IHubContext<SignalRTCHub> _hubContext;
public HubPushController(IHubContext<SignalRTCHub> hubContext)
{
_hubContext = hubContext;
}
//这个是指定用户进行推送
[HttpGet]
public async Task SendMessageByEmail(string employeeid, string message)
{
await _hubContext.Clients.User(employeeid).SendAsync("ReceiveMessage",message);
}
//这个是全部用户的推送
[HttpGet]
public async Task SendMessageToAll(string message)
=> await _hubContext.Clients.All.SendAsync("ReceiveMessage", message);
}
4、参考资料
1、ASP.NET Core SignalR configuration
2、SignalR AccessTokenProvider works with TypeScript client but not .NET client