Reputation: 12737
I have a Blazor Server app that uses Windows authentication to identify users. The app "detects" the user currently browsing the app and can display domain/username correctly.
I wrote a class that checks logged in username and fetches roles from a database, then updates the current principal so that the UI can properly take advantage of the given roles for the user.
I am avoiding EntityFramework and core identity scaffolding code for reasons I can't share here.
Here is the class I wrote
public class CompanyAuthentication
{
public CompanyAuthentication(AuthenticationStateProvider auth)
{
var result = auth.GetAuthenticationStateAsync().Result;
// here I have access to the current username:
string username = result.User.Identity.Name;
// now I can fetch roles from file, but for simplicity, I will use the following:
if(username.BeginsWith("admin"))
{
var claims = new ClaimsIdentity(new Claim[] {new Claim(ClaimType.Role, "admin")});
result.User.AddIdentity(claims); // this will have an effect on UI
}
}
}
I also added the above class as a service in Startup.cs
services.AddScoped<CompanyAuthentication>();
Now, in any razor page, I simply do:
@page "/counter"
@inject CompanyAuthentication auth
<AuthorizeView Roles="admin">
<Authorized> Welcome admin </Authorized>
<NotAuthorized> You are not authorized </NotAuthorized>
</AuthorizeView>
That all works fine, as long as I don't forget to inject CompanyAuthentication
in each page I intend to use.
Is there a way to automatically inject my class into every page without having to do @inject
?
I am aware that I can write a custom authentication class that inherits AuthenticationStateProvider
and then add it as service, but if I do that, I lose access to the currently logged in username, and thus, I can't fetch roles from the database.
I tried to use HttpContext.Current.User.Identity.Name
but that is not in the scope of any part in a Balzor server app.
How can I take advantage of Windows AuthenticationStateProvider and at the same time, customize its roles, without having to inject the customization everywhere ?
Upvotes: 0
Views: 1146
Reputation: 121
The accepted answer did not work for me using server-side Blazor with .Net v8, GetAuthenticationStateAsync() was never being called, I guess because of changes to how authentication and authorization works. Instead of overriding ServerAuthenticationStateProvider I had to re-implement it. It is a very simple class so you're not really losing anything.
public class MyAuthenticationStateProvider : AuthenticationStateProvider, IHostEnvironmentAuthenticationStateProvider
{
private Task<AuthenticationState> _authenticationStateTask;
[SupportedOSPlatform("windows")]
public new async void SetAuthenticationState(Task<AuthenticationState> authenticationStateTask)
{
_authenticationStateTask = authenticationStateTask ?? throw new ArgumentNullException(nameof(authenticationStateTask));
var originalWindowsIdentity = (ClaimsIdentity)authenticationStateTask.Result.User.Identity;
if (originalWindowsIdentity?.IsAuthenticated == true && originalWindowsIdentity.Name != null) // Check if *Windows* auth succeeded
{
// We create a copy of the claimsPrincipal because the original's Identity may be disposed of later and become inaccessible otherwise
var copyOfWindowsIdentity = new ClaimsIdentity(originalWindowsIdentity, originalWindowsIdentity.Claims, originalWindowsIdentity.AuthenticationType, originalWindowsIdentity.NameClaimType, originalWindowsIdentity.RoleClaimType);
var ClaimsPrincipal = new ClaimsPrincipal(copyOfWindowsIdentity);
ClaimsPrincipal.AddIdentity(AdminIdentity); // Or fetch roles and privileges from DB etc.
_authenticationStateTask = Task.FromResult(new AuthenticationState(appUser.ClaimsPrincipal));
}
else
{
// Ensure a non-null identity even if not authenticated
_authenticationStateTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
}
NotifyAuthenticationStateChanged(_authenticationStateTask);
}
/* Same as in ServerAuthenticationStateProvider */
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _authenticationStateTask
?? throw new InvalidOperationException($"Do not call {nameof(GetAuthenticationStateAsync)} outside of the DI scope for a Razor component. Typically, this means you can call it only within a Razor component or inside another DI service that is resolved for a Razor component.");
private ClaimsIdentity AdminIdentity
=> new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "admin") }, "My Auth Type");
}
Simply adding claims to the existing identity that you get as a parameter to SetAuthenticationState will not work. The claims will be added but things like will not because the original ClaimsIdentity created does not have the RoleType set properly. The IsInRole method does not directly check the claims of type ClaimTypes.Role, it relies on the RoleClaimType property of the ClaimsIdentity, so the lookup fails. So you have to create a new identity and add it as a second identity to the ClaimsPrincipal (or just not add the original one and add this instead).
Service registration in program.cs is a little different in v8 and I don't think the order is particularly important, i.e. you can do builder.Services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>(); anywhere before builder.Build():
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization(options =>
{
// By default, all incoming requests will be authorized according to the default policy.
options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddCircuitOptions(circuitOptions =>
{
circuitOptions.DisconnectedCircuitMaxRetained = 100;
})
.AddHubOptions(hubOptions =>
{
hubOptions.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
})
;
builder.Services.AddHttpContextAccessor();
builder.Services.AddCascadingAuthenticationState();
// ...add more services
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
A simple test page:
@page "/"
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
<PageTitle>Index</PageTitle>
<AuthorizeView Roles="admin">
<Authorized>
<h1>admin area</h1>
</Authorized>
<NotAuthorized>
<p>You're not authorized.</p>
</NotAuthorized>
</AuthorizeView>
Upvotes: 0
Reputation: 30177
You should be able to write a custom AuthenticationStateProvider
. I've set up a Blazor Server site with authentication set to Windows.
The custom AuthenticationStateProvider. Note it inherits from ServerAuthenticationStateProvider
.
public class MyAuthenticationStateProvider : ServerAuthenticationStateProvider
{
private bool _isNew = true;
public async override Task<AuthenticationState> GetAuthenticationStateAsync()
{
var state = await base.GetAuthenticationStateAsync();
// Add your code here to get the user info to use in the logic of what roles you add
// only add the extra Identity once
if (_isNew)
state.User.AddIdentity(AdminIdentity);
_isNew = false;
return state;
}
private ClaimsIdentity AdminIdentity
=> new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "admin") }, "My Auth Type");
}
Register it in services - note the order:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// Add custom AuthenticationStateProvider after AddServerSideBlazor will overload the existing registered service
builder.Services.AddScoped<AuthenticationStateProvider, MyAuthenticationStateProvider>();
builder.Services.AddSingleton<WeatherForecastService>();
var app = builder.Build();
And here's a test page to show you the claims:
@page "/"
@inject AuthenticationStateProvider Auth;
@using System.Security.Claims
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
@if(user is not null)
{
@foreach(var claim in user.Claims)
{
<div>
@claim.Type : @claim.Value
</div>
}
}
@code{
private ClaimsPrincipal user = default!;
protected async override Task OnInitializedAsync()
{
var state = await Auth.GetAuthenticationStateAsync();
user = state.User;
}
}
Here's a screen capture showing both the user windows security info and the added Role.
Upvotes: 3