grantcolley / atlas

A .NET 8.0 Blazor framework for hosting and building Blazor applications using the Backend for Frontend (BFF) pattern.
MIT License
2 stars 1 forks source link
auth0 backendforfrontend blazor blazor-client blazor-server minimal-api net8 net80 oauth2 sqlite sqlite3 sqlserver wasm webapi webassembly

Alt text

.NET 8.0, Blazor, ASP.NET Core Web API, Auth0, FluentUI, FluentValidation, Backend for Frontend (BFF), Entity Framework Core, MS SQL Server, SQLite

A .NET 8.0 Blazor framework for hosting and building Blazor applications using the Backend for Frontend (BFF) pattern. It comes with authentication, authorisation, change tracking, and persisting structured logs to the database.

See the Worked Examples for step-by-step guidance on how to introduce new modules into the Atlas framework.

\ Build status

\ Alt text

Table of Contents

Setup the Solution

Multiple Startup Projects

In the Solution Properties, specify multiple startup projects and set the action for both Atlas.API Web API and Atlas.Blazor.Web.App Blazor application, to Start.

Alt text

Atlas.API Configuration

In the Atlas.API appsettings.json set the connection strings, configure Auth0 settings and generating seed data.

[!NOTE]
Read the next section on Authentication for how to configure Auth0 as the identity provider.

{
  "ConnectionStrings": {
    "DefaultConnection": ""   👈 set the Atlas database connection string
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  },
  "Serilog": {
    "Using": [ "Serilog.Sinks.MSSqlServer" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Error",
        "Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "MSSqlServer",
        "Args": {
          "connectionString": "",   👈set the Atlas database connection string for Serilogs MS SqlServer
          "tableName": "Logs",
          "autoCreateSqlTable": true,
          "columnOptionsSection": {
            "customColumns": [
              {
                "ColumnName": "User",
                "DataType": "nvarchar",
                "DataLength": 450
              },
              {
                "ColumnName": "Context",
                "DataType": "nvarchar",
                "DataLength": 450
              }
            ]
          }
        }
      }
    ]
  },
  "AllowedHosts": "*",
  "Auth0": {
    "Domain": "",                        👈specify the Auth0 domain
    "Audience": "https://Atlas.API.com"  👈specify the audience
  },
  "SeedData": {
    "GenerateSeedData": "true", 👈 set to true to create seed data including modules, categories, pages, users, permissions and roles.
    "GenerateSeedLogs":  "true" 👈 set to true to generate mock logs
  }
}

Atlas.Blazor.Web.App Configuration

In the Atlas.Blazor.Web.App appsettings.json configure Auth0 settings and specify the Atlas.API url.

[!NOTE]
Read the next section on Authentication for how to configure Auth0 as the identity provider.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  },
  "Serilog": {
    "Using": [ "Serilog.Sinks.MSSqlServer" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Error",
        "Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "MSSqlServer",
        "Args": {
          "connectionString": "",    👈set the Atlas database connection string for Serilogs MS SqlServer
          "tableName": "Logs",
          "autoCreateSqlTable": true,
          "columnOptionsSection": {
            "customColumns": [
              {
                "ColumnName": "User",
                "DataType": "nvarchar",
                "DataLength": 450
              },
              {
                "ColumnName": "Context",
                "DataType": "nvarchar",
                "DataLength": 450
              }
            ]
          }
        }
      }
    ]
  },
  "AllowedHosts": "*",
  "Auth0": {
    "Domain": "",                           👈specify the Auth0 domain
    "ClientId": "",                         👈specify the Auth0 ClientId
    "ClientSecret": "",                     👈specify the Auth0 ClientSecret
    "Audience": "https://Atlas.API.com"     👈specify the audience
  },
  "AtlasAPI": "https://localhost:44420"     👈specify the AtlasAPI url
}

Create the Database

Use the .NET CLI for Entity Framework to create your database and create your schema from the migration. In the Developer Powershell or similar, navigate to the Atlas.API folder and run the following command.

dotnet ef database update --project ..\..\data\Atlas.Migrations.SQLServer

Insert Seed Data

In the Atlas.API appsettings.json configuration file set GenerateSeedData and GenerateSeedLogs to true. This will populate the database with seed data at startup.

[!WARNING]
If "GenerateSeedData": "true" the tables in the Atlas database will be truncated and repopulated with seed data every time the application starts. Existing data will be permanently lost.

  "SeedData": {
    "GenerateSeedData": "true", 👈 set to true to create seed data including modules, categories, pages, users, permissions and roles.
    "GenerateSeedLogs":  "true" 👈 set to true to generate mock logs
  }

Authentication

Atlas is setup to use Auth0 as its authentication provider, although this can be swapped out for any provider supporting OAuth 2.0. With Auth0 you can create a free account and it has a easy to use dashboard for registering applications, and creating and managing roles and users.

Using the Auth0, register the Atlas.API Web API and Atlas.Blazor.Web.App Blazor application, and create a atlas-user role and users.

Create an Auth0 Role

In the Auth0 dashboard create a role called atlas-user. This role must be assigned to all users wishing to access the Atlas application.

[!IMPORTANT]
Atlas users must be assigned the atlas-user role in Auth0 to access the Atlas application.

Alt text

Create Auth0 Users

Create Auth0 users. The user's Auth0 email claim is mapped to the email of an authorised user in the Atlas database.

[!IMPORTANT]
Atlas users must be assigned the atlas-user role to access the Atlas application.

Alt text

Alt text

[!TIP] SeedData.cs already contains some pre-defined sample users with roles and permissions. Either create these users in Auth0, or amend the sample users in SeedData.cs to reflect those created in Auth0.

[!WARNING]
If "GenerateSeedData": "true" the tables in the Atlas database will be truncated and repopulated with seed data every time the application starts. Existing data will be permanently lost.

        private static void CreateUsers()
        {
            if (dbContext == null) throw new NullReferenceException(nameof(dbContext));

            users.Add("alice", new User { Name = "alice", Email = "alice@email.com" });
            users.Add("jane", new User { Name = "jane", Email = "jane@email.com" });
            users.Add("bob", new User { Name = "bob", Email = "bob@email.com" });
            users.Add("grant", new User { Name = "grant", Email = "grant@email.com" });

            foreach (User user in users.Values)
            {
                dbContext.Users.Add(user);
            }

            dbContext.SaveChanges();
        }

Alt text

Securing Atlas.API

The following article explains how to register and secure a minimal WebAPI with Auth0 with the relevant parts in the Atlas.API Program.cs.


//....existing code removed for brevity

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://{builder.Configuration["Auth0:Domain"]}";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = builder.Configuration["Auth0:Domain"],
            ValidAudience = builder.Configuration["Auth0:Audience"]
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy(Auth.ATLAS_USER_CLAIM, policy =>
    {
        policy.RequireAuthenticatedUser().RequireRole(Auth.ATLAS_USER_CLAIM);
    });

//....existing code removed for brevity

app.UseAuthentication();

app.UseAuthorization();

//....existing code removed for brevity

When mapping the minimal Web API methods add RequireAuthorization(Auth.ATLAS_USER_CLAIM), as can be seen here in AtlasEndpointMapper.cs.


//....existing code removed for brevity

app.MapGet($"/{AtlasAPIEndpoints.GET_CLAIM_MODULES}", ClaimEndpoint.GetClaimModules)
            .WithOpenApi()
            .WithName(AtlasAPIEndpoints.GET_CLAIM_MODULES)
            .WithDescription("Gets the user's authorized modules")
            .Produces<IEnumerable<Module>?>(StatusCodes.Status200OK)
            .Produces(StatusCodes.Status500InternalServerError)
            .RequireAuthorization(Auth.ATLAS_USER_CLAIM);  // 👈 add RequireAuthorization to endpoints

//....existing code removed for brevity

Securing Atlas.Blazor.Web.App

The following article explains how to register and add Auth0 Authentication to Blazor Web Apps.

Here are the relevant parts in the Atlas.Blazor.Web.App Program.cs.


//....existing code removed for brevity

builder.Services
    .AddAuth0WebAppAuthentication(Auth0Constants.AuthenticationScheme, options =>
    {
        options.Domain = builder.Configuration["Auth0:Domain"] ?? throw new NullReferenceException("Auth0:Domain");
        options.ClientId = builder.Configuration["Auth0:ClientId"] ?? throw new NullReferenceException("Auth0:ClientId");
        options.ClientSecret = builder.Configuration["Auth0:ClientSecret"] ?? throw new NullReferenceException("Auth0:ClientSecret");
        options.ResponseType = "code";
    }).WithAccessToken(options =>
    {
        options.Audience = builder.Configuration["Auth0:Audience"] ?? throw new NullReferenceException("Auth0:Audience");
    });

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<TokenHandler>();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();

//....existing code removed for brevity

app.MapGet("login", async (HttpContext httpContext, string redirectUri = @"/") =>
{
    AuthenticationProperties authenticationProperties = new LoginAuthenticationPropertiesBuilder()
            .WithRedirectUri(redirectUri)
            .Build();

    await httpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, authenticationProperties);
});

app.MapGet("logout", async (HttpContext httpContext, string redirectUri = @"/") =>
{
    AuthenticationProperties authenticationProperties = new LogoutAuthenticationPropertiesBuilder()
            .WithRedirectUri(redirectUri)
            .Build();

    await httpContext.SignOutAsync(Auth0Constants.AuthenticationScheme, authenticationProperties);
    await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
});

//....existing code removed for brevity

The following section in the article describes how the client authentication state is synced with the server authentication state using PersistingRevalidatingAuthenticationStateProvider.cs in Atlas.Blazor.Web.App and PersistentAuthenticationStateProvider.cs in Atlas.Blazor.Web.App.Client.

Finally, the following article describes how to call protected APIs from a Blazor Web App, including calling external APIs which requires injecting the access token into HTTP requests. This is handled by the TokenHandler.cs.

    public class TokenHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
    {
        private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor;

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if(_httpContextAccessor.HttpContext == null)  throw new NullReferenceException(nameof(_httpContextAccessor.HttpContext));

            string? accessToken = await _httpContextAccessor.HttpContext.GetTokenAsync("access_token").ConfigureAwait(false);

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            return await base.SendAsync(request, cancellationToken);
        }
    }

Log In

Clicking the Login button on the top right corner of the application will re-direct the user to the Auth0 login page. Once authenticated, the user is directed back to the application, and the navigation panel will display the Modules, Categories and Pages the user has permission to access.

Click the Login button on the top right corner to be redirected to the Auth0 login page.

Alt text

Authenticate in Auth0.

Alt text

The Auth0 callback redirects the authenticated user back to Atlas, and the navigation panel will display the Modules, Categories and Pages the user has permission to access.

Alt text

Authorization

Users, Roles and Permissions

Atlas users are maintained in the Atlas database. The user's email in the Atlas database corresponds to the email claim provided by Auth0 to authenticated users. When a user is authenticated, a lookup is done in the Atlas database to get the users roles and permissions. This will determine which modules, categories and pages the user has access to in the navigation panel. It will also provide more granular permissions in each rendered page e.g. read / write.

Creating, updating and deleting Atlas users, roles and permissions, is done in the Authorisation category of the Administration module.

[!TIP] The Authorisation category of the Administration module is only accessible to users who are members of the Admin-Read Role and Admin-Write Role. The Admin-Read Role gives read-only view of users, roles and permissions, while the Admin-Write Role permit creating, updating and deleting them.

Alt text

Here we see user Bob is assignd the roles Support Role and User Role.

Alt text

Here we see the role Support Role, the list of permissions it has been granted, and we can see Bob is a member of the role.

Alt text

Here we see the permission Support, and the roles that have been granted the Support permission.

Alt text

Navigation

Modules, Categories and Pages

Modules are applications, and can be related or unrelated to each other. Each module consists of one or more categories. Each category groups related pages. A page is a routable razor @page.

Creating, updating and deleting modules, categories and pages, is done in the Applications category of the Administration module.

[!TIP] Because each page must point to a routable razor @page, the Applications category of the Administration module is only accessible to users who are members of the Developer Role. i.e. creating, updating and deleting modules, categories and pages is a developer concern.

Each module, category and page in the Navigation panel has a permission, and are only accessible to users who have been assigned that permission via role membership.

Alt text

Here we see the Support module, the order it appears in the navigation panel, the permission required for it to appear in the navigation panel, and the icon that is displayed with it in the navigation panel. We see it has an Events category. We can also see highlighted in yellow how it appears in the navigation panel.

Alt text

Here we see the Events category, the module it belongs to, the order it appears under the module in the navigation panel, the permission required for it to appear in the navigation panel, and the icon that is displayed with it in the navigation panel. We also see it has a page called Logs.

Alt text

Here we see the Logs page, the category it belongs to, the order it appears under the category in the navigation panel, the permission required for it to appear in the navigation panel, and the icon that is displayed with it in the navigation panel. Crucially, we also see the route, which is the routable razor @page that it navigates to when the user clicks the page in the navigation panel.

Alt text

Here we can see the Logs.razor component, with its routable @page attribute.

[!IMPORTANT]
The route specified in the page must map to a valid @page attribute on a routable component.

@page "/Logs"
@using System.Text.Json
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [StreamRendering]

<PageTitle>Logs</PageTitle>

@if (_alert == null)
{
    <FluentCard>
        <FluentHeader>
            Logs
        </FluentHeader>

<!-- code removed for brevity -->

Support

[!TIP] The Support module, its categories and routable pages, are only accessible to users who are members of the Support Role.

[!NOTE] Members of the Support Role also have Admin-Read and Admin-Write permissions, permitting them to add, update and delete users.

Logging

Logs are persisted to the Logs table in the Atlas database and are viewable to members of the Support Role.

Here we can see mock logs created at startup when "GenerateSeedLogs": "true" is set in the Atlas.API's appsettings.json.

Alt text

Clicking on the log entry will display the full log details in a popup box.

Alt text

Audit

The ApplicationDbContext.cs uses EF Change Tracking to capture OldValue and NewValues from INSERT's, UPDATE's and DELETE's, for entities where their poco model class inherits from ModelBase.cs. Tracked changes can be queried in the Audit table of the Atlas database.

Alt text

More can be read here about change tracking in Entity Framework:

Worked Examples

Blazor Template

Create a Blazor Template module for the standard template WeatherForecast and Counter pages.

[!TIP] The code for this worked example can be found in the blazor-template branch.

  1. Add a new permission to Auth in Atlas.Core.

    public static class Auth
    {
        public const string ATLAS_USER_CLAIM = "atlas-user";
        public const string ADMIN_READ = "Admin-Read";
        public const string ADMIN_WRITE = "Admin-Write";
        public const string DEVELOPER = "Developer";
        public const string SUPPORT = "Support";
        public const string BLAZOR_TEMPLATE = "Blazor-Template"; // 👈 new Blazor-Template permission 
    }
  2. Create a new WeatherForecast class in Atlas.Core.

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
  3. Create a new WeatherEndpoints in Atlas.API.

    public class WeatherEndpoints
    {
        internal static async Task<IResult> GetWeatherForecast(IClaimData claimData, IClaimService claimService, ILogService logService, CancellationToken cancellationToken)
        {
            Authorisation? authorisation = null;
    
            try
            {
                authorisation = await claimData.GetAuthorisationAsync(claimService.GetClaim(), cancellationToken)
                    .ConfigureAwait(false);
    
                if (authorisation == null
                    || !authorisation.HasPermission(Auth.BLAZOR_TEMPLATE))
                {
                    return Results.Unauthorized();
                }
    
                var startDate = DateOnly.FromDateTime(DateTime.Now);
                var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
                IEnumerable<WeatherForecast> forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
                {
                    Date = startDate.AddDays(index),
                    TemperatureC = Random.Shared.Next(-20, 55),
                    Summary = summaries[Random.Shared.Next(summaries.Length)]
                });
    
                return Results.Ok(forecasts);
            }
            catch (AtlasException ex)
            {
                logService.Log(Core.Logging.Enums.LogLevel.Error, ex.Message, ex, authorisation?.User);
    
                return Results.StatusCode(StatusCodes.Status500InternalServerError);
            }
        }
    }
  4. Add a new AtlasAPIEndpoints constant in Atlas.Core.

    public static class AtlasAPIEndpoints
    {
        // existing code removed for brevity
    
        public const string GET_WEATHER_FORECAST = "getweatherforecast"; // 👈 new getweatherforecast endpoint constant 
    }
  5. Map the endpoint in ModulesEndpointMapper in Atlas.API.

    internal static class ModulesEndpointMapper
    {
        internal static WebApplication? MapAtlasModulesEndpoints(this WebApplication app)
        {
            // Additional module API's mapped here...
    
            app.MapGet($"/{AtlasAPIEndpoints.GET_WEATHER_FORECAST}", WeatherEndpoints.GetWeatherForecast)
                .WithOpenApi()
                .WithName(AtlasAPIEndpoints.GET_WEATHER_FORECAST)
                .WithDescription("Gets the weather forecast")
                .Produces<IEnumerable<WeatherForecast>?>(StatusCodes.Status200OK)
                .Produces(StatusCodes.Status500InternalServerError)
                .RequireAuthorization(Auth.ATLAS_USER_CLAIM);
    
            return app;
        }
    }
  6. Create interface IWeatherForecastRequests in Atlas.Requests.

    public interface IWeatherForecastRequests
    {
        Task<IEnumerable<WeatherForecast>?> GetWeatherForecastAsync();
    }
  7. Create class WeatherForecastRequests in Atlas.Requests.

    public class WeatherForecastRequests(HttpClient httpClient) : IWeatherForecastRequests
    {
        protected readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        protected readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web);
    
        public async Task<IEnumerable<WeatherForecast>?> GetWeatherForecastAsync()
        {
            return await JsonSerializer.DeserializeAsync<IEnumerable<WeatherForecast>?>
                (await _httpClient.GetStreamAsync(AtlasAPIEndpoints.GET_WEATHER_FORECAST)
                .ConfigureAwait(false), _jsonSerializerOptions).ConfigureAwait(false);
        }
    }
  8. Register the service WeatherForecastRequests in Program of Atlas.Blazor.Web.App.

    // existing code removed for brevity
    
    builder.Services.AddTransient<IWeatherForecastRequests, WeatherForecastRequests>(sp =>
    {
       IHttpClientFactory httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
       HttpClient httpClient = httpClientFactory.CreateClient(AtlasWebConstants.ATLAS_API);
       return new WeatherForecastRequests(httpClient);
    });
    
    WebApplication app = builder.Build();
    
    // existing code removed for brevity
  9. Create the Weather component in Atlas.Blazor.Web

    
    @page "/Weather"
    @attribute [StreamRendering]
    @rendermode @(new InteractiveServerRenderMode(prerender: false))
Weather Weather


This component demonstrates showing data.


@if (Forecasts == null) {

Loading...

} else { <FluentDataGrid TGridItem=Atlas.Core.Models.WeatherForecast Items="@Forecasts" Style="height: 600px;overflow:auto;" GridTemplateColumns="0.25fr 0.25fr 0.25fr 0.25fr" ResizableColumns=true GenerateHeader="GenerateHeaderOption.Sticky">

    <PropertyColumn Property="@(f => f.TemperatureC)" Sortable="true" />
    <PropertyColumn Property="@(f => f.TemperatureF)" Sortable="true" />
    <PropertyColumn Property="@(f => f.Summary)" Sortable="true" />
</FluentDataGrid>

}

@code { [Inject] public IWeatherForecastRequests? WeatherForecastRequests { get; set; }

private IEnumerable<WeatherForecast>? _forecasts;

public IQueryable<WeatherForecast>? Forecasts
{
    get
    {
        return _forecasts?.AsQueryable();
    }
}

protected override async Task OnInitializedAsync()
{
    if (WeatherForecastRequests == null) throw new NullReferenceException(nameof(WeatherForecastRequests));

    _forecasts = await WeatherForecastRequests.GetWeatherForecastAsync().ConfigureAwait(false);
}

}


10. Create the [Counter](https://github.com/grantcolley/atlas/blob/blazor-template/src/Atlas.Blazor.Web/Components/Pages/BlazorTemplate/Counter.razor) component in **Atlas.Blazor.Web**
```HTML+Razor
@page "/Counter"
@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>

<FluentLabel Typo="Typography.PageTitle">Counter</FluentLabel>

<br>

<FluentLabel Typo="Typography.PaneHeader">Current count: @currentCount</FluentLabel>

<br>

<FluentButton Appearance="Appearance.Accent" OnClick="@IncrementCount">Click me</FluentButton>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}
  1. Create a new user will@email.com in Auth0 and assign the user the atlas-user role. Alt text

  2. Create the permission Blazor-Template. Alt text

  3. Create the role Blazor Template Role and assign it the Blazor-Template permission. Alt text

  4. Create the user will@email.com and assign the Blazor Template Role role. Alt text

  5. Create the module Blazor Template. Alt text

  6. Create the category Templates and set the Module to Blazor Template. Alt text

  7. Create the page Weather and set the Category to Templates. Alt text

  8. Create the page Counter and set the Category to Templates. Alt text

  9. Log out, then log back in as user will@email.com Alt text

  10. In the navigation panel, user will@email.com only has permission to the Blazor Template module. Alt text

  11. Click on the Weather navigation link. Alt text

  12. Click on the Counter navigation link. Alt text

Notes

FluentDesignTheme Dark/Light

What the Fluent UI quick guide doesn't tell you is you must also add a reference to /_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css.

For the Blazor Web App project, add the reference to the top of the app.css file in wwwroot:

@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css';

For the Blazor WebAssembly stand alone project, add the reference to the index.html file in wwwroot.

<Link href="https://github.com/grantcolley/atlas/blob/main/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />

Backend for frontend