Пользователи

Последнее обновление: 07.08.2022

Каждый пользователь в 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

В файле 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>

На веб-странице вначале осуществляем вход в приложение, как было описано в прошлой статье, затем отправляем сообщение определенному пользователю.

Таким образом, пользователи смогут отправлять сообщения только определенным пользователям:

Управление пользователями в SignalR в приложении ASP.NET Core и C#
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850