В прошлой статье рассматривалась аутентификация и авторизация на основе кук. Другим расспространенным способом аутентификации и авторизации представляет использование токенов. Рассмотрим, как использовать токены в приложении SignalR.
Подробнее про аутентификацию и авторизацию токенов в asp.net можно посмотреть в соответствующих статьях: Аутентификация с помощью JWT-токенов и Авторизация с помощью JWT-токенов в клиенте JavaScript. В данном же случае рассмотрим непосредственное взавимодействие SignalR и механизма аутентификацию с помощью токенов. И для этого возьмем следующий проект:
Прежде всего для использования JWT-токенов в проект ASP.NET Core необходимо добавить Nuget-пакет Microsoft.AspNetCore.Authentication.JwtBearer.
Пусть у нас будет следующий хаб ChatHub:
using Microsoft.AspNetCore.Authorization; // для атрибута Authorize using Microsoft.AspNetCore.SignalR; namespace SignalRApp { [Authorize] public class ChatHub : Hub { public async Task Send(string message, string userName) { await Clients.All.SendAsync("Receive", message, userName); } } }
Поскольку к классу применяется атрибут Authorize, то доступ к хабу установлен только для аутентифицировнных пользователей. В самом хабе метод Send получает сообщение и имя отправившего его пользователя и ретранслирует его всем подключенным клиентам.
В файле 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 // условная бд с пользователями var people = new List<Person> { new Person("tom@gmail.com", "12345"), new Person("bob@gmail.com", "55555") }; var builder = WebApplication.CreateBuilder(args); 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.Name, 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)); }
Для представления отдельного пользователя здесь определен класс Person с двумя свойствами, а для целей тестирования в приложении опреден список объектов Person. Для хранения настроек токена определен вспомогательный класс AuthOptions.
В отличие от стандартной работы с токеном в webapi здесь нам еще надо получить токен из строки запроса, если запрос обращен к хабу:
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; } };
То есть, если запрос идет по пути "/chat", а в строке запроса передан параметр "access_token" (который хранит токен), то токен извлекается и устанавливается для свойства context.Token
.
Таким образом, мы сможем идентифицировать текущего пользователя.
Для непосредственной аутентификации пользователя и выдачи токена определена конечная точка app.MapPost("/login")
, которая через post-запрос получает логин и пароль пользователя и
и возвращает токен и имя пользователя.
Для тестирования определим в проекте в папке wwwroot следующую веб-страницу index.html:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>METANIT.COM</title> </head> <body> <div id="loginBlock"> <p> Введите логин:<br /> <input id="email" type="text" /> </p> <p> Введите пароль:<br /> <input id="password" type="password" /> </p> <input id="loginBtn" type="button" value="Войти" /> </div><br /> <div id="inputForm"> <input type="text" id="message" /> <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; // токен let username; // имя пользователя const hubConnection = new signalR.HubConnectionBuilder() .withUrl("/chat", { accessTokenFactory: () => token }) .build(); // аутентификация document.getElementById("loginBtn").addEventListener("click", async ()=>{ // отправляем запрос на аутентификацию // посылаем запрос на адрес "/login", в ответ получим токен и имя пользователя 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; hubConnection.invoke("Send", message, username) .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)); var firstElem = document.getElementById("chatroom").firstChild; document.getElementById("chatroom").insertBefore(elem, firstElem); }); </script> </body> </html>
Изначально токен не установлен, он будет установлен только после прохождении аутентификации на сервере. Для этого по нажатию на кнопку loginBtn посылается POST-запрос на сервер по адресу "/login" и взамен получаем токен и имя пользователя, которые сохраняются в глобальные переменные на веб-странице.
И только после получения токена запускатся соединение:
hubConnection.start()
При этом при установке соединения в SignalR в клиенте javascript необходимо дополнительно передавать токен:
const hubConnection = new signalR.HubConnectionBuilder() .withUrl("/chat", { accessTokenFactory: () => token }) .build();
То есть SignalR на стороне клиент не добавляет токен в заголовки запроса. И при обращении к хабу токен фактически будет посылаться как параметр строки запроса.
Результат работы программы: