Reputation: 41
I hope someone could clear up a couple of concepts for me.
I use WinForms and Blazor Server and I desire to send a message to a user.
For that purpose, I've decided to try JWT authentication.
I would also prefer the Blazor server have this JWT authentication built in as per documentation
Blazor server:
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
// Add services to the container.
services.AddCors(option => option.AddPolicy("CorsPolicy", p => p.AllowAnyHeader().AllowAnyOrigin().AllowAnyMethod().AllowCredentials()));
//services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters() //Guessing this section is for security of the token - ensures that I'm the one that made it and such.
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("test")),
ValidIssuer = "test",
ValidAudience = "test"
};
options.Events = new()
{
OnMessageReceived = (context) =>
{
var path = context.HttpContext.Request.Path;
if (path.StartsWithSegments("/hubs")) // || path.StartsWithSegments("/token")
{
var accessToken = string.IsNullOrWhiteSpace(context.Request.Query["access_token"]) ? context.Request.Headers["Authorization"] : context.Request.Query["access_token"];
if (!string.IsNullOrWhiteSpace(accessToken))
{
//Real
context.Token = accessToken; //another guess - this is adding the accesstoken to the httpContext so it can be used somewhere else probably.
//Test attach claims to context. I want to be able to do this somewhere else though.
var claims = new Claim[]
{
new(ClaimTypes.Name, "myUserName"),
};
var identity = new ClaimsIdentity(claims);
context.Principal = new(identity);
context.Success();
}
}
return Task.CompletedTask;
},
OnAuthenticationFailed = (context) =>
{
Debug.WriteLine("OnAuthenticationFailed: " + context.Exception.Message);
return Task.CompletedTask;
}
};
});
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddSignalR()
.AddHubOptions<ChatHub>(options => options.EnableDetailedErrors = true);
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapBlazorHub();
app.MapHub<ChatHub>("/hubs/chathub");
app.MapFallbackToPage("/_Host");
Hub:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] //Same as: [Authorize(AuthenticationSchemes = "Bearer")]
public class ChatHub : Hub
{
public Task SendMessageAsync(string user, string message)
{
Debug.WriteLine(Context.UserIdentifier); //null
bool test1 = Context.User.Identity.IsAuthenticated; //false
string test2 = Context?.User?.Identity?.Name; //myUserName
return Clients.User(Context?.User?.Identity?.Name).SendAsync("ReceiveMessage", user, message); //This does then not work ofc.
}
}
Client:
HubConnection connection;
string url = "https://localhost:7041/hubs/chathub";
string token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
public Form1()
{
InitializeComponent();
connection = new HubConnectionBuilder()
.WithUrl(url, options =>
{
options.AccessTokenProvider = () => Task.FromResult(token);
})
.WithAutomaticReconnect()
.Build();
}
private async void HubConnectBtn_Click(object sender, EventArgs e)
{
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
this.Invoke(() =>
{
var newMessage = $"{user}: {message}";
MessagesLB.Items.Add(newMessage);
});
});
try
{
await connection.StartAsync();
MessagesLB.Items.Add("Connected!");
}
catch(Exception ex)
{
MessagesLB.Items.Add(ex.Message);
}
}
What I do not understand:
When connecting the Client, how do I pass username and password from the winform to the Blazor server, and how to I get access to the middleware authentication and return a JTW back to the client connection. Do I need to make an API on the Blazor Server, do a call and generate a JWT which I pass here: options.AccessTokenProvider = () => Task.FromResult(token);
or is there a more logical way?
I've looked into the AuthenticationStateProvider, but couldn't produce the results I wanted.
Is JWT authentication even intended for this, or is there a better alternative?
I'm using an already existing database filled with users that I'm going to access when this server is looking up if the username and password is correct. Meaning I want a "userService" or something like that which contains all users. For now it's fine to mock users, but I need to know where I can do this and swap it out with a DBconnection/context later on.
Any help would be most appreciated!
EDIT:
After some time I found a approach that did work as I wanted it. By using a custom Authentication scheme I could add a token containing user data from and to user/group which means I have complete control over the communication flow. A great benefit of this is that you never need to have a user database to check the authenticity of the user it self, I solved this by having baked into the token a salt/token value that only me and whom ever other person would integrate with the signalR relay server knows about. The Token then contains licensenumber, department no, user info etc to make it unique when registering.
SERVER SIDE:
public class MyAuthenticationHandler : AuthenticationHandler<MyCustomAuthenticationSchemeOptions>
{
public MyAuthenticationHandler(IOptionsMonitor<MyCustomAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
TokenModel tokenModel;
if (!Request.Headers.ContainsKey(HeaderNames.Authorization))
{
return Task.FromResult(AuthenticateResult.Fail("Header Not Found."));
}
AuthenticationHeaderValue auth;
if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out auth))
{
return Task.FromResult(AuthenticateResult.Fail("No authentication header"));
}
if (!auth.Scheme.Equals("Bearer"))
{
return Task.FromResult(AuthenticateResult.Fail("Authentication Scheme was not Bearer"));
}
//var header = Request.Headers[HeaderNames.Authorization].ToString();
//var tokenMatch = Regex.Match(header, MyAuthenticationSchemeConstants.MyToken);
if (!string.IsNullOrWhiteSpace(auth.Parameter))
{
//string[] token = header.Split(" ");
try
{
string parsedToken = Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter));
tokenModel = JsonConvert.DeserializeObject<TokenModel>(parsedToken);
}
catch(Exception ex)
{
Debug.WriteLine("Exception Occured while Deserializing: " + ex);
return Task.FromResult(AuthenticateResult.Fail("TokenParseException"));
}
if(tokenModel != null)
{
List<Claim> claims = new()
{
new Claim(ClaimTypes.Name, tokenModel.Name),
new Claim("Group", tokenModel?.GroupName)
};
if (tokenModel.UserId > 0)
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, tokenModel.UserId.ToString()));
}
var claimsIdentity = new ClaimsIdentity(claims,
nameof(MyAuthenticationHandler));
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);
// pass on the ticket to the middleware
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
return Task.FromResult(AuthenticateResult.Fail("Model is Empty"));
}
}
public class MyCustomAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}
I also use a UserSerivice (though not necessary) to relay some information like "user not connected", a work in progress. Just a addUser, getID, removeUser from a list of usermodel. This enables me to have the hub like this:
public class ChatHub : Hub
{
IUserService _userService;
public ChatHub(IUserService userService)
{
_userService = userService;
}
public async Task SendMessageAsync(int user, string message)
{
Debug.WriteLine(Context.UserIdentifier); //userID -> Claim.UserIdentifier
bool test1 = Context.User.Identity.IsAuthenticated; //true
string test2 = Context?.User?.Identity?.Name; //myUserName -> Claim.Name
if (_userService.GetById(user) == false)
await Clients.User(Context.UserIdentifier).SendAsync("ReceiveMessage", Context?.User?.Identity?.Name, $"user is not connected");
await Clients.User(user.ToString()).SendAsync("ReceiveMessage", Context?.User?.Identity?.Name, message);
}
public async Task SendMessageToGroupAsync(string groupname, string message)
{
await Clients.OthersInGroup(groupname).SendAsync("ReceiveMessage", groupname, message);
}
public async override Task OnConnectedAsync()
{
//TODO Register Context.UserIdentifier -
int conID = 0;
if(int.TryParse(Context.UserIdentifier, out conID) == true)
{
_userService.AddUser(conID, Context.ConnectionId);
}
await Groups.AddToGroupAsync(Context.ConnectionId, Context.User.Claims.First(c => c.Type == "Group").Value);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
int conID = 0;
if (int.TryParse(Context.ConnectionId, out conID) == true)
{
_userService.RemoveUser(conID, Context.ConnectionId); //TODO Should probably be async calls to service.
}
await base.OnDisconnectedAsync(exception); //According to https://github.com/dotnet/aspnetcore/issues/19043 users are removed from all groups by disconnecting. Test/resolve this.
}
}
Then in the program.cs you just add it like usual (the MyAuthenticationSchemeConstants are just the name and a random token value I've chosen for now) :
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
// Add services to the container.
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddAuthentication(options => options.DefaultAuthenticateScheme = MyAuthenticationSchemeConstants.MyAuthenticationSchemeName)
.AddScheme<MyCustomAuthenticationSchemeOptions, MyAuthenticationHandler>( MyAuthenticationSchemeConstants.MyAuthenticationSchemeName, options => { });
UserService _userService = new();
. . .
app.UseAuthentication();
app.UseAuthorization();
app.MapBlazorHub();
app.MapHub<ChatHub>("/hubs/chathub");
Client-Side example:
Just a winform with a couple of text fields and buttons using localhost for example purposes, but just swap out the url for a central hub url.
string url = "https://localhost:7185/hubs/chathub";
string token = "";
public Form1()
{
InitializeComponent();
SendBtn.Enabled = false;
//client = new(baseAddress);
}
private void BuildToken()
{
TokenModel tokenModel = new()
{
UserId = Int32.Parse(UserIDTB.Text),
Name = UsernameTB.Text,
GroupName = "Waiter",
EmailAddress = "[email protected]"
};
token = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(tokenModel)));
}
private async void HubConnectBtn_Click(object sender, EventArgs e)
{
BuildToken();
connection = new HubConnectionBuilder()
.WithUrl(url, options =>
{
options.AccessTokenProvider = () => Task.FromResult(token);
})
.WithAutomaticReconnect()
.Build();
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
this.Invoke(() =>
{
var newMessage = $"{user}: {message}";
MessagesLB.Items.Add(newMessage);
});
});
try
{
await connection.StartAsync();
SendBtn.Enabled = true;
HubConnectBtn.Enabled = false;
MessagesLB.Items.Add("Connected!");
}
catch (Exception ex)
{
MessagesLB.Items.Add(ex.Message);
}
}
private async void SendBtn_Click(object sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(RecieverTB.Text))
{
MessagesLB.Items.Add("Write a recipient");
return;
}
if (string.IsNullOrWhiteSpace(MessageTB.Text))
{
MessagesLB.Items.Add("Write a message");
return;
}
try
{
await connection.InvokeAsync("SendMessageAsync", Int32.Parse(RecieverTB.Text), MessageTB.Text);
}
catch (Exception ex)
{
MessagesLB.Items.Add(ex.Message);
}
}
I hope this can help someone else!
Side note - if anyone has an input on how I can pass the Task.FromResult text to my clients that would be great!
Upvotes: 2
Views: 2089
Reputation: 334
I use signalr and jwt and my startup is a little different:
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = Configuration["JWT: ValidAudience"],
ValidIssuer = Configuration["JWT: ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JWT: SecretKey"]))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
// If the request is for our hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
(path.StartsWithSegments("/api/hubs/")))
{
// Read the token out of the query string
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
As you see you just have to set context.Token. But the token that signalr sends is obtained from the api through the authentication process. As you said, you authenticate sending a post request to a route on your api which could be something like this:
[HttpPost("authenticate")]
public async Task<ActionResult<AuthenticateResponse>> Post([FromBody]
AuthenticateRequest request)
{
AuthenticateResponse response = new AuthenticateResponse();
var result = await signInManager.PasswordSignInAsync(request.User,
request.Password, true, false);
if (result.Succeeded)
{
var user = await userManager.FindByNameAsync(request.User);
string[] roles = (await userManager.GetRolesAsync(user)).ToArray();
response.UserId = user.Id;
response.UserName = user.UserName;
response.Roles = roles;
response.Token = Helpers.TokenService.CreateToken(user, roles,
configuration);
}
else response.Error = "Authentication Error";
return Ok(response);
}
Here is the TokenService:
public static class TokenService
{
private const double EXPIRE_HOURS = 10;
public static string CreateToken(ApplicationUser user, string[] roles,
IConfiguration configuration)
{
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id)
};
foreach (var userRole in roles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
var authSigningKey = new
SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:
SecretKey"]));
var token = new JwtSecurityToken(
issuer: configuration["JWT: ValidIssuer"],
audience: configuration["JWT: ValidAudience"],
expires: DateTime.Now.AddHours(EXPIRE_HOURS),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey,
SecurityAlgorithms.HmacSha256)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
And you have to derive your ApplicationdbContext from IdentityDbContext
Upvotes: 1