mmaestro
mmaestro

Reputation: 11

.NET 8 Blazor Server - role authorization with Windows Authentication

I have a solution in .NET 7 that works ok, however I want to migrate to .NET 8 with new structure that MS presented and I am facing a lot of issues as authentication does not work the way I would like to.

So in AuthService I have a logic to get data from AD. I get there First name, Last Name, Email, Department based on the username I get in CustomServerAuthenticationStateProvider.GetAuthenticationStateAsync. With this approach I get SSO.

If user has not been on site yet I register him and create a record in database where I am later on able to assign him a proper roles that he needs for his work. If his username is already found in database then I just read his roles and assing them to ClaimsIdentity -> userWinIdentity.AddClaims(_userService.GetRolesClaims());

So I migrated everyting to new .NET8 solution and it works if I come to the page with clicking to link from main menu that navigates user to Profile.razor page. When I am there and press F5 to refresh the page then I get this error:

Access to host localhost is denied.
You do not have user rights to view this page.
HTTP 403 ERROR

Program.cs

using BlazorServer.Data;
using BlazorServer.Providers;
using BlazorServer.Services;
using BlazorServer.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using BlazorServer.Consts;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<DataContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});


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

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddControllers();
builder.Services.AddLocalization();
builder.Services.AddMudServices();

builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<TimeZoneService>();

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

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddHttpContextAccessor();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseHttpsRedirection();
app.UseRouting();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapControllers();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

var localizationOptions = new RequestLocalizationOptions()
        .SetDefaultCulture(CultureConsts.supportedCultures[0])
        .AddSupportedCultures(CultureConsts.supportedCultures)
        .AddSupportedUICultures(CultureConsts.supportedCultures);

app.UseRequestLocalization(localizationOptions);

app.Run();

CustomServerAuthenticationStateProvider.cs

public class CustomServerAuthenticationStateProvider : AuthenticationStateProvider, IHostEnvironmentAuthenticationStateProvider
{
    private readonly AuthService _authService;
    private readonly UserService _userService;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private Task<AuthenticationState> _authenticationStateTask;

    public CustomServerAuthenticationStateProvider(AuthService authService, UserService userService, IHttpContextAccessor httpContextAccessor)
    {
        _authService = authService;
        _userService = userService;
        _httpContextAccessor = httpContextAccessor;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        var userWinIdentity = (ClaimsIdentity)httpContext?.User.Identity!;

        if (userWinIdentity != null && userWinIdentity.IsAuthenticated && !string.IsNullOrEmpty(userWinIdentity.Name))
        {
            var username = userWinIdentity.Name.Split('\\').Last();
            User userObj = _authService.Authenticate(username);
            _userService.User = userObj;

            userWinIdentity.AddClaims(_userService.AddCustomUserClaims());
            userWinIdentity.AddClaims(_userService.GetRolesClaims());
        }

        var user = new ClaimsPrincipal(userWinIdentity);
        return await Task.FromResult(new AuthenticationState(user));
    }

    public void SetAuthenticationState(Task<AuthenticationState> authenticationStateTask)
    {
        _authenticationStateTask = authenticationStateTask ?? throw new ArgumentNullException(nameof(authenticationStateTask));
        NotifyAuthenticationStateChanged(_authenticationStateTask);
    }
}

UserService.cs

public List<Claim> AddCustomUserClaims()
{
    return
    [
        new Claim("FullName", User.FullName)
    ];
}

public List<Claim> GetRolesClaims()
{
    List<Claim> claims = new();

    foreach (var role in User.Roles)
        claims.Add(new Claim(ClaimTypes.GroupSid, role.Name));
// if I use ClaimTypes.Role then it does not work properly on Profile.razor, I do not know why. If someone has a clue please let me know
    
    return claims;
}

Profile.razor

@page "/profile"
@attribute [Authorize(Roles = $"{SecurityGroup.RegisteredUser}")]
@inject UserService userService
@rendermode RenderMode.InteractiveServer

<PageTitle>@localizer["Info portal"] | @localizer["Profile"]</PageTitle>

<AuthorizeView>
    @if (user != null)
    {
        <div class="container-fluid">
            <div class="row">
                <div class="col-xs-3 col-sm-3 col-md-3 col-lg-2" style="font-weight: bold;">
                    @localizer["Username"]
                </div>
                <div class="col-xs-9 col-sm-9 col-md-9 col-lg-10">
                    @user.Username
                </div>
            </div>
            <div class="row">
                <div class="col-xs-3 col-sm-3 col-md-3 col-lg-2" style="font-weight: bold;">
                    @localizer["First name"]
                </div>
                <div class="col-xs-9 col-sm-9 col-md-9 col-lg-10">
                    @user.FirstName
                </div>
            </div>
            <div class="row">
                <div class="col-xs-3 col-sm-3 col-md-3 col-lg-2" style="font-weight: bold;">
                    @localizer["Last name"]
                </div>
                <div class="col-xs-9 col-sm-9 col-md-9 col-lg-10">
                    @user.LastName
                </div>
            </div>
            <div class="row">
                <div class="col-xs-3 col-sm-3 col-md-3 col-lg-2" style="font-weight: bold;">
                    @localizer["Email"]
                </div>
                <div class="col-xs-9 col-sm-9 col-md-9 col-lg-10">
                    @user.Email
                </div>
            </div>
            <div class="row">
                <div class="col-xs-3 col-sm-3 col-md-3 col-lg-2" style="font-weight: bold;">
                    @localizer["Department"]
                </div>
                <div class="col-xs-9 col-sm-9 col-md-9 col-lg-10">
                    @user.Department
                </div>
            </div>
            
        </div>
    }
</AuthorizeView>


@code {
    User user = new();

    protected override async Task OnInitializedAsync()
    {
        user = userService.User;
    }

}

User.cs

public class User
{
    public User()
    {
        Roles = new HashSet<Role>();
    }
    [Key]
    public int Id { get; set; }
    public string Username { get; set; } = string.Empty;
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Department { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string PersonalNr { get; set; } = string.Empty;

    public virtual ICollection<Role> Roles { get; set; }

}

I set following properties to be able to get user that is logged in windows acount: Double click on project:

  <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
**<UseWindowsService>true</UseWindowsService>**

Right click on project -> properties -> debug -> open debug launch profiles -> Enable Windows Authentication

The issue is that CustomServerAuthenticationStateProvider is not triggered on page refresh. I was checking already everywhere and checking a lot of different approaches but none of them work, so I can't figure out what I need to do to solve that.

There is also one mistery that I do not know the answer. In UserService I have a method to add claims based on roles that user has defined in database. If I add them like that: claims.Add(new Claim(ClaimTypes.GroupSid, role.Name)); then authorization works in production.

If I fake a login on my development machine like that:

userWinIdentity = new ClaimsIdentity(new[]
{
    new Claim(ClaimTypes.Name, "username"),
}, "Fake authentication type");

then I need to update the code in UserService and use it like that claims.Add(new Claim(ClaimTypes.Role, role.Name));

This is like that in .NET 7. If anyone knows why it behaves differently in development and production please let me know.

So any help would be greatly appreciated.

EDIT: I was playing arround and I figure out that from what I get from template I changed Routes.razor, basically I added even though it should not be needed. If I remove that piece of code then my code does not even work.

<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

In that case I get that error:

An unhandled exception occurred while processing the request.
NullReferenceException: Object reference not set to an instance of an object.
InfoPortal.Components.Layout.UserButton.<BuildRenderTree>b__0_5(RenderTreeBuilder __builder2)

Stack Query Cookies Headers Routing
NullReferenceException: Object reference not set to an instance of an object.
InfoPortal.Components.Layout.UserButton.<BuildRenderTree>b__0_5(RenderTreeBuilder __builder2)
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddContent(int sequence, RenderFragment fragment)
Microsoft.AspNetCore.Components.Authorization.AuthorizeViewCore.BuildRenderTree(RenderTreeBuilder builder)
Microsoft.AspNetCore.Components.ComponentBase.<.ctor>b__6_0(RenderTreeBuilder builder)
Microsoft.AspNetCore.Components.Rendering.ComponentState.RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception renderFragmentException)
Microsoft.AspNetCore.Components.RenderTree.Renderer.HandleExceptionViaErrorBoundary(Exception error, ComponentState errorSourceOrNull)
Microsoft.AspNetCore.Components.RenderTree.Renderer.RenderInExistingBatch(RenderQueueEntry renderQueueEntry)
Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessPendingRender()
Microsoft.AspNetCore.Components.RenderTree.Renderer.AddToRenderQueue(int componentId, RenderFragment renderFragment)
Microsoft.AspNetCore.Components.RenderHandle.Render(RenderFragment renderFragment)
Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
Microsoft.AspNetCore.Components.ComponentBase.CallOnParametersSetAsync()
Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
Microsoft.AspNetCore.Components.RenderTree.Renderer.HandleExceptionViaErrorBoundary(Exception error, ComponentState errorSourceOrNull)
Microsoft.AspNetCore.Components.RenderTree.Renderer.AddToPendingTasksWithErrorHandling(Task task, ComponentState owningComponentState)
Microsoft.AspNetCore.Components.Rendering.ComponentState.SupplyCombinedParameters(ParameterView directAndCascadingParameters)
Microsoft.AspNetCore.Components.Rendering.ComponentState.SetDirectParameters(ParameterView parameters)
Microsoft.AspNetCore.Components.RenderTree.Renderer.RenderRootComponentAsync(int componentId, ParameterView initialParameters)
Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.BeginRenderingComponent(IComponent component, ParameterView initialParameters)
Microsoft.AspNetCore.Components.Endpoints.EndpointHtmlRenderer.RenderEndpointComponent(HttpContext httpContext, Type rootComponentType, ParameterView parameters, bool waitForQuiescence)
System.Threading.Tasks.ValueTask<TResult>.get_Result()
Microsoft.AspNetCore.Components.Endpoints.RazorComponentEndpointInvoker.RenderComponentCore(HttpContext context)
Microsoft.AspNetCore.Components.Endpoints.RazorComponentEndpointInvoker.RenderComponentCore(HttpContext context)
Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext+<>c+<<InvokeAsync>b__10_0>d.MoveNext()
Microsoft.AspNetCore.Localization.RequestLocalizationMiddleware.Invoke(HttpContext context)
Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

EDIT 2: Sample project https://easyupload.io/5oejk7

Upvotes: 0

Views: 667

Answers (1)

mmaestro
mmaestro

Reputation: 11

I managed to solve the issue with one of the solution adviced on that link. https://www.codeproject.com/Questions/5362704/Blazor-windows-authentication-with-custom-claims

The solution that I used is:

program.cs needs to point to a custom authenticator with a custom scheme->

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "CustomWindowsAuthentication";
}).AddScheme<CustomWindowsAuthenticationOptions, CustomWindowsAuthenticationHandler>("CustomWindowsAuthentication", null);

Then you do indeed need an additional class that handles that stuff:

public class CustomWindowsAuthenticationHandler : AuthenticationHandler<CustomWindowsAuthenticationOptions>
{
    public CustomWindowsAuthenticationHandler(
        IOptionsMonitor<CustomWindowsAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Context.User.Identity.IsAuthenticated || !(Context.User.Identity is WindowsIdentity windowsIdentity))
        {
            return AuthenticateResult.NoResult();
        }

        var loginName = windowsIdentity.Name;
        if (loginName.Contains("User1")
            || loginName.Contains("User2")
            || loginName.Contains("User3")
            || loginName.Contains("User4"))
        {
            var claims = new List<Claim>
            {
                new Claim("CustomClaim", "Admin")
            };

            var identity = new ClaimsIdentity(claims, Scheme.Name);
            var principal = new ClaimsPrincipal(identity);

            var ticket = new AuthenticationTicket(principal, Scheme.Name);
            return AuthenticateResult.Success(ticket);
        }

        return AuthenticateResult.Fail("Custom authentication failed.");
    }
}

And for the sake of completeness the "CustomWindowsAuthenticationOptions" because you need that too, although it's empty since i don't need any super special options.

public class CustomWindowsAuthenticationOptions : AuthenticationSchemeOptions
{
    
}

Upvotes: 1

Related Questions