Add Json Web Token (JWT) Bearer Authentication to Blazor WebAssembly (Example #2)

Develop this application on Linux!

This example deals with both the server- and client-side implementation. It uses ASP.NET Core 3.1, but shoud be just the same for .NET 5 and later (like .NET 6).

Create a new Blazor WebAssembly (WASM) application called "Acme" in this example. Make sure to make it ASP.NET Core hosted so that the server project will be created and present.

IMPORTANT: The next step is to add the right NuGet package to the Server's project. Add the Microsoft.AspNetCore.Authentication.JwtBearer NuGet package to the "Acme.Server" server project. Make sure to choose a version compatible with your version of .NET (3.1.17 in this example).

The code below contains comments as to what is new and what is happening but mostly in regard to the JWT authorization. It does not get into Bootstrap or anything else unrelated to JWT.

Here's a tip about building these WebAssembly projects: always select Build and then Clean from the menu of Visual Studio 2019 before running the program. If this does not work then switch to the client (WASM) project and run it from there. Hopefully a future update will fix this or Visual Studio 2022 will have this bug fixed.

If this is the first time working with Blazor and JWT authentication then this simpler example may fit the bill better.

Modify the Shared Project "Acme.Shared"

In the shared project, create four new class files: Login.cs, Password.cs, Register.cs, Tracking.cs.

Add this code to the Register.cs file.

// Register.cs

namespace Acme.Shared
{
	public class RegisterAccount
	{
		public string Email { get; set; }
		public string Password { get; set; }
		public string Name { get; set; }
		public void Clear() => Email = Password = Name = null;
	}
	public class RegisterResult
	{
		public string Message { get; set; }
		public bool Success { get; set; }
	}
}

Add this code to the Login.cs file.

// Login.cs

namespace Acme.Shared
{
	public class LoginAccount
	{
		public string Email { get; set; }
		public string Password { get; set; }
		public void Clear() => Email = Password = null;
	}
	public class LoginResult
	{
		public string Message { get; set; }
		public string Email { get; set; }
		public string Name { get; set; }
		public string JWT { get; set; }
	}
}

Add this code to the Password.cs file.

// Password.cs

namespace Acme.Shared
{
	public class Password
	{
		public string CurrentPassword { get; set; }
		public string NewPassword { get; set; }
		public void Clear() => CurrentPassword = NewPassword = null;
	}
}

Add this code to the Tracking.cs file.

// Tracking.cs

namespace Acme.Shared
{
	public class Tracking
	{
		public System.DateTime Date { get; set; }
		public string Status { get; set; }
		public string Destination { get; set; }
		public string User { get; set; }
	}
}

Modify the Server Project "Acme.Server"

Here is the UserDatabase.cs file located in the server project. It is a crude example of a database for storing users. It is JSON and file-system based. At this point one should already be familiar with using the database of their choice. The decision not to use a database in this example is done to make the example focus purely on JWT. See this article for help using Microsoft's SQL Server database.

// UserDatabase.cs
using Microsoft.AspNetCore.Hosting;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json;
using System.IO;
using System.Threading;

namespace Acme.Server
{
	public class User
	{
		public string Email { get; set; }
		public string Password { get; set; }
		public string Name { get; set; }
	}
	public interface IUserDatabase
	{
		Task<User> AuthenticateUser(string email, string password, CancellationToken cancel);
		Task<User> AddUser(string email, string password, string name, CancellationToken cancel);
		Task<bool> ChangePassword(string email, string currentPassword, string newPassword, CancellationToken cancel);
		Task<bool> AdminChangePassword(string email, string newPassword, CancellationToken cancel);
	}
	public class UserDatabase : IUserDatabase
	{
		private readonly IWebHostEnvironment env;
		public UserDatabase(IWebHostEnvironment env) => this.env = env;
		private static string CreateHash(string password)
		{
			var salt = "997eff51db1544c7a3c2ddeb2053f052";
			var md5 = new HMACMD5(Encoding.UTF8.GetBytes(salt + password));
			byte[] data = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
			return System.Convert.ToBase64String(data);
		}
		public async Task<User> AuthenticateUser(string email, string password, CancellationToken cancel = default(CancellationToken))
		{
			if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
				return null;
			var path = Path.Combine(env.ContentRootPath, "Users");
			if (!Directory.Exists(path))
				return null;
			path = Path.Combine(path, email);
			if (!File.Exists(path))
				return null;
			var user = JsonSerializer.Deserialize<User>(await File.ReadAllTextAsync(path, cancel));
			if (user.Password != CreateHash(password))
				return null;
			return user;
		}
		public async Task<User> AddUser(string email, string password, string name, CancellationToken cancel = default(CancellationToken))
		{
			if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(name))
				return null;
			var path = Path.Combine(env.ContentRootPath, "Users"); // NOTE: THIS WILL CREATE THE "USERS" FOLDER IN THE SERVER'S ROOT!!!
			if (!Directory.Exists(path))
				Directory.CreateDirectory(path); // NOTE: MAKE SURE THERE ARE CREATE/WRITE PERMISSIONS
			path = Path.Combine(path, email);
			if (File.Exists(path))
				return null;
			var user = new User { Email = email, Password = CreateHash(password), Name = name };
			await File.WriteAllTextAsync(path, JsonSerializer.Serialize(user), cancel);
			return user;
		}
		public async Task<bool> ChangePassword(string email, string currentPassword, string newPassword, CancellationToken cancel = default(CancellationToken))
		{
			if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
				return false;
			var path = Path.Combine(env.ContentRootPath, "Users");
			if (!Directory.Exists(path))
				return false;
			path = Path.Combine(path, email);
			if (!File.Exists(path))
				return false;
			var user = JsonSerializer.Deserialize<User>(await File.ReadAllTextAsync(path, cancel));
			if(user.Password != CreateHash(currentPassword))
				return false;
			user.Password = CreateHash(newPassword);
			await File.WriteAllTextAsync(path, JsonSerializer.Serialize(user), cancel);
			return true;
		}
		public async Task<bool> AdminChangePassword(string email, string newPassword, CancellationToken cancel = default(CancellationToken)) // THIS IS USED FOR ADMINISTRATORS TO CHANGE THE PASSWORD
		{
			if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
				return false;
			var path = Path.Combine(env.ContentRootPath, "Users");
			if (!Directory.Exists(path))
				return false;
			path = Path.Combine(path, email);
			if (!File.Exists(path))
				return false;
			var user = JsonSerializer.Deserialize<User>(await File.ReadAllTextAsync(path, cancel));
			user.Password = CreateHash(newPassword);
			await File.WriteAllTextAsync(path, JsonSerializer.Serialize(user), cancel);
			return true;
		}
	}
}

Here is the Startup.cs file of the server project.

// Startup.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

using Microsoft.AspNetCore.Authentication.JwtBearer; // NOTE: THIS LINE OF CODE IS NEWLY ADDED
using Microsoft.IdentityModel.Tokens; // NOTE: THIS LINE OF CODE IS NEWLY ADDED
using System.IO.Compression; // NOTE: THIS LINE OF CODE IS NEWLY ADDED

namespace Acme.Server
{
	public class Startup
	{
		public Startup(IConfiguration configuration)
		{
			Configuration = configuration;
		}

		public IConfiguration Configuration { get; }

		public void ConfigureServices(IServiceCollection services)
		{

			services.AddControllersWithViews();
			services.AddRazorPages();

			// NOTE: THE FOLLOWING COMPRESSION RELATED LINES ARE NEWLY ADDED
			services.AddResponseCompression(options =>
			{
				options.Providers.Add<BrotliCompressionProvider>();
				options.EnableForHttps = true; // OPTIONAL - enable for sites that use HTTPS
			});
			services.Configure<BrotliCompressionProviderOptions>(options =>
			{
				options.Level = CompressionLevel.Optimal; // OPTIMAL is just that, not too fast or slow
			});

			// NOTE: LOCAL AUTHENTICATION CODE IS BELOW THIS LINE BUT INSIDE THIS BLOCK
			services.AddTransient<IUserDatabase, UserDatabase>();  // NOTE: LOCAL AUTHENTICATION ADDED HERE; AddTransient() IS OK TO USE BECAUSE STATE IS SAVED TO THE DRIVE

			// NOTE: THE FOLLOWING LINES ARE NEWLY ADDED
			services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
			{
				options.TokenValidationParameters = new TokenValidationParameters
				{
					ValidateAudience = true,
					ValidAudience = "acme.com",
					ValidateIssuer = true,
					ValidIssuer = "acme.com",
					ValidateLifetime = true,
					ValidateIssuerSigningKey = true,
					IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS YOUR SECRET KEY RIGHT HERE")) // NOTE: THIS SHOULD BE A SECRET KEY NOT TO BE SHARED
				};
			});
		}

		public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
		{
			app.UseResponseCompression();

			if (env.IsDevelopment())
			{
				app.UseDeveloperExceptionPage();
				app.UseWebAssemblyDebugging();
			}
			else
			{
				app.UseExceptionHandler("/Error");
				app.UseHsts();
			}

			app.UseHttpsRedirection();
			app.UseBlazorFrameworkFiles();
			app.UseStaticFiles();


			app.UseAuthentication(); // NOTE: LINE IS NEWLY ADDED


			app.UseRouting();


			app.UseAuthorization(); // NOTE: LINE IS NEWLY ADDED, NOTICE PLACEMENT


			app.UseEndpoints(endpoints =>
			{
				endpoints.MapRazorPages();
				endpoints.MapControllers();
				endpoints.MapFallbackToFile("index.html");
			});
		}
	}
}

Here is the authentication controller file located in the Controllers folder of the server project. It is named AuthController.cs and it will fill in the User.Identity.Name field for methods/controllers that are marked [Authorize]. The "SECRET KEY" should be the same in both this file and Startup.cs, so maybe consider saving it in and using a configuration file.

// AuthController.cs
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Acme.Shared;
using System.Threading;

namespace Acme.Server.Controllers
{
	[ApiController]
	public class AuthController : ControllerBase
	{
		private string CreateJWT(User user)
		{
			var secretkey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS YOUR SECRET KEY RIGHT HERE")); // NOTE: SAME KEY AS USED IN Startup.cs FILE
			var credentials = new SigningCredentials(secretkey, SecurityAlgorithms.HmacSha256);

			var claims = new[] // NOTE: could also use List<Claim> here
			{
				new Claim(ClaimTypes.Name, user.Email), // NOTE: this will be the "User.Identity.Name" value to retrieve the user name on the server side
				new Claim(JwtRegisteredClaimNames.Sub, user.Email),
				new Claim(JwtRegisteredClaimNames.Email, user.Email),
				new Claim(JwtRegisteredClaimNames.Jti, user.Email) // NOTE: this could a unique ID assigned to the user by a database
			};

			var token = new JwtSecurityToken(issuer: "acme.com", audience: "acme.com", claims: claims, expires: DateTime.Now.AddMinutes(60), signingCredentials: credentials);
			return new JwtSecurityTokenHandler().WriteToken(token);
		}

		private IUserDatabase userdb { get; }

		public AuthController(IUserDatabase userdb) => this.userdb = userdb;

		[HttpPost]
		[Route("api/v1/auth/register")]
		public async Task<RegisterResult> Post([FromBody] RegisterAccount reg, CancellationToken cancel)
		{
			try
			{
				User newuser = await userdb.AddUser(reg.Email, reg.Password, reg.Name, cancel);
				if (newuser != null)
					return new RegisterResult { Message = "Registration successful.", Success = true };
				return new RegisterResult { Message = "Registration unsuccessful.", Success = false };
			}
			catch (Exception ex)
			{
				return new RegisterResult { Message = ex.Message, Success = false };
			}
		}

		[HttpPost]
		[Route("api/v1/auth/login")]
		public async Task<LoginResult> Post([FromBody] LoginAccount log, CancellationToken cancel)
		{
			try
			{
				User user = await userdb.AuthenticateUser(log.Email, log.Password, cancel);
				if (user != null)
					return new LoginResult { JWT = CreateJWT(user), Email = user.Email, Name = user.Name };
				return new LoginResult { Message = "User/password not found." };
			}
			catch (Exception ex)
			{
				return new LoginResult { Message = ex.Message };
			}
		}

		[HttpPatch]
		[Authorize]
		[Route("api/v1/auth/password")]
		public async Task<bool> Patch([FromBody] Password pwd, CancellationToken cancel)
		{
			try
			{
				return await userdb.ChangePassword(User.Identity.Name, pwd.CurrentPassword, pwd.NewPassword, cancel);
			}
			catch
			{
				return false;
			}
		}
	}
}

Create a new class file named TrackingController.cs located in the Controllers folder of the server project.

// TrackingController.cs
using Acme.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Acme.Server.Controllers
{
	[ApiController]
	public class TrackingController : ControllerBase
	{
		private static readonly string[] Statuses = new[]
		{
			"Delayed", "Delivered", "Picked Up", "Exception", "On Hold", "En Route", "Rescheduled"
		};

		private static readonly string[] Destinations = new[]
		{
			"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware",
			"Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky",
			"Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi",
			"Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico",
			"New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
			"Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
			"Virginia", "Washington", "West Virginia", "Wyoming"
		};

		private readonly IWebHostEnvironment env;
		public TrackingController(IWebHostEnvironment env) => this.env = env;

		[HttpGet]
		[Authorize]
		[Route("api/v1/tracking")]
		public IEnumerable<Tracking> Get()
		{
			var rng = new Random();
			return Enumerable.Range(1, 50).Select(index => new Tracking
			{
				Date = DateTime.Now.AddDays(-rng.Next(0,30)),
				Status = Statuses[rng.Next(Statuses.Length)],
				Destination = Destinations[rng.Next(Destinations.Length)],
				User = User.Identity.Name
			});
		}
	}
}

Modify the Client Project "Acme.Client" (Blazor WASM)

Create a new class file named LocalStorage.cs in the "Acme.Client" root folder. This code compresses and saves values to the browser local storage.

// LocalStorage.cs
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Threading.Tasks;
using Microsoft.JSInterop;

namespace Utilities
{
	public interface ILocalStorage
	{
		/// <summary>
		/// Remove a key from browser local storage.
		/// </summary>
		/// <param name="key">The key previously used to save to local storage.</param>
		public Task RemoveAsync(string key);

		/// <summary>
		/// Save a string value to browser local storage.
		/// </summary>
		/// <param name="key">The key to use to save to and retrieve from local storage.</param>
		/// <param name="value">The string value to save to local storage.</param>
		public Task SaveStringAsync(string key, string value);

		/// <summary>
		/// Get a string value from browser local storage.
		/// </summary>
		/// <param name="key">The key previously used to save to local storage.</param>
		/// <returns>The string previously saved to local storage.</returns>
		public Task<string> GetStringAsync(string key);

		/// <summary>
		/// Save an array of string values to browser local storage.
		/// </summary>
		/// <param name="key">The key previously used to save to local storage.</param>
		/// <param name="values">The array of string values to save to local storage.</param>
		public Task SaveStringArrayAsync(string key, string[] values);

		/// <summary>
		/// Get an array of string values from browser local storage.
		/// </summary>
		/// <param name="key">The key previously used to save to local storage.</param>
		/// <returns>The array of string values previously saved to local storage.</returns>
		public Task<string[]> GetStringArrayAsync(string key);
	}

	public class LocalStorage : ILocalStorage
	{
		private readonly IJSRuntime jsruntime;
		public LocalStorage(IJSRuntime jSRuntime)
		{
			jsruntime = jSRuntime;
		}

		public async Task RemoveAsync(string key)
		{
			await jsruntime.InvokeVoidAsync("localStorage.removeItem", key).ConfigureAwait(false);
		}

		public async Task SaveStringAsync(string key, string value)
		{
			var compressedBytes = await Compressor.CompressBytesAsync(Encoding.UTF8.GetBytes(value));
			await jsruntime.InvokeVoidAsync("localStorage.setItem", key, Convert.ToBase64String(compressedBytes)).ConfigureAwait(false);
		}

		public async Task<string> GetStringAsync(string key)
		{
			var str = await jsruntime.InvokeAsync<string>("localStorage.getItem", key).ConfigureAwait(false);
			if (str == null)
				return null;
			var bytes = await Compressor.DecompressBytesAsync(Convert.FromBase64String(str));
			return Encoding.UTF8.GetString(bytes);
		}

		public async Task SaveStringArrayAsync(string key, string[] values)
		{
			await SaveStringAsync(key, values == null ? "" : string.Join('\0', values));
		}

		public async Task<string[]> GetStringArrayAsync(string key)
		{
			var data = await GetStringAsync(key);
			if (!string.IsNullOrEmpty(data))
				return data.Split('\0');
			return null;
		}
	}

	internal class Compressor
	{
		public static async Task<byte[]> CompressBytesAsync(byte[] bytes)
		{
			using (var outputStream = new MemoryStream())
			{
				using (var compressionStream = new GZipStream(outputStream, CompressionLevel.Optimal))
				{
					await compressionStream.WriteAsync(bytes, 0, bytes.Length);
				}
				return outputStream.ToArray();
			}
		}

		public static async Task<byte[]> DecompressBytesAsync(byte[] bytes)
		{
			using (var inputStream = new MemoryStream(bytes))
			{
				using (var outputStream = new MemoryStream())
				{
					using (var compressionStream = new GZipStream(inputStream, CompressionMode.Decompress))
					{
						await compressionStream.CopyToAsync(outputStream);
					}
					return outputStream.ToArray();
				}
			}
		}
	}
}

Modify the Program.cs file in "Acme.Client" project.

// Program.cs
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace Acme.Client
{
	public class Program
	{
		public static async Task Main(string[] args)
		{
			var builder = WebAssemblyHostBuilder.CreateDefault(args);

			// NOTE: THIS LINE IS NEWLY ADDED
			builder.Services.AddSingleton<Utilities.ILocalStorage, Utilities.LocalStorage>();

			builder.RootComponents.Add<App>("app");

			builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

			await builder.Build().RunAsync();
		}
	}
}

Add one line @using Acme.Shared to the _Imports.razor file to share the classes in that project with the Razor pages in the "Acme.Client" project.

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Acme.Client
@using Acme.Client.Shared

@using Acme.Shared

Razor Components in the Shared Folder

Here is the custom DataGrid component in the client project. It's just a generic tool used to display a table of data. It accepts a List<T> as the Items parameter. Name the file DataGrid.razor.

@typeparam TItem
@using System.Reflection

@if (Items != null)
{
	<div class="@ParentClassName">
		@if (info != null && info.Length > 0)
		{
			<table class="@TableClassName">
				<thead>
					<tr>
						@foreach (var member in info)
						{
							if (member.MemberType == MemberTypes.Property && !hideColumns.Contains(member.Name))
							{
								<th><a href="javascript:;" @onclick="@(()=>Sort(member.Name))">@member.Name</a></th>
							}
						}
					</tr>
				</thead>
				<tbody>
					@foreach (var item in Items)
					{
						<tr>
							@foreach (var member in info)
							{
								if (member.MemberType == MemberTypes.Property && !hideColumns.Contains(member.Name))
								{
									<td>@FormatFieldData(item.GetType().GetProperty(member.Name).GetValue(item))</td>
								}
							}
						</tr>
					}
				</tbody>
			</table>
		}
	</div>
}

@code {

	[Parameter]
	public List<TItem> Items { get; set; }

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

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

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

	private string[] hideColumns = { };
	[Parameter]
	public string HideColumns
	{
		get
		{
			return string.Join(',', hideColumns);
		}

		set
		{
			hideColumns = value.Split(',', StringSplitOptions.RemoveEmptyEntries);
		}
	}

	private MemberInfo[] info;

	private string FormatFieldData(object data)
	{
		if (data == null)
			return null;
		if (data is DateTime)
			return ((DateTime)data).ToString(DateFormatString);
		if (data is int || data is long || data is short)
			return ((long)data).ToString("0,0");
		if (data is uint || data is ulong || data is ushort)
			return ((ulong)data).ToString("0,0");
		if (data is double)
			return ((double)data).ToString("#.##");
		if (data is float)
			return ((float)data).ToString("#.##");
		return data.ToString();
	}

	protected override void OnParametersSet()
	{
		base.OnParametersSet();
		if (Items != null && Items.Count > 0)
		{
			Type type = Items[0].GetType();
			info = type.GetMembers();
		}
	}
	private string sortedField;
	private bool descending = true;
	private void Sort(string field)
	{
		if (sortedField == field)
			descending = !descending;
		sortedField = field;
		if (descending)
		{
			Items.Sort((a, b) =>
			{
				var obj = typeof(TItem).GetProperty(field).GetValue(a);
				if (obj is DateTime)
				{
					DateTime dta = (DateTime)typeof(TItem).GetProperty(field).GetValue(a);
					DateTime dtb = (DateTime)typeof(TItem).GetProperty(field).GetValue(b);
					return dta.ToString("yyyyMMddHHmmssfff").CompareTo(dtb.ToString("yyyyMMddHHmmssfff"));
				}
				else if (obj is byte || obj is sbyte || obj is short || obj is ushort || obj is int)
				{
					int ia = (int)typeof(TItem).GetProperty(field).GetValue(a);
					int ib = (int)typeof(TItem).GetProperty(field).GetValue(b);
					return ia - ib;
				}
				else
				{
					var ta = typeof(TItem).GetProperty(field).GetValue(a);
					var tb = typeof(TItem).GetProperty(field).GetValue(b);
					return ta.ToString().CompareTo(tb.ToString());
				}
			});
		}
		else
		{
			Items.Sort((b, a) =>
			{
				var obj = typeof(TItem).GetProperty(field).GetValue(a);
				if (obj is DateTime)
				{
					DateTime dta = (DateTime)typeof(TItem).GetProperty(field).GetValue(a);
					DateTime dtb = (DateTime)typeof(TItem).GetProperty(field).GetValue(b);
					return dta.ToString("yyyyMMddHHmmssfff").CompareTo(dtb.ToString("yyyyMMddHHmmssfff"));
				}
				else if (obj is byte || obj is sbyte || obj is short || obj is ushort || obj is int)
				{
					int ia = (int)typeof(TItem).GetProperty(field).GetValue(a);
					int ib = (int)typeof(TItem).GetProperty(field).GetValue(b);
					return ia - ib;
				}
				else
				{
					var ta = typeof(TItem).GetProperty(field).GetValue(a);
					var tb = typeof(TItem).GetProperty(field).GetValue(b);
					return ta.ToString().CompareTo(tb.ToString());
				}
			});
		}
	}
}

Here is the modified MainLayout.razor page in the client project. It has been greatly modified as it will be a cascading parameter in its child objects.

@inherits LayoutComponentBase
@inject Utilities.ILocalStorage LocalStorage
@inject NavigationManager nav
@inject HttpClient Http

	@* THIS ALLOWS IT TO BE A CASCADING PARAMETER *@
<CascadingValue Value="this">
	<div class="sidebar">
		<NavMenu />
	</div>
	<div class="main">
		<div class="top-row px-4">
			<div style="width:70px;text-align:right;" class="mb-1">
				@if (LoggedIn)
				{
					<button class="btn btn-sm btn-danger" title="Logout" @onclick="Logout"><span class="oi oi-account-logout"></span></button>
				}
				else
				{
					<a class="btn btn-sm btn-primary text-white" title="Register" href="/register"><span class="oi oi-pencil"></span></a>
					<button class="btn btn-sm btn-success" title="Login" @onclick="OpenLogin"><span class="oi oi-account-login"></span></button>
				}
			</div>
		</div>
		<div class="content px-4">
			@Body
		</div>
	</div>
</CascadingValue>

<div class="modal-backdrop fade @(show ? "show" : "") @(display ? "" : "d-none")"></div>
<div class="modal fade @(show ? "show" : "")  @(display ? "d-block" : "d-none")" tabindex="-1" role="dialog">
	<div class="modal-dialog" role="document">
		<div class="modal-content">
			<div class="modal-header">
				<h5 class="modal-title">Login Form</h5>
				<button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Cancel">
					<span aria-hidden="true">&times;</span>
				</button>
			</div>
			<form @onsubmit="SubmitLogonForm">
				<div class="modal-body">
					<div class="mb-3">
						<label for="loginEmail" class="form-label">Email</label>
						<input type="email" class="form-control" id="loginEmail" placeholder="mailbox@domain.com" autocomplete="off" required @bind-value="log.Email" @onkeypress="() => loginMessage = null" />
					</div>
					<div class="mb-3">
						<label for="loginPassword" class="form-label">Password</label>
						<input type="password" class="form-control" id="loginPassword" required @bind-value="log.Password" @onkeypress="() => loginMessage = null" />
					</div>
					<div class="mb-3 alert alert-danger @(string.IsNullOrWhiteSpace(loginMessage) ? "d-none" : "")" role="alert">@loginMessage</div>
				</div>
				<div class="modal-footer">
					<button type="submit" class="btn btn-success" disabled="@disableBtn">Login</button>
					<button type="button" class="btn btn-secondary" data-dismiss="modal" @onclick="Cancel">Cancel</button>
				</div>
			</form>
		</div>
	</div>
</div>

@code {

	private bool show, display, disableBtn;
	LoginAccount log = new LoginAccount();
	private string loginMessage, jwt, userEmail, userName;
	bool loggedIn;

	[Parameter]
	public bool LoggedIn
	{
		get { return loggedIn; }
		set { } // NOTE: don't allow this parameter to be set
	}
	[Parameter]

	public string JWT
	{
		get { return jwt; }
		set { } // NOTE: don't allow this parameter to be set
	}

	[Parameter]
	public string UserEmail
	{
		get { return userEmail; }
		set { } // NOTE: don't allow this parameter to be set
	}

	[Parameter]
	public string UserName
	{
		get { return userName; }
		set { } // NOTE: don't allow this parameter to be set
	}

	public async Task OpenLogin()
	{
		log.Clear();
		display = true;
		await Task.Delay(100);
		show = true;
	}

	public async Task Logout()
	{
		userEmail = userName = jwt = null;
		log.Clear();
		loggedIn = false;
		await LocalStorage.RemoveAsync("name");
		await LocalStorage.RemoveAsync("jwt");
		await LocalStorage.RemoveAsync("email");
	}

	private async Task<bool> SubmitLogonForm()
	{
		if (!string.IsNullOrEmpty(log.Email) && !string.IsNullOrEmpty(log.Password))
		{
			disableBtn = true;
			using (var msg = await Http.PostAsJsonAsync<LoginAccount>("/api/v1/auth/login", log, System.Threading.CancellationToken.None))
			{
				if (msg.IsSuccessStatusCode)
				{
					LoginResult result = await msg.Content.ReadFromJsonAsync<LoginResult>();
					if (!string.IsNullOrEmpty(result.JWT))
					{
						await Cancel();
						loggedIn = true;
						userEmail = result.Email;
						jwt = result.JWT;
						userName = result.Name;
						await LocalStorage.SaveStringAsync("email", result.Email);
						await LocalStorage.SaveStringAsync("jwt", result.JWT);
						await LocalStorage.SaveStringAsync("name", result.Name);
					}
					else
					{
						loginMessage = result.Message;
					}
				}
				disableBtn = false;
			}
		}
		return false;
	}

	private async Task Cancel()
	{
		loginMessage = null;
		show = false;
		await Task.Delay(500);
		display = false;
	}


	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();
		userEmail = await LocalStorage.GetStringAsync("email");
		jwt = await LocalStorage.GetStringAsync("jwt");
		userName = await LocalStorage.GetStringAsync("name");
		loggedIn = !string.IsNullOrEmpty(jwt);
	}
}

Here is the modified NavMenu.razor page. Only a link for "account" has been added.


<div class="top-row pl-4 navbar navbar-dark">
    <a class="navbar-brand" href="">Acme</a>
    <button class="navbar-toggler" @onclick="ToggleNavMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
</div>

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
	<ul class="nav flex-column">
		<li class="nav-item px-3">
			<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
				<span class="oi oi-home" aria-hidden="true"></span> Home
			</NavLink>
		</li>
		<li class="nav-item px-3">
			<NavLink class="nav-link" href="counter">
				<span class="oi oi-plus" aria-hidden="true"></span> Counter
			</NavLink>
		</li>
		<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>
		<li class="nav-item px-3">
			<NavLink class="nav-link" href="account">
				<span class="oi oi-@(mainLayout.LoggedIn ? "lock-unlocked" : "lock-locked")"></span> Account
			</NavLink>
		</li>
	</ul>
</div>

@code {
	[CascadingParameter]
	public MainLayout mainLayout { get; set; }

	private bool collapseNavMenu = true;

	private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;

	private void ToggleNavMenu()
	{
		collapseNavMenu = !collapseNavMenu;
	}
}

Razor Pages

Here is the Register.razor page for the client project.

@page "/register"
@inject HttpClient Http
@inject NavigationManager nav

<h3 class="my-4">Registration</h3>

<form @onsubmit="SubmitForm">
	<div class="mb-3">
		<p>Enter your information.</p>
	</div>
	<div class="mb-3">
		<label for="name" class="form-label">Name</label>
		<input type="text" class="form-control" id="name" autocomplete="off" required @bind-value="reg.Name" @onkeypress="() => message = null" />
	</div>
	<div class="mb-3">
		<label for="email" class="form-label">Email</label>
		<input type="email" class="form-control" id="email" placeholder="mailbox@domain.com" autocomplete="off" required @bind-value="reg.Email" @onkeypress="() => message = null" />
	</div>
	<div class="mb-3">
		<label for="pwd1" class="form-label">Password</label>
		<input type="password" class="form-control" id="pwd1" required @bind-value="reg.Password" @onkeypress="() => message = null" />
	</div>
	<div class="mb-3">
		<label for="pwd2" class="form-label">Confirm Password</label>
		<input type="password" class="form-control" id="pwd2" required @bind-value="confirmpwd" @onkeypress="() => message = null" />
	</div>
	<button type="submit" class="btn btn-primary" disabled="@disableBtn">Submit</button>
</form>

<div class="my-3 alert alert-@alertType @(string.IsNullOrWhiteSpace(message) ? "d-none" : "")" role="alert">@message</div>

@code {
	[CascadingParameter]
	public MainLayout mainLayout { get; set; }

	string alertType, message;
	bool disableBtn;

	RegisterAccount reg = new RegisterAccount();
	string confirmpwd;

	private async Task<bool> SubmitForm()
	{
		if (reg.Password.Length < 8)
		{
			alertType = "danger";
			message = "Passwords must be at least 8 characters.";
		}
		else if (reg.Password != confirmpwd)
		{
			reg.Password = null;
			confirmpwd = null;
			alertType = "danger";
			message = "Passwords do not match. Please try again.";
		}
		else
		{
			disableBtn = true;
			using (var msg = await Http.PostAsJsonAsync<RegisterAccount>("/api/v1/auth/register", reg, System.Threading.CancellationToken.None))
			{
				if (msg.IsSuccessStatusCode)
				{
					RegisterResult result = await msg.Content.ReadFromJsonAsync<RegisterResult>();
					if (result.Success)
					{
						alertType = "success";
						message = $"You have been registered. Login using your password and the email {reg.Email}.";
						reg.Clear();
						confirmpwd = null;
					}
					else
					{
						alertType = "danger";
						message = result.Message + " Try again with different values.";
					}
				}
				disableBtn = false;
			}

		}
		return false;
	}

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();

	}
}

Here is the Account.razor page for the client project. It gives login status and then has a few links to account only access pages.

@page "/account"
@inject Utilities.ILocalStorage LocalStorage
@inject HttpClient Http
@inject NavigationManager nav

@if (mainLayout.LoggedIn)
{
	<h3 class="my-4">Account</h3>
	<div class="alert alert-success" role="alert">Logged in as <b>@mainLayout.UserEmail (@mainLayout.UserName)</b></div>
	<a class="btn btn-primary" href="/account/tracking">Track Shipments</a>
	<a class="btn btn-primary" href="/account/password">Change Password</a>
	<button class="btn btn-danger" @onclick="mainLayout.Logout">Logout</button>
}
else
{
	<div class="alert alert-danger" role="alert">Please Login for access...</div>
	<button type="submit" class="btn btn-success" @onclick="mainLayout.OpenLogin">Login</button>
}
@code {
	[CascadingParameter]
	public MainLayout mainLayout { get; set; }


	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();
	}
}

Here is the AccountPassword.razor page for the client project. It allows the logged in user to change his password.


@page "/account/password"
@inject HttpClient Http
@inject NavigationManager nav

@if (mainLayout.LoggedIn)
{
	<h3 class="my-4">Change Password</h3>

	<form @onsubmit="SubmitForm">
		<div class="mb-3">
			<p>Enter your current password and the new password.</p>
		</div>
		<div class="mb-3">
			<label for="pwd1" class="form-label">Current Password</label>
			<input type="password" class="form-control" id="pwd1" required @bind-value="pwd.CurrentPassword" @onkeypress="() => message = null" />
		</div>
		<div class="mb-3">
			<label for="pwd2" class="form-label">New Password</label>
			<input type="password" class="form-control" id="pwd2" required @bind-value="pwd.NewPassword" @onkeypress="() => message = null" />
		</div>
		<div class="mb-3">
			<label for="pwd3" class="form-label">Confirm New Password</label>
			<input type="password" class="form-control" id="pwd3" required @bind-value="confirmpwd" @onkeypress="() => message = null" />
		</div>
		<button type="submit" class="btn btn-primary" disabled="@disableBtn">Submit</button>
	</form>

	<div class="my-3 alert alert-@alertType @(string.IsNullOrWhiteSpace(message) ? "d-none" : "")" role="alert">@message</div>
}
else
{
	<div class="alert alert-danger" role="alert">Please Login for access...</div>
	<button type="submit" class="btn btn-success" @onclick="mainLayout.OpenLogin">Login</button>
}

@code {
	[CascadingParameter]
	public MainLayout mainLayout { get; set; }

	bool disableBtn;
	private string alertType, message, confirmpwd;

	Password pwd = new Password();

	private async Task<bool> SubmitForm()
	{
		if (pwd.NewPassword.Length < 8)
		{
			alertType = "danger";
			message = "Passwords must be at least 8 characters.";
		}
		else if (pwd.NewPassword != confirmpwd)
		{
			pwd.NewPassword = null;
			confirmpwd = null;
			alertType = "danger";
			message = "Passwords do not match. Please try again.";
		}
		else
		{
			disableBtn = true;
			var requestMsg = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/auth/password");
			requestMsg.Headers.Add("Authorization", "Bearer " + mainLayout.JWT);
			requestMsg.Content = JsonContent.Create(pwd);
			var response = await Http.SendAsync(requestMsg);
			if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) // NOTE: THEN TOKEN HAS EXPIRED
			{
				await mainLayout.Logout();
				nav.NavigateTo("/account");
			}
			else if (response.IsSuccessStatusCode)
			{
				if(await response.Content.ReadFromJsonAsync<bool>())
				{
					alertType = "success";
					message = "Password successfully changed.";
					pwd.Clear();
					confirmpwd = null;
				}
				else
				{
					alertType = "danger";
					message = "Password not changed. You may have entered an incorrect current password.";
				}
			}
			disableBtn = false;
		}
		return false;
	}

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();

		if (!mainLayout.LoggedIn)
			nav.NavigateTo("/account");
	}

}

Here is the AccountTracking.razor page for the client project. It allows the logged in user to see track his shipments.

@page "/account/tracking"
@inject HttpClient Http
@inject NavigationManager nav

@if (mainLayout.LoggedIn)
{
	<h3 class="my-4">Tracking</h3>
	if (tracking == null)
	{
		<div class="alert alert-secondary" role="alert">@message</div>
	}
	else
	{
		<div class="text-end w-100">
			<button class="btn btn-sm btn-primary text-white" title="Refresh" href="javascript:;" @onclick="GetTracking"><span class="oi oi-loop-circular"></span></button>
		</div>
		<DataGrid Items="tracking" ParentClassName="table-responsive" TableClassName="table table-hover table-striped" DateFormatString="MM/dd/yyyy" />
	}
}
else
{
	<div class="alert alert-danger" role="alert">Please Login for access...</div>
	<button type="submit" class="btn btn-success" @onclick="mainLayout.OpenLogin">Login</button>
}

@code {
	[CascadingParameter]
	private MainLayout mainLayout { get; set; }

	string message;

	private List<Tracking> tracking;

	private async Task GetTracking()
	{
		tracking = null;
		message = "Loading...";
		var requestMsg = new HttpRequestMessage(HttpMethod.Get, "/api/v1/tracking");
		requestMsg.Headers.Add("Authorization", "Bearer " + mainLayout.JWT);
		var response = await Http.SendAsync(requestMsg);
		if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) // NOTE: THEN TOKEN HAS EXPIRED
		{
			await mainLayout.Logout();
			nav.NavigateTo("/account");
		}
		else if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
			tracking = new List<Tracking>();
		else if (response.IsSuccessStatusCode)
			tracking = await response.Content.ReadFromJsonAsync<List<Tracking>>();
		else
			message = response.StatusCode.ToString();
	}

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();

		if (mainLayout.LoggedIn)
			await GetTracking();
		else
			nav.NavigateTo("/account");
	}
}

Summary

That's it! The files Login.cs, Password.cs, Register.cs, Tracking.cs, UserDatabase.cs, AuthController.cs, TrackingController.cs, LocalStorage.cs, DataGrid.razor, Register.razor, Account.razor, AccountPassword.razor and AccountTracking.razor are all new, while Startup.cs, Program.cs (client project), MainLayout.razor and NavMenu.razor are simply (or not-so-simply) modified.

This is a lot of code but it is better to have all the code than to guess where exactly code snippets belong.


Cookies are simple text files stored on the user's computer. They are used for adding features and security to this site.
OK