A complete SignalR with ASP Net Core example with WSS, Authentication, Nginx

SignalR with ASP Net Core

SignalR is a framework from ASP NET Core allowing us to establish a two way communication between client and server. This two way communication allows the client to send messages to the server but more importantly allows the server to push messages to the client.
SignalR makes use of Websocket when available else it falls back to SSE or pulling. Today we will focus on how to setup SignalR to work with WSS, Websocket secure and how we can authenticate the user requesting to connect to our SignalR hub via Webscoket.

  1. Getting started with SignalR
  2. SSL encryption for Websocket Secure WSS
  3. Websocket Authentication with Identity Server 4
  4. SignalR behind Nginx

1. Getting started with SignalR

The Hubs are the main components of SignalR. It is an abstraction of a two way communication available for both client and server. Public functions from the hub can be called from the server code and can be called from the client. The frontend NPM package @aspnet/signalr library makes the public functions available from Javascript for client side coding.

This example contains source code from the ASP NET Core official documentation.

1.1 SignalR Hub

Let’s start by creating our first hub by creating an empty project.
ASP NET Core 2.1 comes with SignalR built in so we can directly create a hub.

public class ChatHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, "SignalR Users");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception exception)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, "SignalR Users");
        await base.OnDisconnectedAsync(exception);
    }

    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

This class inherit from Hub, the base class provided by SignalR. It provides facilities to send messages to clients and groups and access the context of the request.
In this example, after being connected, the connection is added to a group called SignalR Users. This group can be used to send messages using await Clients.Group("SignalR Users").SendAsync("ReceiveMessage");. But in this example, we send the message to all users with Clients.All when it is received.

Next we need to configure SignalR and add it to the application builder in Startup.cs.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSignalR();
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.UseStaticFiles();

        app.UseSignalR(hubRouteBuilder => {
            hubRouteBuilder.MapHub<ChatHub>("/chathub");
        });

        app.UseMvc();
    }
}

We start to add SignalR in the services .AddSignalR() and next we register the hub in a path accessible with .MapHub<ChatHub>("/chathub");. This will make the communication with the ChatHub available onto localhost:5000/chathub.

1.2 Client side

Next we create a simple Razor page under /Pages/Index.cshtml together with its PageModel under /Pages/Index.cshtml.cs.

@page
@model Example.Pages.IndexModel
@{
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Index</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css" integrity="sha384-Smlep5jCw/wG7hdkwQ/Z5nLIefveQRIY9nfy6xoR1uRYBtpZgI6339F5dgvm/e9B" crossorigin="anonymous">
</head>
<body>
    <div class="container">
        <div class="row">
            <div class="col-6">
                <form>
                    <div class="form-group">
                        <label for="userInput">User</label>
                        <input type="text" class="form-control" id="userInput" aria-describedby="userHelp" placeholder="Enter User">
                    </div>
                    <div class="form-group">
                        <label for="messageInput">Message</label>
                        <input type="text" class="form-control" id="messageInput" aria-describedby="messageHelp" placeholder="Enter Message">
                    </div>
                    <button type="button" id="sendButton" class="btn btn-primary">Send Message</button>
                </form>
            </div>
            <div class="col-6">
                <ul id="messagesList"></ul>
            </div>
        </div>
    </div>
</body>
</html>

If you are not familiar with Razor pages, you can refer to my previous blog post on LibMan with Razor page.

We created two textboxes, one for the username and the other for the message. What we need to do next is to submit the message to SignalR Hub when the user click on the button. The message will then be dispatched to all clients as coded Clients.All in the hub.
We start first by adding SignalR by running npm install @aspnet/signalr --save. This downloads the source code under node_modules. For the sake of this example, we copy the signalr file from /node_modules/@aspnet/signalr/dist/browser/signalr.js and paste it under /wwwroot/libs/signalr.js.

Next we reference it from the index.cshtml:

<script src="~/libs/signalr.js"></script>

Referencing signalr.js provides access to signalR which then gives access to the HubConnectionBuilder. Using that, we can code the connection and sending/receiving messages from and to the hub:

<script>
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/chathub")
        .configureLogging(signalR.LogLevel.Trace)
        .build();

    connection.on("ReceiveMessage", (user, message) => {
        const li = document.createElement("li");
        li.textContent = user + " says " + message;
        document.getElementById("messagesList").appendChild(li);
    });

    connection.start().catch(err => console.error(err.toString()));

    document.getElementById("sendButton").addEventListener("click", event => {
        const user = document.getElementById("userInput").value;
        const message = document.getElementById("messageInput").value;
        connection.invoke("SendMessage", user, message).catch(err => console.error(err.toString()));
        event.preventDefault();
    });
</script>

We start by building a hub connection using the HubConnectionBuilder. We set the log level to Trace to print more logs in Chrome console. Next we register a handler on ReceiveMessage which is the function specified in the hub Clients.All.SendAsync("ReceiveMessage", user, message);. The arguments correspond to the function on the hub too with (user, message).
Lastly we start the connection, it is recommended to register handlers before starting the connection to avoid losing messages.

And that’s all we need, once we run, we should now be able to have a Websocket communication and send message to the hub and receive message from the hub. Here is the full code of the index.cshtml.

@page
@model Example.Pages.IndexModel
@{
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Index</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css" integrity="sha384-Smlep5jCw/wG7hdkwQ/Z5nLIefveQRIY9nfy6xoR1uRYBtpZgI6339F5dgvm/e9B" crossorigin="anonymous">
</head>
<body>
    <div class="container">
        <div class="row">
            <div class="col-6">
                <form>
                    <div class="form-group">
                        <label for="userInput">User</label>
                        <input type="text" class="form-control" id="userInput" aria-describedby="userHelp" placeholder="Enter User">
                    </div>
                    <div class="form-group">
                        <label for="messageInput">Message</label>
                        <input type="text" class="form-control" id="messageInput" aria-describedby="messageHelp" placeholder="Enter Message">
                    </div>
                    <button type="button" id="sendButton" class="btn btn-primary">Send Message</button>
                </form>
            </div>
            <div class="col-6">
                <ul id="messagesList"></ul>
            </div>
        </div>
    </div>

    <script src="~/libs/signalr.js"></script>
    <script>
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/chathub")
            .configureLogging(signalR.LogLevel.Trace)
            .build();

        connection.on("ReceiveMessage", (user, message) => {
            const li = document.createElement("li");
            li.textContent = user + " says " + message;
            document.getElementById("messagesList").appendChild(li);
        });

        connection.start().catch(err => console.error(err.toString()));

        document.getElementById("sendButton").addEventListener("click", event => {
            const user = document.getElementById("userInput").value;
            const message = document.getElementById("messageInput").value;
            connection.invoke("SendMessage", user, message).catch(err => console.error(err.toString()));
            event.preventDefault();
        });
    </script>
</body>
</html>

2. SSL encryption for Websocket Secure WSS

Just like HTTPS for HTTP, Websocket can be secured via SSL (WSS). When we run under http://localhost:5000 and open the chrome debugging console, we can see the information message telling that we have established connection with ws://localhost:5000/chathub. ws being the none secure websocket connection. When we switch to https://localhost:5001, we see that the connection establish is on wss://localhost:5001/chathub. wss being the secure websocket connection.

SingalR detects the scheme used and establish the proper websocket connection. If none secure, it will use ws and if secured, it will use wss.

Now that we know how SignalR works and how to have the connection encrypted, let’s see how we can have the communication protected by and authentication mechanism.

3. Websocket Authentication with Identity Server 4

So far we have created a hub and established connection which are anonymous. Because SignalR works on the same pipeline as any ASP NET Core Middleware, it also supports authentication using the [Authorize] attribute just like we would use on controllers.

[Authorize]
public class ChatHub : Hub
{ 
    ... 
}

After adding the authorize attribute, we hit the following error when running the application:

info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
      Request starting HTTP/1.1 POST http://localhost:5000/chathub/negotiate text/plain;charset=UTF-8 0
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed.
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.

It can also be seen from the Chrome debug console where the /ngotiate fails:

:5000/chathub/negotiate:1 Failed to load resource: the server responded with a status of 500 (Internal Server Error)

If you already have an authentication mechanism setup, you will be able to use SignalR with your authentication.
For our example, we will setup a simple Resource Owner Password with Identity Server 4 to demonstrate how SignalR can authenticate with bearer tokens.

3.1 Setup Identity Server 4

Start by downloading Identity Server 4 from Nuget, register the Identity Server services and add Identity Server to the app builder pipeline.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ... other configs

        services.AddIdentityServer()
            .AddDeveloperSigningCredential()
            .AddInMemoryClients(new[] {
                new Client {
                    ClientId = "my-app",
                    ClientName = "My App",
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    AllowedScopes = { "my-api" },
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword
                }
            })
            .AddInMemoryApiResources(new[] {
                new ApiResource("my-api", "SignalR Test API")
            })
            .AddInMemoryIdentityResources(new List<IdentityResource> {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email()
            })
            .AddInMemoryPersistedGrants()
            .AddTestUsers(new List<TestUser>{
                new TestUser {
                    SubjectId = "alice",
                    Username = "alice",
                    Password = "password",
                    Claims = new[] { new Claim("role", "admin") }
                }
            });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        ... other configs

        app.UseIdentityServer();
    }
}

If you are not familiar with Identity Server, you can check my previous blog post on Resource Owner Password flow with Identity Server 4 or Implicit flow with Identity Server 4.

With this configuration, we have setup a client application called my-app, an API resource called my-api and allowed the client to request for my-api scope which will give access to the whole API. We also have configured Identity resources for the user and have added one test user, alice. Our configuration of our identity server is now done, once we run we should be able to hit localhost:5000/.well-known/openid-configuration.

3.2 Setup API

Next we move on to secure our API. We start first by adding IdentityServer4.AccessTokenValidation package as it will be used to validate the bearer token. And we add the authentication to the services and register the authentication middleware early in the pipeline:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ... other configs

        services
            .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
            .AddIdentityServerAuthentication(options =>
            {
                options.Authority = "http://localhost:5000";
                options.RequireHttpsMetadata = false;
                options.ApiName = "my-api";
                options.NameClaimType = "sub";
                options.TokenRetriever = new Func<HttpRequest, string>(req =>
                {
                    var fromHeader = TokenRetrieval.FromAuthorizationHeader();
                    var fromQuery = TokenRetrieval.FromQueryString();
                    return fromHeader(req) ?? fromQuery(req);
                });
            });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.UseStaticFiles();

        app.UseAuthentication(); // <<== The authentication middleware is placed before SignalR and Mvc

        app.UseSignalR(hubRouteBuilder => {
            hubRouteBuilder.MapHub<ChatHub>("/chathub");
        });

        app.UseIdentityServer();

        app.UseMvc();
    }
}

When registering the authentication services, we specify the default scheme as Bearer with AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme). And we specify the details for Identity server like the authority address, the ApiName as we have configured previously, the NameClaimType so that ASP NET Core will have it’s User.Identity.Name filled in with the sub claim. And lastly we configured an alternative of token retrieval to the classic Authorization header.

options.TokenRetriever = new Func<HttpRequest, string>(req =>
{
    var fromHeader = TokenRetrieval.FromAuthorizationHeader();
    var fromQuery = TokenRetrieval.FromQueryString();
    return fromHeader(req) ?? fromQuery(req);
});

TokenRetrieval are static functions provided by IdentityModel.AspNetCore.OAuth2Introspection allowing us to retrieve the token either from the Authorization header or from a query string access_token parameter.

Here is the full Startup.cs:

using IdentityModel.AspNetCore.OAuth2Introspection;
using IdentityServer4.AccessTokenValidation;
using IdentityServer4.Models;
using IdentityServer4.Test;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Security.Claims;

namespace Example
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSignalR();

            services.AddMvc();

            services.AddIdentityServer()
                .AddDeveloperSigningCredential()
                .AddInMemoryClients(new[] {
                    new Client {
                        ClientId = "my-app",
                        ClientName = "my-app",
                        ClientSecrets = { new Secret("secret".Sha256()) },
                        AllowedScopes = { "my-api" },
                        AllowedGrantTypes = GrantTypes.ResourceOwnerPassword
                    }
                })
                .AddInMemoryApiResources(new[] {
                    new ApiResource("my-api", "SignalR Test API")
                })
                .AddInMemoryIdentityResources(new List<IdentityResource> {
                    new IdentityResources.OpenId(),
                    new IdentityResources.Profile(),
                    new IdentityResources.Email()
                })
                .AddInMemoryPersistedGrants()
                .AddTestUsers(new List<TestUser>{
                    new TestUser {
                        SubjectId = "alice",
                        Username = "alice",
                        Password = "password",
                        Claims = new[] { new Claim("role", "admin") }
                    }
                });

            services
                .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = "http://localhost:5000";
                    options.RequireHttpsMetadata = false;
                    options.ApiName = "my-api";
                    options.NameClaimType = "sub";
                    options.TokenRetriever = new Func<HttpRequest, string>(req =>
                    {
                        var fromHeader = TokenRetrieval.FromAuthorizationHeader();
                        var fromQuery = TokenRetrieval.FromQueryString();
                        return fromHeader(req) ?? fromQuery(req);
                    });
                });
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseAuthentication();

            app.UseStaticFiles();

            app.UseSignalR(hubRouteBuilder => {
                hubRouteBuilder.MapHub<ChatHub>("/chathub");
            });

            app.UseIdentityServer();

            app.UseMvc();
        }
    }
}

We now have a functional Identity Server able to deliver Bearer token and we have configured our API.

If we want to send message to particular authenticated users using Clients.User(userId).SendAsync(...), we need to provide tell SignalR where to find the userId in the claims by registering a IUserIdProvider:

public class NameUserIdProvider : IUserIdProvider
{
    public string GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst("sub")?.Value;
    }
}

We specify that the claim with the userId is the sub claim and we register it to the services:

services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

What we have left to do is configure our client to request a token.

3.3 Setup Client

What we want to do is to be able to retrieve a token for alice and establish a connection with the HubConnectionBuilder using the token.

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chathub", { accessTokenFactory: () => accessToken })
    .build();

To do that we start by installing IdentityModel. Next in the model code Index.cshtml.cs, we request for the token in order to be able to have it on the model.

public class IndexModel : PageModel
{
    public string Token { get; set; }

    public async Task OnGetAsync()
    {
        var disco = await DiscoveryClient.GetAsync("http://localhost:5000");
        var tokenClient = new TokenClient(disco.TokenEndpoint, "my-app", "secret");
        var tokenResponse = await tokenClient.RequestResourceOwnerPasswordAsync("alice", "password", "my-api");
        Token = tokenResponse.Json.Value<string>("access_token");
    }
}

Lastly we can now complete the HubConnectionBuilder token with the retrieved access token:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chathub", { accessTokenFactory: () => @Model.Token })
    .build();

Once we run now, we should be able to authenticate and send messages!

4. SignalR behind Nginx

Lastly if our server is behind nginx, we need to make sure to proxy the necessary headers:

proxy_http_version 1.1;
proxy_set_header   Connection $http_connection;
proxy_set_header   Upgrade $http_upgrade;

For example we could have the following configuration:

server {
    listen              443 ssl;
    listen              [::]:443 ssl;
    ssl_certificate     /mnt/c/ssl/localhost.crt;
    ssl_certificate_key /mnt/c/ssl/localhost.key;

    location / {
        proxy_pass          https://localhost:5001;
        include             /etc/nginx/proxy_params;
        proxy_http_version  1.1;
        proxy_cache_bypass  $http_upgrade;
        proxy_set_header    Connection $http_connection;
        proxy_set_header    Upgrade $http_upgrade;
    }
}

And that concludes today’s post.
The full source code is available on my GitHub https://github.com/Kimserey/signalr-core-sample/blob/master/Example/Startup.cs.

Conclusion

Today we saw how we could implement a simple chat with SignalR. We saw how SignalR worked and which technologies it relied on. We digged more into Websocket by looking at how we could serve Websocket on a secured channel and how we could authenticate Websocket with a Bearer token. We also configured a simple Identity server 4 Resource Owner password flow to demonstrate the authentication with SignalR. Lastly we saw how to configure Nginx to proxy the Websocket connection. Hope you liked this post, see you next time!

Comments

Post a Comment

Popular posts from this blog

Verify dotnet SDK and runtime version installed

SDK-Style project and project.assets.json