5/16/2020 Admin

Implementing Roles In Blazor WebAssembly


image

You can implement roles in a Blazor WebAssembly application.

 

Install Blazor WebAssembly

image

Blazor has two modes, Blazor Server and Blazor WebAssembly.

For this tutorial, install the latest Blazor WebAssembly version.

 

Create The Application

image

Open Visual Studio.

 

image

Create a New Project.

 

image

Create a Blazor App.

 

image

Give the project the name RoleEnabledWebAssembly and click Create.

 

image

Select the Blazor WebAssembly template, select ASP.NET Core hosted and click the change link under Authentication.

 

image

Select Individual User Accounts and click OK.

 

image

Click Create.

 

image

The application will be created.

 

image

The application will display in Visual Studio.

 

Enable Role Support

image

Open the Startup.cs file in the Server project.

Change this code:

 

            services.AddDefaultIdentity<ApplicationUser>(options => 
            options.SignIn.RequireConfirmedAccount = true)
                .AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddIdentityServer()
                .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();

 

To this:

 

            services.AddDefaultIdentity<ApplicationUser>()
                .AddRoles<IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>();
            // Configure identity server to put the role claim into the id token 
            // and the access token and prevent the default mapping for roles 
            // in the JwtSecurityTokenHandler.
            services.AddIdentityServer()
                .AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options => {
                    options.IdentityResources["openid"].UserClaims.Add("role");
                    options.ApiResources.Single().UserClaims.Add("role");
                });
            // Need to do this as it maps "role" to ClaimTypes.Role and causes issues
            System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler
                .DefaultInboundClaimTypeMap.Remove("role");

 

image

Blazor dynamically creates a Register page, but, we need to override it so that we can add code that will:

 

  1. Create an Administrators role (if one does not already exist)
  2. Automatically add any user named Admin@Email to the Administrators role

 

Add an Account folder to the Areas/Identity/Pages folder.

Add the following files:

 

_ViewImports.cshtml

 

@using Microsoft.AspNetCore.Identity
@using RoleEnabledWebAssembly.Server.Areas.Identity
@using RoleEnabledWebAssembly.Server.Areas.Identity.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

 

Register.cshtml

 

@page
@model RegisterModel
@{
    ViewData["Title"] = "Register";
}
<h1>@ViewData["Title"]</h1>
<div class="row">
    <div class="col-md-4">
        <form asp-route-returnUrl="@Model.ReturnUrl" method="post">
            <h4>Create a new account.</h4>
            <hr />
            <div asp-validation-summary="All" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Input.Email"></label>
                <input asp-for="Input.Email" class="form-control" />
                <span asp-validation-for="Input.Email" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Input.Password"></label>
                <input asp-for="Input.Password" class="form-control" />
                <span asp-validation-for="Input.Password" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Input.ConfirmPassword"></label>
                <input asp-for="Input.ConfirmPassword" class="form-control" />
                <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
            </div>
            <button type="submit" class="btn btn-primary">Register</button>
        </form>
    </div>
    <div class="col-md-6 col-md-offset-2">
                                           
    </div>
</div>
@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

 

Register.cshtml.cs

 

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RoleEnabledWebAssembly.Server.Models;
namespace RoleEnabledWebAssembly.Server.Areas.Identity.Pages
{
    [AllowAnonymous]
    public class RegisterModel : PageModel
    {
        // *************************************************
        // This is the user that will be automatically 
        // made an Administrator
        // *************************************************
        const string ADMINISTRATOR_USERNAME = "Admin@email";
        const string ADMINISTRATION_ROLE = "Administrators";
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;
        public RegisterModel(
            SignInManager<ApplicationUser> signInManager,
            UserManager<ApplicationUser> userManager,
            RoleManager<IdentityRole> roleManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _roleManager = roleManager;
        }
        [BindProperty]
        public InputModel Input { get; set; }
        public string ReturnUrl { get; set; }
        public IList<AuthenticationScheme> ExternalLogins { get; set; }
        public class InputModel
        {
            [Required]
            [EmailAddress]
            [Display(Name = "Email")]
            public string Email { get; set; }
            [Required]
            [StringLength(100, ErrorMessage = 
                "The {0} must be at least {2} and at max {1} characters long.", 
                MinimumLength = 6)]
            [DataType(DataType.Password)]
            [Display(Name = "Password")]
            public string Password { get; set; }
            [DataType(DataType.Password)]
            [Display(Name = "Confirm password")]
            [Compare("Password", ErrorMessage = 
                "The password and confirmation password do not match.")]
            public string ConfirmPassword { get; set; }
        }
        public async Task OnGetAsync(string returnUrl = null)
        {
            ReturnUrl = returnUrl;
            ExternalLogins = 
                (await _signInManager.GetExternalAuthenticationSchemesAsync())
                .ToList();
        }
        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            ExternalLogins = 
                (await _signInManager.GetExternalAuthenticationSchemesAsync())
                .ToList();
            if (ModelState.IsValid)
            {
                var user = 
                    new ApplicationUser { 
                        UserName = Input.Email, 
                        Email = Input.Email };
                var result = 
                    await _userManager.CreateAsync(user, Input.Password);
                if (result.Succeeded)
                {
                    // Set confirm Email for user
                    user.EmailConfirmed = true;
                    await _userManager.UpdateAsync(user);
                    // ensure there is a ADMINISTRATION_ROLE
                    var RoleResult = 
                        await _roleManager.FindByNameAsync(ADMINISTRATION_ROLE);
                    if (RoleResult == null)
                    {
                        // Create ADMINISTRATION_ROLE Role
                        await _roleManager.CreateAsync(
                            new IdentityRole(ADMINISTRATION_ROLE));
                    }
                    if (user.UserName.ToLower() == ADMINISTRATOR_USERNAME.ToLower())
                    {
                        // Put admin in Administrator role
                        await _userManager.AddToRoleAsync(user, ADMINISTRATION_ROLE);
                    }
                    // Log user in
                    await _signInManager.SignInAsync(user, isPersistent: false);
                    return LocalRedirect(returnUrl);
                }
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }
            // If we got this far, something failed, redisplay form
            return Page();
        }
    }
}

 

Detect Users In Administrators Role

image

Open WeatherForecastController.cs and add:

 

[Authorize(Roles = "Administrators")]

 

image

This will only allow users in the Administrators role to call this controller.

 

image

Open the Index.razor page and change all the code to:

 

@page "/"
@using Microsoft.AspNetCore.Components.Authorization;
<AuthorizeView Roles="@ADMINISTRATION_ROLE">
    <p>You are an Administrator</p>
</AuthorizeView>
@code {
    [CascadingParameter]
    private Task<AuthenticationState> authenticationStateTask { get; set; }
    string ADMINISTRATION_ROLE = "Administrators";
}

 

This will display “You are an Administrator” only if the logged in user is in the Administrators role.

 

Test The Application

image

Hit F5 to run the application.

 

image

Click the button to Register a new user.

 

image

Create a user named Admin@Email.

 

image

If this is the first time you have created a user it will run migrations.

 

image

Complete the migrations by reloading the web browser.

 

image

You should be logged in (if not, click the Login link and log in as Admin@Email).

The code on the Index.razor page will detect that the user is an Administrator and indicate that.

 

image

When we go to the Fetch data page, that calls the WeatherForecastController.cs controller, it will work.

If you go to that page without logging in, or as another user who is not an Administrator, it will throw an error.

 

image

If we go to the F12 Dev tools in our web browser and look at the userinfo call in the Network tab, we see the user is in the Administrators role.

 

Multiple Roles For a User

If we add the following code to add two roles for a user named helpdeskadmin@email:

 

        var HelpDeskRoleResult =
            await _roleManager.FindByNameAsync("Helpdesk");
        if (HelpDeskRoleResult == null)
        {
            // Create "Helpdesk" Role
            await _roleManager.CreateAsync(
                new IdentityRole("Helpdesk"));
        }
        if (user.UserName.ToLower() == "helpdeskadmin@email")
        {
            // Put user in Administrator and Helpdesk roles
            await _userManager.AddToRoleAsync(user, ADMINISTRATION_ROLE);
            await _userManager.AddToRoleAsync(user, "Helpdesk");
        }

 

image

The Fetch data page, that validates the role membership server side, works…

 

image

But, the client side role validation code on the home page does not.

 

image

Even though the Network tools indicate that the user is properly in both roles.

 

image

The Admin@email account, that is only in one role, still works however.

 

image

Add a file called RolesClaimsPrincipalFactory.cs to the Client project with the following code:

 

// From: https://github.com/dotnet/AspNetCore.Docs/issues/17649
// By Javier Calvarro Nelson https://github.com/javiercn 
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
namespace RoleEnabledWebAssembly.Client
{
    public class RolesClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
    {
        public RolesClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor) : base(accessor)
        {
        }
        public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
            RemoteUserAccount account, RemoteAuthenticationUserOptions options)
        {
            var user = await base.CreateUserAsync(account, options);
            if (user.Identity.IsAuthenticated)
            {
                var identity = (ClaimsIdentity)user.Identity;
                var roleClaims = identity.FindAll(identity.RoleClaimType);
                if (roleClaims != null && roleClaims.Any())
                {
                    foreach (var existingClaim in roleClaims)
                    {
                        identity.RemoveClaim(existingClaim);
                    }
                    var rolesElem = account.AdditionalProperties[identity.RoleClaimType];
                    if (rolesElem is JsonElement roles)
                    {
                        if (roles.ValueKind == JsonValueKind.Array)
                        {
                            foreach (var role in roles.EnumerateArray())
                            {
                                identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
                            }
                        }
                        else
                        {
                            identity.AddClaim(new Claim(options.RoleClaim, roles.GetString()));
                        }
                    }
                }
            }
            return user;
        }
    }
}

 

image

Open the Program.cs file, in the Client project, and change:

 

            builder.Services.AddApiAuthorization();

 

to:

 

            // Call the factory 
            builder.Services.AddApiAuthorization()
                .AddAccountClaimsPrincipalFactory<RolesClaimsPrincipalFactory>();

 

image

Now it works properly.

 

Links

[Blazor][Wasm] Using roles with ApiAuthorization + Blazor

Secure an ASP.NET Core Blazor WebAssembly standalone app with the Authentication library

Azure AD Groups, Administrative Roles, and user-defined roles

Blazor WebAssembly 3.2.0 Release Candidate now available

An error has occurred. This application may no longer respond until reloaded. Reload 🗙