简体   繁体   中英

How To Send Message To Specific User In Blazor Server Using SignalR?

I have implemented a simple public chat room system using blazor server and signalR with database to store username and message. IN system user join chat just by entering its name and the UI for chatting appears.

Now, what I want is to add another feature where message can be sent to specific user that is present in that public chat room.
Any help will be great and Thank you.

Below is my code for public chat room.
Below is my Hub

    public class Chat:Hub
    {
        public async Task SendMessage(Message message)
        {
            await Clients.All.SendAsync("Receiver", message);
        }
    }

Below is code to save username and message in database

    public string MsgBody { get; set; }
    public string UserName { get; set; }
    public Message chatmsg { get; set; } = new Message();
    public bool isChatting = false;
    public string errorMsg;
    public List<Message> messages = new List<Message>();
    public List<Message> messagesList = new List<Message>();
    public HubConnection hubConnection;
    [Inject]
    public NavigationManager NavigationManager { get; set; }
    [Inject]
    public MainService mainService { get; set; }
    public async Task SendAsync()
    {
        chatmsg.UsrName = UserName;
        chatmsg.MessageBody = MsgBody;
        mainService.SaveMessage(chatmsg);
        await hubConnection.SendAsync("SendMessage", chatmsg);
        MsgBody = string.Empty;
        chatmsg = new Message();
    }

Below is code to load data
@if (!isChatting)
{
<div class="col-lg-5">
    <p>Enter your name to start chatting:</p>

    <div class="input-group  my-3">
        <input @bind="UserName" type="text" class="form-control my-input">
        <div class="input-group-append">
            <button class="btn btn-outline-secondary" type="button" @onclick="@Chat"><span class="oi oi-chat" aria-hidden="true"></span> Chat!</button>
        </div>
    </div>
</div>
   if (errorMsg != null)
   {
    <div class="col-lg-5">
        <small id="emailHelp" class="form-text text-danger">@errorMsg</small>
    </div>
   }
}
else
{
<div class="alert alert-secondary mt-4" role="alert">
    <span class="oi oi-person mr-2" aria-hidden="true"></span>
    <span>you are connected as <b>@UserName</b></span>
    <button class="btn btn-sm btn-warning ml-md-auto" @onclick="@DisconnectAsync">disconnect</button>
</div>
<div id="scrollbox">
    @foreach (var item in messagesList)
    {
        @if (item.IsNotice)
        {
            <div class="alert alert-info">@item.MessageBody</div>
        }
        else
        {
            <div class="@item.CSS">
                <div class="user">@item.UsrName</div>
                <div class="msg">@item.MessageBody</div>
            </div>
        }
    }
    <hr />
    <textarea class="input-lg" placeholder="enter your comment" @bind="MsgBody"></textarea>
    <button class="btn btn-default" @onclick="()=>SendAsync()">Send</button>
</div>
}

Below is my UI
@if (:isChatting) { <div class="col-lg-5"> <p>Enter your name to start chatting.</p> <div class="input-group my-3"> <input @bind="UserName" type="text" class="form-control my-input"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" @onclick="@Chat"><span class="oi oi-chat" aria-hidden="true"></span> Chat.</button> </div> </div> </div> if (errorMsg.= null) { <div class="col-lg-5"> <small id="emailHelp" class="form-text text-danger">@errorMsg</small> </div> } } else { <div class="alert alert-secondary mt-4" role="alert"> <span class="oi oi-person mr-2" aria-hidden="true"></span> <span>you are connected as <b>@UserName</b></span> <button class="btn btn-sm btn-warning ml-md-auto" @onclick="@DisconnectAsync">disconnect</button> </div> <div id="scrollbox"> @foreach (var item in messagesList) { @if (item.IsNotice) { <div class="alert alert-info">@item.MessageBody</div> } else { <div class="@item.CSS"> <div class="user">@item.UsrName</div> <div class="msg">@item.MessageBody</div> </div> } } <hr /> <textarea class="input-lg" placeholder="enter your comment" @bind="MsgBody"></textarea> <button class="btn btn-default" @onclick="()=>SendAsync()">Send</button> </div> }

This is a complete solution how to do that. You can download a working and updated sample of the code from Github :

Note: The purpose of this answer is not how to create a SignlR app, and how to manage users. This is shown in the docs and many other tutorials. But one thing is lacking and it is how to protect the SignalR hub's end points, and how to make the user's claims available in the hub in Blazor Server App. I did not find a single example of dong it with Blazor Server App. I sought some hints from the Blazor team, but to no avail...

Some clarification

Note: Authenticating your users in the front-end of your Blazor Server App will not make you authorized to access protected end-points on the Hub. You should treat the Hub as you treat a Web Api end-points, which requires you to pass an access token when you perform HTTP calls to it. As for instance, if you want to retrieve data from the WeatherForecastController in a Web Api to the FetchData page employing the HttpClient service, you need to pass the access token in the Authorization header

When you use WebAssembly app with API authentication, you can pass the access token to the Hub when the hub connection is created. It's easy, the docs has a sample code demonstrating this, and actually you don't have much to do in order to secure the Hub, and access......................, no even with this there are some issues to deal with because only the UserIdentifier can be accessed in the Hub, not all the user's claims.

However, the answer here is about Blazor Server App, and the solution is to pass the security cookie (".AspNetCore.Identity.Application") to the hub. So, the first step to solve the issue is to capture the cookie from the HttpContext before the Blazor SPA is being rendered, and pass the cookie to the Blazor App as a parameter sent to the App component. Now that the cookie is available in the Blazor App you can access it from the Chat page and pass it to the Hub. Note that, unlike the WebAssembly App sample with SignalR, all the ClaimPrincipal object is available in the Hub, and you can access all its claims, as for instance:

var user = Context.User.Identity.Name
var userid = Context.UserIdentifier

_Host.cshtml

    @{
        // Capture the security cookie when the the initial call
        // to the Blazor is being executed. Initial call is when 
        // the user type the url of your Blazor App in the address
        // bar and press enter, or when the user is being redirected 
        // from the Login form to your Blazor SPA
        // See more here: https://stackoverflow.com/a/59538319/6152891 
         var cookie = 
     HttpContext.Request.Cookies[".AspNetCore.Identity.Application"];
    
    }


  <body>
     @* Pass the captured Cookie to the App component as a paramter*@
    <component type="typeof(App)" render-mode="Server" param- 
       Cookie="cookie" />
  </body>

App.razor

@inject CookiesProvider CookiesProvider

@* code omitted here... *@

@code{

    [Parameter]
    public string Cookie { get; set; }

    protected override Task OnInitializedAsync()
    {
        // Pass the Cookie parameter to the CookiesProvider service
        // which is to be injected into the Chat component, and then 
        // passed to the Hub via the hub connection builder
        CookiesProvider.Cookie = Cookie;

        return base.OnInitializedAsync();
    }
}

CookiesProvider.cs (Complete code)

using Microsoft.JSInterop;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SignalRServerIdentityAuthentication
{
    public class CookiesProvider
    {
        public string Cookie { get; set; }
    }
}

Startup.ConfigureService

 services.AddScoped<CookiesProvider>();
 services.AddSignalR();

Startup.Configure

 app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapBlazorHub();
                endpoints.MapHub<ChatHub>("/chatHub");
                endpoints.MapFallbackToPage("/_Host");
            });

Note that the NavMenu contain an AuthorizeView component the object of which is to prevent a user from accessing the Chat component, unless she has been authenticated. Note also that the Chat page is protected with the Authorize attribute.

NavMenu.razor

<li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
        <AuthorizeView>
            <li class="nav-item px-3">
                <NavLink class="nav-link" href="chat">
                    <span class="oi oi-chat" aria-hidden="true"></span> Chat
                </NavLink>
            </li>
        </AuthorizeView>

Chat.razor (Complete code)

    @page "/chat"

    @attribute [Authorize]

@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.AspNetCore.SignalR

@using SignalRServerIdentityAuthentication.Hubs

@inject NavigationManager NavigationManager


@using System.Net.Http
@using System.Net.Http.Json

@using System;

@using System.Net.Http.Headers;
@using System.Threading.Tasks;
@using Microsoft.AspNetCore.Http.Connections;
@using System.Net


@implements IAsyncDisposable
<p>@messageForBoard</p>
<hr />

<div>
    <label for="user">User:</label>
    <span id="user">@userName</span>
</div>
<div class="form-group">
    <label for="messageInput">Message:</label>
    <input onfocus="this.select();" @ref="elementRef" id="messageInput" @bind="messageInput" class="form-control my-input"/>
</div>

<div>
<button @onclick="Send" disabled="@(!IsConnected)" class="btn btn-outline- 
     secondary">Send Message</button> 

@if (UserList != null)
    {
        <select id="user-list" @bind="selectedUser">
            <option value="">All.....</option>
            @foreach (var user in UserList)
            {
                <option value="@user">@user</option>
            }
        </select>
    }
  
 </div>

 <div>
    <label for="messagesList">Public Message Board:</label>
    <ul id="messagesList">
       @foreach (var message in messages)
       {
          <li>@message</li>
       }
    </ul>
</div>

<div>
    <label for="private-messages-list">Private Message Board:</label>
    <ul id="private-messages-list">
       @foreach (var message in privateMessages)
       {
          <li>@message</li>
       }
    </ul>
</div>

@code {
    HubConnection hubConnection;
    private List<string> messages = new List<string>();
    private List<string> privateMessages = new List<string>();
    private string messageForBoard;
    private string userName;
    private string messageInput;
    private string selectedUser;
    private List<string> UserList;

    private ElementReference elementRef;

    [Inject]
    public CookiesProvider CookiesProvider { get; set; }
  

    protected override async Task OnInitializedAsync()
    {
        var container = new CookieContainer();
        var cookie = new Cookie() 
         {
             Name = ".AspNetCore.Identity.Application", 
             Domain = "localhost",
             Value = CookiesProvider.Cookie
         };

         container.Add(cookie);

      hubConnection = new HubConnectionBuilder()
    .WithUrl(NavigationManager.ToAbsoluteUri("/chathub"), options => 
    {
        // Pass the security cookie to the Hub. This is the way to do 
        // that in your case. In other cases, you may need to pass
        // an access token, but not here......
        options.Cookies = container; 
    }).Build();

        hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            messages.Add(encodedMsg);
           InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<string>("ReceiveUserName", (name) =>
        {
            userName = name;

            InvokeAsync(() => StateHasChanged());
        });

         hubConnection.On<string>("MessageBoard", (message) =>
        {
            messageForBoard = message;

           InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<string, string>("ReceivePrivateMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            privateMessages.Add(encodedMsg);

            InvokeAsync(() => StateHasChanged());
        });

        hubConnection.On<List<string>>("ReceiveInitializeUserList", ( list) =>
        {
            UserList = list ;

            InvokeAsync(() => StateHasChanged());
        });


        await hubConnection.StartAsync();
        await hubConnection.InvokeAsync("InitializeUserList");
      
    }
    protected override void OnAfterRender(bool firstRender)
    {
         elementRef.FocusAsync();
    }
       
   async Task Send() => await hubConnection.SendAsync("SendMessage", 
                                       selectedUser, messageInput);
    public bool IsConnected => hubConnection.State == 
                                      HubConnectionState.Connected;

    public void Dispose()
    {
       hubConnection.DisposeAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await hubConnection.DisposeAsync();
    }
}

Note that in order to pass a private message you need to have the UserIdentifier, but you'll also need to associate the user you want to post a private message with the UserIdentifier. You can simply store a list of UserIdentifiers in the Chat, and pass the reiured one. This of course poses some security risks and should be avoided. See my code how I deal with this. The user can only view a list of user names (yes, these are the emails of the connected users. Recall that in the database the UserName colum contains the user's email). You can of course change this to a more displayable values; your display name can be first name + last name, etc. It's up to you. Just remeber that you'll need to add a new claim for this. How to do that merits a new question...

Hubs/ChatHub.cs (Complete code needs some cleaning of unnecessary using statements)

    using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.IdentityModel.Tokens;

using Microsoft.IdentityModel;

using System.Net.Http;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authentication;
using System.Net.Http.Json;

namespace SignalRServerIdentityAuthentication.Hubs
{
    [Authorize()]
    public class ChatHub : Hub
    {
        private static List<ConnectedUser> connectedUsers = new List<ConnectedUser>();
        public async Task InitializeUserList() 
        {
            var list = (from user in connectedUsers
                       select user.Name ).ToList();

            await Clients.All.SendAsync("ReceiveInitializeUserList", list);
        }
        public async Task SendMessage(string userID, string message)
        {
            if (string.IsNullOrEmpty(userID)) // If All selected
            {
                await Clients.All.SendAsync("ReceiveMessage", Context.User.Identity.Name ?? "anonymous", message);
            }
            else
            {
                var userIdentifier = (from _connectedUser in connectedUsers
                                      where _connectedUser.Name == userID
                                      select _connectedUser.UserIdentifier).FirstOrDefault();

                await Clients.User(userIdentifier).SendAsync("ReceivePrivateMessage",
                                       Context.User.Identity.Name ?? "anonymous", message);
            }

        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
           
            var user = connectedUsers.Where(cu => cu.UserIdentifier == Context.UserIdentifier).FirstOrDefault();

            var connection = user.Connections.Where(c => c.ConnectionID == Context.ConnectionId).FirstOrDefault();
            var count = user.Connections.Count;

            if(count == 1) // A single connection: remove user
            {
                connectedUsers.Remove(user);

            }
            if (count > 1) // Multiple connection: Remove current connection
            {
                user.Connections.Remove(connection);
            }

            var list = (from _user in connectedUsers
                        select new { _user.Name }).ToList();

           await Clients.All.SendAsync("ReceiveInitializeUserList", list);

           await   Clients.All.SendAsync("MessageBoard", 
                      $"{Context.User.Identity.Name}  has left");

            // await Task.CompletedTask;
         
        }

       
        public override async Task OnConnectedAsync()
        {
            var user = connectedUsers.Where(cu => cu.UserIdentifier == Context.UserIdentifier).FirstOrDefault();

            if (user == null) // User does not exist
            {
                ConnectedUser connectedUser = new ConnectedUser
                {
                    UserIdentifier = Context.UserIdentifier,
                    Name = Context.User.Identity.Name,
                    Connections = new List<Connection> { new Connection { ConnectionID = Context.ConnectionId } }
                };

                connectedUsers.Add(connectedUser);
            }
            else
            {
                user.Connections.Add(new Connection { ConnectionID = Context.ConnectionId });
            }

            // connectedUsers.Add(new )

            await Clients.All.SendAsync("MessageBoard", $"{Context.User.Identity.Name}  has joined");

            await  Clients.Client(Context.ConnectionId).SendAsync("ReceiveUserName", Context.User.Identity.Name);
       
        
        }
  }
}

Hubs/ConnectedUser.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SignalRServerIdentityAuthentication.Hubs
{
    public class ConnectedUser
    {
        public string Name { get; set; }
        public string UserIdentifier { get; set; }

        public List<Connection> Connections { get; set; } 
    }
    public class Connection
    {
         public string ConnectionID { get; set; }
              
    }
}

There are two ways I know of.

  1. You need to keep a record of the users ConnectionId and map that to the User on the server.
Clients.Client(ConnectionId).SendAsync(...);
  1. When a user connects to the hub put them in a Group that is only for them. For example use their userId as the Group name. Then you can broadcast to that group and only that user will receive the message.
 Clients.Group($"userId").SendAsync(...);

Override OnConnectedAsync() on the hub to set up either method. Dont forget to override OnDisconnectedAsync() to tidy up any state variables or storage on disconnect. If done correctly this also has the benefit of tracking who is connected.

Use the [Authorize] attribute on the hub. See here for how with WASM.

The following code may help as well. (Hub)

var connectionId = Context.ConnectionId;

var userId = Context.UserIdentifier;
var applicationUser = await userManager.FindByIdAsync(userId);

IMHO: For security reasons I do not send the other users details to the client other than their display name. This is why I use [Authorize] attribute. Either technique obfuscates the other users details and connection to the hub.

You could simply just add another property to your Message class to identify the intended recipient of the message and if its blank send to all. Then in your hub use this property to choose between the broadcast methods.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM