Introduction

My previous post showed how one might run multiple Blazor applications on Linux with Apache, and made a reference to an application I use to deploy Blazor apps to our local Linux server. A full review of that app would be too large for a single post, although I can show some aspects.

Although the Linux server I use is physically located in the same room as my development machine, I thought to explore Blazor security when creating my deployment app. This post shows the results of that endeavour.

The standard creation wizard for Blazor Web Apps available in Visual Studio contains an option for setting the Authentication type. If this is set to “Individual accounts”, username-password-based authentication is added to your app using the ASP.NET Core Identity framework. While the resulting app is fully functional, it is also quite complex, imposing a structure on application navigation and needing a server database for the usernames and roles.

I desired a much simpler approach for my app. I already had the ability to use a Linux command line to deploy my apps on the server. Once I had logged in to the server, Linux used my user settings to control access to the Apache configuration area and the use of commands such as systemctl. If my deployment app could leverage the same controls I would not need special database that I would need to maintain just for that purpose.

I had originally thought to modify the Authentication type “Individual accounts” app, overriding the necessary components to replace the username database with a “query” against the Linux password file. This turned out to be much harder than creating the security mechanism from scratch. I did make use of some of that generated code in my simplified app.

The “Individual accounts” app provides several features that I did not need:

  • Database for usernames and roles
  • Maintenance screens for that database
  • Registration mechanism for new accounts
  • Support for external login providers

Perhaps the feature that brings the most complexity is the least obvious – the fact that the generated app supports the persistence of the authentication state across page loads. This will be explained a bit further on.

Environment

The environment is the same as with the previous post:

  • Ubuntu 22.04.1 (LTS)
  • Apache 2.4.52 (Ubuntu)
  • dotnet 9.0
  • Windows 11, Visual Studio for development

Source Code

The source code for this post is available on GitHub.

The Demonstration App

A concrete example should help with understanding the key concepts. Perform the following:

  • Create a new Blazor Web App. I called mine BlazorSecurityDemo.
  • Select
    • Authentication type – None
    • Interactive render mode – Server
    • Interactivity location – Global
    • Include sample pages
    • I chose Do not include top-level statements. This is a personal preference and shouldn’t impact the result.

The resulting app includes three pages we will not need, but does include some handy infrastructure such as style sheets and the navigation bar.

  • Apply the same base URL fix as in the previous post. This gives us more flexibility in deploying the app later.
    • Remove the base directive in Components/App.razor.
    • Add @page “/home” to Components/Pages/Home.razor.
    • Modify the first NavLink element in Components/Layout/NavMenu.razor to use href=”home”.
  • In Components/Layout/NavMenu.razor, remove the NavLink elements that have href=”counter” and href=”weather”.
    • Counter.razor and Weather.razor may be deleted from Components/Pages.

Authentication and Authorization

In a Blazor app Authentication refers to the process of verifying a user’s identity, whereas Authorization refers to the process of controlling access to assets and functionality within the app. Our demo app will need some modifications in order to provide these processes.

In Program.cs, at the base of the project, register the authentication services:

C#
...    
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

builder.Services.AddAuthentication();
builder.Services.AddCascadingAuthenticationState();

var app = builder.Build();
...

This introduces an AuthenticationState to your app which you can use to hide or show items and to control what functionality is available. The AuthenticationState holds the active ClaimsPrincipal, which provides the ClaimsIdentity‘s that have been established for the user of the app. A user will probably have a primary identity from a login to a server. There may also be secondary identities such as those associated with security keys, for example. Each ClaimsIdentity will have a list of Claims, or facts associated with that identity. In our case, we will create a ClaimsIdentity from the validated Linux user account. The user name and groups related to that account will be captured as Claims.

We will need to add some logic to make this happen.

  • Create folder Authentication at the root of the project.
  • Create UserAuthenticationStateProvider.cs within it:
C#
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace BlazorSecurityDemo.Authentication
{
    public class UserAuthenticationStateProvider : AuthenticationStateProvider
    {
        private readonly AuthenticationState authenticationState = 
            new(new ClaimsPrincipal());

        public override Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            return Task.FromResult(authenticationState);
        }

        public async Task<bool> AuthenticateUserPasswordAsync(string username, string password)
        {
            // verify username and password

            IList<Claim> claims = [new Claim(ClaimTypes.Name, username)];

            var identity = new ClaimsIdentity(claims, "AuthenticateUser");
            var user = new ClaimsPrincipal(identity);

            NotifyAuthenticationStateChanged(
                Task.FromResult(new AuthenticationState(user)));

            return true;
        }

        public void Logout()
        {
            NotifyAuthenticationStateChanged(
                Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
        }
    }
}

The actual login code will be shown later on in this post. The ClaimsIdentity is created with an authentication type of “AuthenticateUser”. The actual value of this string doesn’t matter. The fact that it is not null means that the ClaimsIdentity will be marked as authenticated.

The UserAuthenticationStateProvider will need to be registered in Program.cs so that it will be used in the standard authentication process:

C#
...    
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

builder.Services.AddAuthentication();
builder.Services.AddCascadingAuthenticationState();

builder.Services.
    AddScoped<AuthenticationStateProvider, UserAuthenticationStateProvider>();

var app = builder.Build();
...

Our provider has been registered as a scoped service, which means that a separate instance will be created for each HTTP request. Since this app was created with render mode Server and interactivity location Global, interaction within the app including navigation is actually performed by modifying the DOM. No HTTP requests are issued after the initial page load. Any navigation outside of the app or forcing the reload of a page via the browser controls will cause the AuthenticationStateProvider to be recreated, losing the previously established authentication state. Duplicating the tab with a running app in Microsoft Edge will cause a new HTTP request to be issued for the new tab, which means that the app in the new tab will not inherit the authentication of the current app. This is an important consideration for the security of this app.

Logging In and Out

To allow a user to log in to the server, we can borrow some of the code from the “Individual accounts” Blazor Web App template.

Add some includes to Components/_Imports.razor:

Razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using System.ComponentModel.DataAnnotations
@using System.Security.Claims
@using System.Security.Principal
@using BlazorSecurityDemo
@using BlazorSecurityDemo.Authentication
@using BlazorSecurityDemo.Components

Add Components/Pages/StatusMessage.razor:

Razor
@if (!string.IsNullOrEmpty(Message))
{
    var statusMessageClass = Message.StartsWith("Error") ? "danger" : "success";
    <div class="alert alert-@statusMessageClass" role="alert">
        @Message
    </div>
}

@code {
    [Parameter]
    public string? Message { get; set; }
}

Add Components/Pages/Login.razor:

Razor
@page "/login"

@using System.Runtime.InteropServices;
@inject AuthenticationStateProvider AuthenticationStateProvider

<PageTitle>Log in</PageTitle>

<h1>Please log in to @serverName (@serverType) </h1>

<div>
    <section>
        <StatusMessage Message="@passwordErrorMessage"/>
        <EditForm Model="passwordInput" method="post" 
                  OnValidSubmit="LoginWithPassword" FormName="loginWithPassword">
            <DataAnnotationsValidator/>
            <h2>Use a server username and password to log in.</h2>
            <hr/>

            <ValidationSummary class="text-danger" role="alert"/>
            
            <div class="form-floating mb-3">
                <InputText @bind-Value="passwordInput.Username" 
                           class="form-control" autocomplete="username" 
                           aria-required="true" placeholder="username"/>
                <label for="username" class="form-label">Username</label>
                <ValidationMessage For="() => passwordInput.Username" class="text-danger"/>
            </div>

            <div class="form-floating mb-3">
                <InputText type="password" @bind-Value="passwordInput.Password" 
                           class="form-control" autocomplete="password" 
                           aria-required="true" placeholder="password"/>
                <label for="password" class="form-label">Password</label>
                <ValidationMessage For="() => passwordInput.Password" class="text-danger"/>
            </div>

            <div>
                <button type="submit" class="w-100 btn btn-log btn-primary">Log in</button>
            </div>
        </EditForm>
    </section>
</div>

@code {
    private string? serverName = Environment.MachineName;
    private string? serverType = RuntimeInformation.RuntimeIdentifier;
    private string? passwordErrorMessage = string.Empty;

    [SupplyParameterFromForm(FormName = "loginWithPassword")]
    private PasswordInputModel passwordInput { get; set; } = new();

    protected async Task LoginWithPassword()
    {
        if (AuthenticationStateProvider is UserAuthenticationStateProvider auth)
        {
            var success = await auth.AuthenticateUserPasswordAsync(
                passwordInput.Username, passwordInput.Password);

            if (success)
            {
                passwordErrorMessage = "Logged in";
            }
            else
            {
                passwordErrorMessage = "Error: invalid username/password";
            }
        }
    }

    private sealed class PasswordInputModel
    {
        [Required]
        public string Username { get; set; } = string.Empty;

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; } = string.Empty;
    }
}

I put in a heading describing the server being logged into to help with knowing when the app is running in a debugging environment.

Add a reference to the login page to the navigation bar Components/Layout/NavMenu.razor:

Razor
...
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
    <nav class="nav flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="home" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
            </NavLink>
        </div>

        <div class="nav-item px-3">
            <NavLink class="nav-link" href="login" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Login
            </NavLink>
        </div>
    </nav>
</div>
...

The login page does function, although there is no verification of the username and password and the results are difficult to see. We can create a simple status page to provide some feedback:

Razor
@page "/userDetails"

<PageTitle>UserDetails</PageTitle>

<h3>User Details</h3>

<AuthorizeView>
    <Authorized>
        <label>Claims for @context.User.Identity?.Name</label>
        <br />
        <table class="table">
            <thead>
                <tr>
                    <th>Type</th>
                    <th>Value</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var claim in context.User.Claims)
                {
                    <tr>
                        <td>@claim.Type</td>
                        <td>@claim.Value</td>
                    </tr>
                }
            </tbody>
        </table>
    </Authorized>
    <NotAuthorized>
        Please <a href="login">log in</a>.
    </NotAuthorized>
</AuthorizeView>

@code {

}

The AuthorizeView component controls what content is displayed depending on the authentication state. The Authorized component only displays its content if the current state is authorized, while the NotAuthorized component only displays its content if the state is not authorized. By default “authorized” means that the ClaimsPrincipal primary identity is authenticated. The AuthorizeView component exposes a context variable of type AuthenticationState. This is used in the authorized page content to display some information about the logged in user.

A page to log the user out completes the authentication suite. Create Components/Pages/Logout.razor:

Razor
@page "/logout"

@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager

<h3>Logout</h3>

@code {
    protected override void OnParametersSet()
    {
        if (AuthenticationStateProvider is UserAuthenticationStateProvider auth)
        {
            auth.Logout();
        }

        NavigationManager.NavigateTo("login");
    }
}

This page does not display. It logs the user out and redirects to the login page immediately. You could modify this to provide a warning message with a commit button.

The AuthorizeView component can also be used in the navigation bar to control what page links are visible. We can change it to only display the Login page when logged out and only display the Logout page when logged in.

Modify Components/Pages/Login.razor to redirect to the UserDetails page on success:

Razor
@page "/login"

@using System.Runtime.InteropServices;
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager

<PageTitle>Log in</PageTitle>
...
@code {
...
    protected async Task LoginWithPassword()
    {
        if (AuthenticationStateProvider is UserAuthenticationStateProvider auth)
        {
            var success = await auth.AuthenticateUserPasswordAsync(
                passwordInput.Username, passwordInput.Password);

            if (success)
            {
                passwordErrorMessage = "Logged in";
                NavigationManager.NavigateTo("userDetails");
            }
            else
            {
                passwordErrorMessage = "Error: invalid username/password";
            }
        }
    }
...

Modify Components/Layout/NavMenu.razor to selectively show the page links:

Razor
<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">BlazorSecurityDemo</a>
    </div>
</div>

<input type="checkbox" title="Navigation menu" class="navbar-toggler" />

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
    <nav class="nav flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="home" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
            </NavLink>
        </div>

        <AuthorizeView>
            <Authorized>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="userdetails" Match="NavLinkMatch.All">
                        <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> User Details
                    </NavLink>
                </div>

                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="logout">
                        <span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
                    </NavLink>
                </div>
            </Authorized>
            <NotAuthorized>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="login" Match="NavLinkMatch.All">
                        <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
                    </NavLink>
                </div>
            </NotAuthorized>
        </AuthorizeView>
    </nav>
</div>

I borrowed the extra navigation bar icons from the “Individual accounts” template and placed them in Components/Layout/NavMenu.razor.css:

CSS
...
.bi-house-door-fill-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}

.bi-plus-square-fill-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}

.bi-list-nested-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}

.bi-person-badge-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-badge' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'/%3E%3Cpath d='M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z'/%3E%3C/svg%3E");
}

.bi-person-fill-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-fill' viewBox='0 0 16 16'%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3C/svg%3E");
}

.bi-arrow-bar-left-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
}

.bi-detail-nav-menu {
    background-image: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-card-text' viewBox='0 0 16 16'%3E %3Cpath d='M14.5 3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5zm-13-1A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2z'/%3E %3Cpath d='M3 5.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5M3 8a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9A.5.5 0 0 1 3 8m0 2.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5'/%3E %3C/svg%3E")
}

.nav-item {
    font-size: 0.9rem;
    padding-bottom: 0.5rem;
}
...

Now the app seems to have a better task focus. Note that the use of AuthorizeView in NavMenu.razor only controls what is shown in the menu; it does not prevent the user from navigating to the other pages using the browser address bar.

At this point you can remove Components/Pages/Home.razor if you wish. You will need to remove the first NavLink section from Components/Layout/NavMenu.razor and also add @page "/" to the top of Components/Pages/Login.razor so that it displays by default when you navigate to the app.

User Authentication

The Login page shown previously simply authenticated the user regardless of the username and password supplied. We will now implement a true server login.

Until now the app works equally well in both the development and deployed environments. The addition of a username/password check to the app will complicate matters as the authentication methods for Windows (my development environment) and Linux (my deployment environment) are quite different. We can hide some of this complexity through the use of Blazor’s dependency injection mechanism.

Create a generic login handler Authentication/ServerAuthenticationManager.cs:

C#
namespace BlazorSecurityDemo.Authentication
{
    public class UserPrivilege(string name, bool isGranted)
    {
        public string Name { get; } = name;
        public bool IsGranted { get; } = isGranted;

        public override string ToString()
        {
            return $"{Name}: {IsGranted}";
        }
    }

    public class UserAuthenticationDetails(string username, bool isAuthenticated, 
        List<string> groups, List<UserPrivilege> privileges)
    {
        public string Username { get; } = username;
        public bool IsAuthenticated { get; } = isAuthenticated;
        public List<string> Groups { get; } = groups;
        public List<UserPrivilege> Privileges { get; } = privileges;
    }

    public abstract class ServerAuthenticationManager
    {
        public abstract Task<UserAuthenticationDetails> 
            CheckPasswordAsync(string username, string password);
    }
}

Create a Windows-specific login handler Authentication/WindowsAuthenticationManager.cs:

C#
namespace BlazorSecurityDemo.Authentication
{
    public class WindowsAuthenticationManager : ServerAuthenticationManager
    {
        public override Task<UserAuthenticationDetails> 
            CheckPasswordAsync(string username, string password)
        {
            return Task.FromResult(new UserAuthenticationDetails(
                username, 
                true, 
                ["BUILTIN\\Users"], 
                [new("SeShutdownPrivilege", false)])
                );
        }
    }
}

Create a Linux-specific login handler Authentication/LinuxAuthenticationManager.cs:

C#
namespace BlazorSecurityDemo.Authentication
{
    public class LinuxAuthenticationManager : ServerAuthenticationManager
    {
        public override Task<UserAuthenticationDetails> 
            CheckPasswordAsync(string username, string password)
        {
            return Task.FromResult(new UserAuthenticationDetails(
                username, 
                true, 
                ["users"], 
                [])
                );
        }
    }
}

The two concrete authentication managers are just stubs for now. We will get to the details soon.

Register the two managers in Program.cs:

C#
...
builder.Services.AddScoped<AuthenticationStateProvider, UserAuthenticationStateProvider>();

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
    builder.Services.AddScoped<ServerAuthenticationManager, WindowsAuthenticationManager>();
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
    builder.Services.AddScoped<ServerAuthenticationManager, LinuxAuthenticationManager>();
}

var app = builder.Build();
...

When the app is started the runtime environment is queried to determine which platform the app is running on so that the appropriate concrete manager may be used. This same approach could be used to create stubs for functions that don’t function in a non-Linux environment.

Now use the generic manager in Authentication/UserAuthenticationStateProvider.cs:

C#
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace BlazorSecurityDemo.Authentication
{
    public class UserAuthenticationStateProvider(
        ServerAuthenticationManager serverAuthenticationManager) : AuthenticationStateProvider
    {
        private readonly ServerAuthenticationManager serverAuthenticationManager = 
            serverAuthenticationManager;
        private readonly AuthenticationState authenticationState = new(new ClaimsPrincipal());
...
        public async Task<bool> AuthenticateUserPasswordAsync(string username, string password)
        {
            // verify username and password
            var details = 
                await serverAuthenticationManager.CheckPasswordAsync(username, password);
            if (!details.IsAuthenticated) return false;

            IList<Claim> claims = [
                new Claim(ClaimTypes.Name, username),
                .. details.Groups.ConvertAll(group => new Claim(ClaimTypes.Role, group)),
                .. details.Privileges.ConvertAll(privilege => new Claim(
                                                                 privilege.Name, 
                                                                 privilege.IsGranted.ToString(), 
                                                                 ClaimValueTypes.Boolean))
                ];

            var identity = new ClaimsIdentity(claims, "AuthenticateUser");
            var user = new ClaimsPrincipal(identity);

            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));

            return true;
        }
...

The ServerAuthenticationManager is injected via the constructor. When UserAuthenticationStateProvider is instantiated at the beginning of an HTTP request scope a concrete implementation of ServerAuthenticationManager is injected based on what was registered at app startup – WindowsAuthenticationManager if the app is running on the Windows platform, LinuxAuthenticationManager if the app is running on Linux. If the app is running on a platform that is not Windows or Linux (OSX, for example), this injection will fail as there would be no concrete service registered. Also note that the injected service must have a lifetime at least as long as consuming service. In this case, all services are registered as Scoped.

The UserAuthenticationDetails object returned by the CheckPasswordAsync() call contains an indication of whether the authentication step succeeded, and also contains lists of what groups and privileges are assigned to the user. The list of groups is converted to a list of Claims of type Role, which will prove useful later on. The list of privileges is converted to a list of Claims with the name the same as the privilege and a boolean value based the privilege setting. The Claims are all added to the ClaimsPrincipal used for the authentication state for the app.

We will need to validate a username and password on both Windows and Linux and also retrieve group and privilege information. We could use API calls on both platforms but this will be complicated, especially on Linux where the options for logging in are quite varied. A simpler approach is to use the command line for both.

Executing commands and retrieving the results from the command line is itself a complex task due to its asynchronous nature and the different output mechanisms for different commands. Fortunately, I managed to find some code online that I was able to modify to get the job done. Create Authentication/CommandProcessor.cs:

C#
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security;

namespace BlazorSecurityDemo.Authentication
{
    public static class CommandProcessor
    {
        public static Task<int> StartCommand(
            string command,
            string? shell = null,
            string? workingDirectory = null,
            string? userid = null,
            string? password = null,
            int? timeout = null,
            TextWriter? outputTextWriter = null,
            TextWriter? errorTextWriter = null)
        {
            string escaped = "\"" + command.Replace("\"", "\\\"") + "\"";
            string arguments;
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                shell ??= "cmd.exe";
                arguments = "/c " + escaped;
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            {
                shell ??= "/bin/sh";
                arguments = "-c " + escaped;
            }
            else
            {
                throw new NotImplementedException(
                    $"Operating system {RuntimeInformation.OSDescription} not supported"
                    );
            }

            return StartProcess(
                shell, arguments,
                workingDirectory: workingDirectory,
                userid: userid,
                password: password,
                timeout: timeout,
                outputTextWriter: outputTextWriter,
                errorTextWriter: errorTextWriter
                );
        }

        public static async Task<int> StartProcess(
            string filename,
            string arguments,
            string? workingDirectory = null,
            string? userid = null,
            string? password = null,
            int? timeout = null,
            TextWriter? outputTextWriter = null,
            TextWriter? errorTextWriter = null)
        {
            using var process = new Process()
            {
                StartInfo = new ProcessStartInfo()
                {
                    CreateNoWindow = true,
                    Arguments = arguments,
                    FileName = filename,
                    RedirectStandardOutput = outputTextWriter != null,
                    RedirectStandardError = errorTextWriter != null,
                    UseShellExecute = false,
                    WorkingDirectory = workingDirectory
                }
            };

            if (userid != null && password != null)
            {
                process.StartInfo.UserName = userid;

                SecureString pwd = new();
                foreach (var ch in password)
                {
                    pwd.AppendChar(ch);
                }

#pragma warning disable CA1416 // Validate platform compatibility
                process.StartInfo.Password = pwd;
#pragma warning restore CA1416 // Validate platform compatibility
            }

            var cancellationTokenSource = timeout.HasValue ?
                new CancellationTokenSource(timeout.Value) :
                new CancellationTokenSource();

            try
            {
                process.Start();
            }
            catch (Exception)
            {
                return -1;
            }

            var tasks = new List<Task>(3) {
                process.WaitForExitAsync(cancellationTokenSource.Token) 
                };
            if (outputTextWriter != null)
            {
                tasks.Add(ReadAsync(
                    x =>
                    {
                        process.OutputDataReceived += x;
                        process.BeginOutputReadLine();
                    },
                    x => process.OutputDataReceived -= x,
                    outputTextWriter,
                    cancellationTokenSource.Token));
            }

            if (errorTextWriter != null)
            {
                tasks.Add(ReadAsync(
                    x =>
                    {
                        process.ErrorDataReceived += x;
                        process.BeginErrorReadLine();
                    },
                    x => process.ErrorDataReceived -= x,
                    errorTextWriter,
                    cancellationTokenSource.Token));
            }

            await Task.WhenAll(tasks);
            return process.ExitCode;
        }

        public static Task WaitForExitAsync(
            this Process process,
            CancellationToken cancellationToken = default)
        {
            process.EnableRaisingEvents = true;

            var taskCompletionSource = new TaskCompletionSource<object?>();

            void handler(object? sender, EventArgs args)
            {
                process.Exited -= handler;
                _ = taskCompletionSource.TrySetResult(null);
            }

            process.Exited += handler;

            if (cancellationToken != default)
            {
                cancellationToken.Register(
                    () =>
                    {
                        process.Exited -= handler;
                        taskCompletionSource.TrySetCanceled();
                    });
            }

            return taskCompletionSource.Task;
        }

        public static Task ReadAsync(
            this Action<DataReceivedEventHandler> addHandler,
            Action<DataReceivedEventHandler> removeHandler,
            TextWriter textWriter,
            CancellationToken cancellationToken = default)
        {
            var taskCompletionSource = new TaskCompletionSource<object?>();

            DataReceivedEventHandler? handler = null;
            handler = new DataReceivedEventHandler(
                (sender, e) =>
                {
                    if (e.Data == null)
                    {
                        if (handler != null)
                            removeHandler(handler);
                        taskCompletionSource.TrySetResult(null);
                    }
                    else
                    {
                        textWriter.WriteLine(e.Data);
                    }
                });

            addHandler(handler);

            if (cancellationToken != default)
            {
                cancellationToken.Register(
                    () =>
                    {
                        removeHandler(handler);
                        taskCompletionSource.TrySetCanceled();
                    });
            }

            return taskCompletionSource.Task;
        }
    }
}

The userid and password parameters only work on Windows. This does allow us validate the username and password and fetch group and privilege details in one step on that platform. Modify Authentication/WindowsAuthenticationManager.cs:

C#
using System.Text.RegularExpressions;

namespace BlazorSecurityDemo.Authentication
{
    public partial class WindowsAuthenticationManager : ServerAuthenticationManager
    {
        public override async Task<UserAuthenticationDetails> 
            CheckPasswordAsync(string username, string password)
        {
            using StringWriter sw = new();

            string cmd = "whoami /groups /priv /fo csv";
            var rc = await CommandProcessor.StartCommand(
                cmd,
                userid: username,
                password: password,
                outputTextWriter: sw
                );
            if (rc != 0) return new(username, false, [], []);

            UserAuthenticationDetails result = new(username, true, [], []);
            ProcessWhoamiOutput(result, sw.ToString());

            return result;
        }

        private enum InputState
        {
            RUNNING, READING_GROUPS, READING_PRIVS
        };

        private static void ProcessWhoamiOutput(UserAuthenticationDetails response, string output)
        {
            var pattern = CSVOutput();
            var lines = output.Split("\n");
            var parseState = InputState.RUNNING;
            foreach (var line in lines)
            {
                var trimmed = line.Trim();
                switch (parseState)
                {
                    case InputState.RUNNING:
                        if (trimmed.StartsWith("\"Group Name"))
                        {
                            parseState = InputState.READING_GROUPS;
                        }
                        else if (trimmed.StartsWith("\"Privilege Name"))
                        {
                            parseState = InputState.READING_PRIVS;
                        }
                        break;
                    case InputState.READING_GROUPS:
                        if (trimmed == "")
                        {
                            parseState = InputState.RUNNING;
                            break;
                        }

                        Match groupMatch = pattern.Match(trimmed);
                        if (groupMatch.Success)
                            response.Groups.Add(groupMatch.Groups["val"].Value);
                        break;
                    case InputState.READING_PRIVS:
                        if (trimmed == "")
                        {
                            parseState = InputState.RUNNING;
                            break;
                        }

                        Match privsMatch = pattern.Match(trimmed);
                        if (privsMatch.Success)
                        {
                            var name = privsMatch.Groups["val"].Value;
                            privsMatch = privsMatch.NextMatch();
                            privsMatch = privsMatch.NextMatch();
                            var state = privsMatch.Groups["val"].Value;
                            response.Privileges.Add(new(name, state == "Enabled"));
                        }
                        break;
                    default:
                        break;
                }
            }
        }

        [GeneratedRegex(@"
            # Parse CSV line. Capture next value in named group: 'val'
            \s*                      # Ignore leading whitespace.
            (?:                      # Group of value alternatives.
              ""                     # Either a double quoted string,
              (?<val>                # Capture contents between quotes.
                [^""]*(""""[^""]*)*  # Zero or more non-quotes, allowing 
              )                      # doubled "" quotes within string.
              ""\s*                  # Ignore whitespace following quote.
            |  (?<val>[^,]*)         # Or... zero or more non-commas.
            )                        # End value alternatives group.
            (?:,|$)                  # Match end is comma or EOS", 
            RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace)]
        private static partial Regex CSVOutput();
    }
}

The Windows whoami command is issued requesting group and privilege information. By setting the userid and password in the StartCommand() call we simultaneously verify the password and run the whoami command under that userid in order to fetch the information for the correct user. I created a new password-based user account on my development machine to test this in debug mode.

Things are a bit more complicated on Linux, especially if you follow the norm and disable password-based logins in favour of key files. As Blazor server apps run as root on Linux by default, we have access to the shadow password file. This allows us to validate the provided password against what was originally set up for the user. We can then use the groups command to get the groups for the user. Linux doesn’t have a concept of privileges as with Windows, so that list will remain empty. Modify Authentication/LinuxAuthenticationManager.cs:

C#
namespace BlazorSecurityDemo.Authentication
{
    public class LinuxAuthenticationManager : ServerAuthenticationManager
    {
        public override async Task<UserAuthenticationDetails> 
            CheckPasswordAsync(string username, string password)
        {
            // validate password against what is in /etc/shadow

            using StringWriter shadowsw = new();
            using StringWriter errorssw = new();

            // watch out for embedded quote characters

            string cleanedUsername = username.Replace("'", "\\'");
            string cleanedPassword = password.Replace("'", "\\'");

            string cmd = $"grep '^{cleanedUsername}:' /etc/shadow";
            var rc = await CommandProcessor.StartCommand(
                cmd, 
                outputTextWriter: shadowsw, 
                errorTextWriter: errorssw
                );
            if (rc != 0)
            {
                return new(username, false, [], []);
            }

            var parts = shadowsw.ToString().Split(':');
            string encryptedPassword = parts[1];

            var passwordParts = encryptedPassword.Split("$");
            if (passwordParts[0].Length > 0)
            {
                return new(username, false, [], []);
            }

            using StringWriter enchsw = new();
            if (passwordParts[1] == "6")
            {
                // Validate using sha-512

                cmd = $"mkpasswd -m sha-512 '{cleanedPassword}' '{passwordParts[2]}'";
                rc = await CommandProcessor.StartCommand(
                    cmd, 
                    outputTextWriter: enchsw, 
                    errorTextWriter: errorssw
                    );
                if (rc != 0)
                {
                    return new(username, false, [], []);
                }
            }
            else if (passwordParts[1] == "y")
            {
                // Validate using yescript

                cmd = $"mkpasswd '{cleanedPassword}' " + 
                    "'${passwordParts[1]}${passwordParts[2]}${passwordParts[3]}'";
                rc = await CommandProcessor.StartCommand(
                    cmd, 
                    outputTextWriter: enchsw, 
                    errorTextWriter: errorssw
                    );
                if (rc != 0)
                {
                    return new(username, false, [], []);
                }
            }
            else
            {
                return new(username, false, [], []);
            }

            string generated = enchsw.ToString();
            if (generated.Trim() != encryptedPassword.Trim())
            {
                return new(username, false, [], []);
            }

            // get groups

            UserAuthenticationDetails response = new(username, true, [], []);
            if (!await GetGroups(response)) return new(username, false, [], []);

            return response;
        }

        private static async Task<bool> GetGroups(UserAuthenticationDetails response)
        {
            using StringWriter groupssw = new();
            using StringWriter errorssw = new();

            string cleanedUsername = response.Username.Replace("'", "\\'");
            string cmd = $"groups '{cleanedUsername}'";
            var rc = await CommandProcessor.StartCommand(
                cmd, 
                outputTextWriter: groupssw, 
                errorTextWriter: errorssw
                );
            if (rc != 0)
            {
                return false;
            }

            var groupList = groupssw.ToString().Split(" ");

            if (groupList.Length < 3 || groupList[0] != response.Username)
            {
                return false;
            }

            for (int gx = 3; gx < groupList.Length; gx++)
            {
                response.Groups.Add(groupList[gx]);
            }

            return true;
        }
    }
}

Depending on applied updates a Ubuntu 22.04.1 distribution will be using either sha-512 or yescript for password encryption. There will be an indicator at the beginning of the encrypted password string of which encryption method is used. You may need to install the whois package on your Linux system to get the mkpasswd command. You should try these commands from the Linux command line to familiarize yourself with the output and to check that they are supported.

We now have a way to validate a username and password and get the list of groups for the user, and for Windows, the list of privileges. We can test for the appropriate Claims when performing various tasks to ensure that the user would be able to accomplish these tasks when using the command line. Coding for the different possible Claims for the ClaimsPrincipal is cumbersome. A better way to handle this is to use policies. Add the following to Program.cs:

C#
...
// Add policies
builder.Services.AddAuthorizationBuilder().AddPolicy("admin", policy =>
{
    // either Windows admin or Linux sudoer
    policy.RequireRole(["BUILTIN\\Administrators", "sudo"]);
});

var app = builder.Build();
...

Since we mapped user groups to roles in UserAuthenticationManager earlier, we can use RequireRole to quickly set up policies. We can now use this policy to control what is displayed. Modify Components/Pages/UserDetails.razor:

Razor
...
<AuthorizeView Policy="admin">
    <label>Admin thing @actionMessage</label>
    <br />
    <button @onclick=DoAdminThing>Do admin thing</button>
</AuthorizeView>

@code {
    private string actionMessage = "is not done";

    [Inject]
    private IAuthorizationService authorizationService { get; set; } = default!;

    [CascadingParameter]
    private Task<AuthenticationState> authenticationStateTask { get; set; } = default!;

    private async Task DoAdminThing()
    {
        var user = (await authenticationStateTask).User;
        if ((await authorizationService.AuthorizeAsync(user, "admin")).Succeeded)
        {
            actionMessage = "is done";
        }
        else
        {
            actionMessage = $"cannot be done by {user.Identity?.Name}";
        }

    }
}

Please note that AuthorizeView only controls what is displayed on the page. To actually verify that the user does satisfy the policy, use the AuthorizeAsync method in a function that needs heightened authority, or check the authorization in an API method. This way if the admin action button is accidently exposed to a non-authorized user via a page edit, the function is still protected.

Conclusion

This post presented a simple approach for securing a web application. As application security is a complex subject you should carefully consider your requirements when designing a solution. Hopefully the concepts presented here will help you on your way.


0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *