Каждый пользователь в SignalR представляет по умолчанию объект ClaimsPrincipal. По сути это аутентифицированный пользователь,
который осуществил вход в приложение. И посредством аутентификационного тикета в куки или jwt-токена хаб может узнать, что это за
пользователь. Используя идентификатор ClaimTypes.NameIdentifier
из ClaimsPrincipal, можно отправлять сообщение определенным пользователям.
Концепция пользователя и концепция клиента(подключения) в SignalR отличаются. Пользователь - это учетная запись, под которой осуществлен вход в приложение. Можно одновременно выполнить вход в приложение сразу на нескольких устройствах, допустим, на ПК в нескольких браузерах и на мобильном устройстве. И если отправить этому пользователю сообщение, то этот пользователь увидит отправленное сообщение во всех браузерах и устройствах, в которых он зашел в приложение.
Подключение, в отличие от пользователя, создается даже на каждую отдельную вкладку браузера. Например, если в одном браузере открыть две вкладки и на обеих вкладках подключиться к хабу, то на каждой вкладке будут разные подключения с разными идентификаторами.
Если пользователь авторизован, то внутри хаба мы можем получить этого пользователя через свойство Context.User:
using Microsoft.AspNetCore.Authorization; // для атрибута Authorize using System.Security.Claims; // для ClaimTypes using Microsoft.AspNetCore.SignalR; namespace SignalRApp { [Authorize] public class ChatHub : Hub { [Authorize] public async Task Send(string message, string to) { var user = Context.User; var userName = user?.Identity?.Name; // получаем роль var userRole = user?.FindFirst(ClaimTypes.Role)?.Value; // принадлежит ли пользователь роли "admins" var isAdmin = user?.IsInRole("admin"); //.......... } } }
Это свойство представляет объект ClaimsPrincipal, поэтому мы можем также, как и в представлениях или контроллерах, получить из него формальное
имя пользователя (user.Identity.Name
), получить роль или значения других клеймов, если они сохранены в аутентификационных куках или токене, узнать, принадлежит ли
пользователь определенной роли, а также получить другую сопутствующую информацию.
Но важно учитывать, что некоторые пользователи, которые обращаются к хабу, могут быть аутентифицированы, некоторые могут быть анонимными. Поэтому если мы предоставляем доступ вообще всем пользователям, то при обращении к функционала в Context.User необходимо проверять этот объект на null. Либо применять атрибут авторизации, который исключает обращения анонимных пользователей.
В хабе мы можем не только получить текущего пользователя, но и отправить сообщения определенным пользователям. Для этого можно использовать ряд методов:
Clients.User(string userId): вызывает метод у пользователя по id
Clients.Users(IReadOnlyList<string> userIds): вызывает метод у пользователей, id которых передаются в метод
В оба метода передается некий id пользователя. Что это за id? В реальности мы сами можем определить, что будет использоваться в качестве id. Для этого добавим в проект новый класс CustomUserIdProvider:
using Microsoft.AspNetCore.SignalR; using System.Security.Claims; // для ClaimTypes namespace SignalRApp { public class CustomUserIdProvider : IUserIdProvider { public virtual string? GetUserId(HubConnectionContext connection) { return connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; } } }
Данный класс реализует интерфейс IUserIdProvider, который определяет метод GetUserId(). Этот метод как раз и возвращает id пользователя.
В данном случае он возвращает значение клейма ClaimTypes.NameIdentifier
, который хранит имя пользователя. Здесь
мы можем возвратить любое другое значение, например, роль пользователя, все зависит от конкретного приложения и конкретной задачи.
Определим следующий класс хаба:
using Microsoft.AspNetCore.Authorization; // для атрибута Authorize using Microsoft.AspNetCore.SignalR; namespace SignalRApp { [Authorize] public class ChatHub : Hub { public async Task Send(string message, string to) { // получение текущего пользователя, который отправил сообщение //var userName = Context.UserIdentifier; if (Context.UserIdentifier is string userName) { await Clients.Users(to, userName).SendAsync("Receive", message, userName); } } public override async Task OnConnectedAsync() { await Clients.All.SendAsync("Notify", $"Приветствуем {Context.UserIdentifier}"); await base.OnConnectedAsync(); } } }
При подключение пользователя у всех подключенных клиентов будет вызываться функция Notify, которой передается сообщение общего характера.
В методе Send получаем сообщение и идентификатор пользователя, которому предназначено это сообщение. В самих методах мы можем получить идентификатор текущего пользователя через свойство Context.UserIdentifier. По сути это будет значение клейма ClaimTypes.NameIdentifier
. Далее сообщение отправляется
получателю, которому предназначено сообщение, и текущему пользователю, которой собственно и отправляет сообщение.
В файле Program.cs определим следующий код:
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using SignalRApp; // пространство имен класса ChatHub using Microsoft.AspNetCore.SignalR; // для IUserIdProvider // условная бд с пользователями var people = new List<Person> { new Person("tom@gmail.com", "11111"), new Person("bob@gmail.com", "55555"), new Person("sam@gmail.com", "22222") }; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>(); // Устанавливаем сервис для получения Id пользователя builder.Services.AddAuthorization(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = AuthOptions.ISSUER, ValidateAudience = true, ValidAudience = AuthOptions.AUDIENCE, ValidateLifetime = true, IssuerSigningKey = AuthOptions.GetSymmetricSecurityKey(), ValidateIssuerSigningKey = true }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; // если запрос направлен хабу var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/chat")) { // получаем токен из строки запроса context.Token = accessToken; } return Task.CompletedTask; } }; }); builder.Services.AddSignalR(); var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseAuthentication(); // добавление middleware аутентификации app.UseAuthorization(); // добавление middleware авторизации app.MapPost("/login", (Person loginModel) => { // находим пользователя Person? person = people.FirstOrDefault(p => p.Email == loginModel.Email && p.Password == loginModel.Password); // если пользователь не найден, отправляем статусный код 401 if (person is null) return Results.Unauthorized(); var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, person.Email) }; // создаем JWT-токен var jwt = new JwtSecurityToken( issuer: AuthOptions.ISSUER, audience: AuthOptions.AUDIENCE, claims: claims, expires: DateTime.UtcNow.Add(TimeSpan.FromMinutes(2)), signingCredentials: new SigningCredentials(AuthOptions.GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256)); var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt); // формируем ответ var response = new { access_token = encodedJwt, username = person.Email }; return Results.Json(response); }); app.MapHub<ChatHub>("/chat"); app.Run(); record class Person(string Email, string Password); public class AuthOptions { public const string ISSUER = "MyAuthServer"; // издатель токена public const string AUDIENCE = "MyAuthClient"; // потребитель токена const string KEY = "mysupersecret_secretsecretsecretkey!123"; // ключ для шифрации public static SymmetricSecurityKey GetSymmetricSecurityKey() => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(KEY)); }
В данном случае применяется аутентификация на основе токенов, которая была рассмотрена в прошлой теме. Соответственно для ее работы в проект необходимо добавить Nuget-пакет Microsoft.AspNetCore.Authentication.JwtBearer.
Для представления пользователя определен класс Person, и в приложении для тестирования определена условная база данных - список с тремя объектами Person.
При обращении по адресу "/login" клиенту отправляет токен для последующего взаимодействия с хабом.
При этом следует отметить два момента. Во-первых, установка сервиса получения id пользователя в хабах SignalR:
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
Во-вторых, при получении данных пользователя при логине в приложение и установке токена логин пользователя будет связан с клеймом ClaimTypes.NameIdentifier
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, person.Email) };
Для тестирования определим в проекте в папке wwwroot следующую веб-страницу index.html:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>METANIT.COM</title> </head> <body> <div id="loginBlock"> Введите логин и пароль:<br /> <input id="email" type="text" /> <input id="password" type="password" /> <input id="loginBtn" type="button" value="Войти" /> </div><br /> <div id="inputForm"> Введите сообщение и получателя:<br /> <input type="text" id="message" placeholder="Введите сообщение" /> <input type="text" id="receiver" placeholder="Введите получателя" /> <input type="button" id="sendBtn" disabled value="Отправить" /> </div> <div id="chatroom"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.js"></script> <script> let token; // токен const hubConnection = new signalR.HubConnectionBuilder() .withUrl("/chat", { accessTokenFactory: () => token }) .build(); // аутентификация document.getElementById("loginBtn").addEventListener("click", async () => { const response = await fetch("/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: document.getElementById("email").value, password: document.getElementById("password").value }) }); // если запрос прошел нормально if (response.ok === true) { // получаем данные const data = await response.json(); token = data.access_token; username = data.username; document.getElementById("loginBtn").disabled = true; hubConnection.start() // начинаем соединение с хабом .then(() => document.getElementById("sendBtn").disabled = false) .catch(err => console.error(err.toString())); } else { // если произошла ошибка, получаем код статуса console.log(`Status: ${response.status}`); } }); // отправка сообщения от простого пользователя document.getElementById("sendBtn").addEventListener("click", () => { const message = document.getElementById("message").value; const receiver = document.getElementById("receiver").value; hubConnection.invoke("Send", message, receiver) .catch(error => console.error(error)); }); // получение сообщения от пользователя hubConnection.on("Receive", (message, user) => { // создаем элемент <b> для имени пользователя const userNameElem = document.createElement("b"); userNameElem.textContent = `${user}: `; // создает элемент <p> для сообщения пользователя const elem = document.createElement("p"); elem.appendChild(userNameElem); elem.appendChild(document.createTextNode(message)); const firstElem = document.getElementById("chatroom").firstChild; document.getElementById("chatroom").insertBefore(elem, firstElem); }); // получени общего уведомления hubConnection.on("Notify", message => { const elem = document.createElement("p"); elem.textContent = message; const firstElem = document.getElementById("chatroom").firstChild; document.getElementById("chatroom").insertBefore(elem, firstElem); }); </script> </body> </html>
На веб-странице вначале осуществляем вход в приложение, как было описано в прошлой статье, затем отправляем сообщение определенному пользователю.
Таким образом, пользователи смогут отправлять сообщения только определенным пользователям: