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.
- Getting started with SignalR
- SSL encryption for Websocket Secure WSS
- Websocket Authentication with Identity Server 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!
Very good article, good job!
ReplyDeletewell done, it help me so much
ReplyDelete