azureasp.net-coresignalr

SignalR prevent user from opening multiple sessions in different tabs


I am using simple hook to send messages to the client from the server(particular user thats logged in) I noticed that user is able to open url in different tab which should be perfectly possible, however I am not happy about the fact it counts as an extra connection to azure signalr(cost issue), I was trying to close the old connection once the user opens the url in different tab. I can easily identify the user, initially I was trying to store the connection inside a dictionary but I noticed a weird pattern where every odd-numbered new tab (e.g., 1st, 3rd, 5th) does not disconnect the previous connection, while every even-numbered tab (e.g., 2nd, 4th) does. I tried to use memory cache instead of dictionary but it is behaving exactly like before.

<script>
    const userIdentifier = '@objectIdClaim';//users unique id

    const connection = new signalR.HubConnectionBuilder()
        .withUrl(`/webhub?username=${userIdentifier}`, { accessTokenFactory: () => this.loginToken })
        .configureLogging(signalR.LogLevel.Trace) 
        .build();

    // Handle server messages
    connection.on("SendMessageToUser", async (message) => {
        console.log("Received message from server:", message);
    });

    //handle opening new tab
    connection.on("ForceDisconnect", () => {
        connection.stop(); // Stop the SignalR connection
        showNotification("You have been disconnected, due to the new login");
    });

    // Stop connection when the user leaves the page
    window.addEventListener("beforeunload", async () => {
        await connection.stop();
        console.log("SignalR web connection stopped.");
    });

    // Start the connection
    (async () => {
        try {
            await connection.start();
            console.log("SignalR connection established.");
        } catch (e) {
            console.error("SignalR connection error:", e.toString());
        }
    })();

    function showNotification(message) {
        //pops up modal
    }

</script>

My web hub c# code

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Diagnostics;
using System.Threading.Tasks;

namespace Something
{
    [Authorize]
    public class Webhub : Hub
    {
        // track user connections
        private readonly IMemoryCache _cache;
        public Webhub(IMemoryCache cache)
        {
            _cache = cache;
        }
    
        public override async Task OnConnectedAsync()
        {
            var userId = Context.UserIdentifier;
            if (userId == null)
            {
                // No UserIdentifier available, disconnect this connection
                Context.Abort();
                return;
            }

            var connectionId = Context.ConnectionId;

            // Check if the user already has an active connection
            if (_cache.TryGetValue(userId, out string existingConnectionId))
            {
                if (existingConnectionId != connectionId) // New connection attempt
                {

                    Debug.WriteLine($"User {userId} connected BEFORE connection ID {existingConnectionId} connecting NOW {connectionId}");
                    // Notify and disconnect the old connection
                    await DisconnectOldConnection(existingConnectionId);
                }
            }

            _cache.Set(userId, connectionId, TimeSpan.FromHours(1));       

            Debug.WriteLine($"User {userId} connected with connection ID {connectionId}.");
            await base.OnConnectedAsync();
        }

        public override async Task OnDisconnectedAsync(Exception? exception)
        {
            var userId = Context.UserIdentifier;
            if (userId != null)
            {
                // Remove the user's connection when they disconnect
                _cache.Remove(userId);
                Debug.WriteLine($"User {userId} disconnected.");
            }

            await base.OnDisconnectedAsync(exception);
        }

        private async Task DisconnectOldConnection(string connectionId)
        {
            // This will disconnect the client with the given connectionId
            // The client handles disconnection on the client-side
            await Clients.Client(connectionId).SendAsync("ForceDisconnect");
        }
    }
}

here are the logs from this weird pattern

User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02 connecting NOW fUpzQDu-Itg7yu-z4reQ_QZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID fUpzQDu-Itg7yu-z4reQ_QZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02 connecting NOW t3mNN20gUdhOK-UXIH5TQQZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID t3mNN20gUdhOK-UXIH5TQQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02 connecting NOW 645t2jvOMbLgeJCVp6Gm5QZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID 645t2jvOMbLgeJCVp6Gm5QZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.

Any ideas or help would be appreciated, what could be causing this or is there another way of doing that? Maybe storing that in the database? Also not sure how that Webhub class is instantiated this is the way I register it in Startup.cs so maybe some scope issue?

 services.AddSignalR().AddAzureSignalR(Configuration["SignalUrl"]);
   services.AddSingleton<IUserIdProvider, MyCustomProvider>();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
                endpoints.MapRazorPages();

                endpoints.MapHub<Webhub>("/webhub");
            });

Solution

  • I managed to do this the following way, memory cache can be replaced by redis or anything you want

     [Authorize]
     public class Webhub : Hub
     {
         private readonly IMemoryCache _cache;
    
         public Webhub(IMemoryCache cache)
         {
             _cache = cache;
         }
    
         public override async Task OnConnectedAsync()
         {
             var userId = Context.UserIdentifier;
             if (userId == null)
             {
                 Context.Abort();
                 return;
             }
    
             var connectionId = Context.ConnectionId;
    
             // Fetch or initialize the list of connections for the user
             var connections = _cache.GetOrCreate(userId, entry =>
             {
                 entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);//adjust
                 return new List<string>();
             });
    
             // Ensure thread safety while updating the list
             lock (connections)
             {
                 // Call DisconnectOldConnections to handle any active connections
                 if (connections.Count > 0)
                 {
                     _ = DisconnectOldConnections(userId);
                 }
    
                 // Add the new connection
                 connections.Add(connectionId);
             }
    
             Console.WriteLine($"User {userId} connected with connection ID {connectionId}.");
             await base.OnConnectedAsync();
         }
    
         public override async Task OnDisconnectedAsync(Exception? exception)
         {
             var userId = Context.UserIdentifier;
             if (userId == null) return;
    
             if (_cache.TryGetValue(userId, out List<string>? connections))
             {
                 lock (connections)
                 {
                     connections.Remove(Context.ConnectionId);
                     if (connections.Count == 0)
                     {
                         // Remove the user from the cache when all connections are closed
                         _cache.Remove(userId);
                     }
                 }
    
                 Console.WriteLine($"User {userId} disconnected. Connection ID {Context.ConnectionId} removed.");
             }
    
             await base.OnDisconnectedAsync(exception);
         }
    
         private async Task DisconnectOldConnections(string userId)
         {
             if (_cache.TryGetValue(userId, out List<string>? connections))
             {
                 List<string> connectionsToDisconnect;
                 lock (connections)
                 {
                     connectionsToDisconnect = new List<string>(connections);
                     connections.Clear(); // Clear the connections for this user
                 }
    
                 // Notify all old connections to disconnect
                 foreach (var connectionId in connectionsToDisconnect)
                 {
                     try
                     {
                         await Clients.Client(connectionId).SendAsync("ForceDisconnect");
                         Console.WriteLine($"Disconnected old connection {connectionId} for user {userId}.");
                     }
                     catch (Exception ex)
                     {
                         Console.WriteLine($"Error disconnecting connection {connectionId}: {ex.Message}");
                     }
                 }
             }
         }
     }
    

    and simply on the client side

        connection.on("ForceDisconnect", () => {
            connection.stop(); // Stop the SignalR connection
            showNotification("You have been disconnected, due to the new login");
    //can redirect or close window here
        });
    

    also good to not use automatic reconnect because it would connect again and disconnect other window etc

    Tested that and it works fine even window opened in different browser

    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02.
    Disconnected old connection yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID 55qCVj8n8eFusbkN9reelgbSt_dwr02.
    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. Connection ID yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02 removed.
    Disconnected old connection 55qCVj8n8eFusbkN9reelgbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID OujWBtMtUnvY0QySE4MCsQbSt_dwr02.
    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. Connection ID 55qCVj8n8eFusbkN9reelgbSt_dwr02 removed.
    Disconnected old connection OujWBtMtUnvY0QySE4MCsQbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
    User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID NKbPBQ1EcT0KVA4oDcFxZAbSt_dwr02.