Skip to content

Commit

Permalink
Get authorization policies to work client-side.
Browse files Browse the repository at this point in the history
  • Loading branch information
bitbound committed Sep 21, 2024
1 parent f52e604 commit 7adb7c9
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 143 deletions.
8 changes: 4 additions & 4 deletions ControlR.Web.Client/Auth/AuthorizationPolicies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public static class AuthorizationPolicies
{
public static AuthorizationPolicy RequireAdministrator =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim(ClaimNames.IsAdministrator, "true")
.Build();
}
.RequireAuthenticatedUser()
.RequireClaim(ClaimNames.IsAdministrator)
.Build();
}
3 changes: 1 addition & 2 deletions ControlR.Web.Client/Auth/ClaimNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,4 @@
public static class ClaimNames
{
public const string IsAdministrator = nameof(IsAdministrator);
public const string Username = nameof(Username);
}
}
64 changes: 23 additions & 41 deletions ControlR.Web.Client/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@implements IDisposable

@inject ILazyDi<NavigationManager> NavMan
@inject ILazyDi<PersistentAuthenticationStateProvider> AuthState
@inject NavigationManager NavMan
@inject AuthenticationStateProvider AuthState
@inject ILogger<NavMenu> Logger

<MudNavMenu>
Expand All @@ -15,20 +15,23 @@
Settings
</MudNavLink>

@if (_isServerAdmin)
{
<MudText Typo="Typo.caption" Color="Color.Dark" GutterBottom="true">
Server Admin
</MudText>
<MudNavLink Disabled="IsDisabled || !_isAuthenticated" Href="@(RouteNames.ServerAdmin)" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.AdminPanelSettings">
Server Admin
</MudNavLink>
}

<MudNavLink Href="@(RouteNames.About)" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QuestionMark">
About
</MudNavLink>

<AuthorizeView Policy="@(PolicyNames.RequireAdministrator)">
<Authorized>
<div class="ms-2 mt-5">
<MudText Typo="Typo.caption" Color="Color.Default">
Admin Area
</MudText>
</div>
<MudNavLink Disabled="IsDisabled || !_isAuthenticated" Href="@(RouteNames.ServerAdmin)" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.AdminPanelSettings">
Server Admin
</MudNavLink>
</Authorized>
</AuthorizeView>

<MudDivider Class="mt-2 mb-2"/>

<AuthorizeView>
Expand Down Expand Up @@ -59,7 +62,6 @@
@code {

private string? _currentUrl;
private bool _isServerAdmin;
private bool _isAuthenticated;

[Parameter]
Expand All @@ -68,38 +70,19 @@

public void Dispose()
{
if (NavMan.Exists)
{
NavMan.Value.LocationChanged -= OnLocationChanged;
}

if (AuthState.Exists)
{
AuthState.Value.AuthenticationStateChanged -= HandleAuthStateChanged;
}
NavMan.LocationChanged -= OnLocationChanged;
AuthState.AuthenticationStateChanged -= HandleAuthStateChanged;
}

protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();

if (AuthState.Exists)
{
var state = await AuthState.Value.GetAuthenticationStateAsync();
_isAuthenticated = state.User.IsAuthenticated();
_isServerAdmin = state.User.IsAdministrator();
}

if (AuthState.Exists)
{
AuthState.Value.AuthenticationStateChanged += HandleAuthStateChanged;
}

if (NavMan.Exists)
{
_currentUrl = NavMan.Value.ToBaseRelativePath(NavMan.Value.Uri);
NavMan.Value.LocationChanged += OnLocationChanged;
}
var state = await AuthState.GetAuthenticationStateAsync();
_isAuthenticated = state.User.IsAuthenticated();
AuthState.AuthenticationStateChanged += HandleAuthStateChanged;
_currentUrl = NavMan.ToBaseRelativePath(NavMan.Uri);
NavMan.LocationChanged += OnLocationChanged;
}

private async void HandleAuthStateChanged(Task<AuthenticationState> stateTask)
Expand All @@ -108,7 +91,6 @@
{
var state = await stateTask;
_isAuthenticated = state.User.IsAuthenticated();
_isServerAdmin = state.User.IsAdministrator();
await InvokeAsync(StateHasChanged);
}
catch (Exception ex)
Expand All @@ -119,7 +101,7 @@

private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
_currentUrl = NavMan.Value.ToBaseRelativePath(e.Location);
_currentUrl = NavMan.ToBaseRelativePath(e.Location);
StateHasChanged();
}

Expand Down
17 changes: 2 additions & 15 deletions ControlR.Web.Client/Extensions/ClaimsPrincipalExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Security.Claims;

namespace ControlR.Web.Client.Extensions;

public static class ClaimsPrincipalExtensions
{
public static bool IsAdministrator(this ClaimsPrincipal user)
{
return
user.TryGetClaim(ClaimNames.IsAdministrator, out var claimValue) &&
bool.TryParse(claimValue, out var isAdmin) &&
isAdmin;
return user.HasClaim(x => x is { Type: ClaimNames.IsAdministrator });
}

public static bool IsAuthenticated(this ClaimsPrincipal user)
{
return user.Identity?.IsAuthenticated ?? false;
}

public static bool TryGetClaim(
this ClaimsPrincipal user,
string claimType,
[NotNullWhen(true)] out string? claimValue)
{
claimValue = user.Claims.FirstOrDefault(x => x.Type == claimType)?.Value ?? string.Empty;
return !string.IsNullOrWhiteSpace(claimValue);
}
}
9 changes: 9 additions & 0 deletions ControlR.Web.Client/PersistentAuthenticationStateProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ public PersistentAuthenticationStateProvider(PersistentComponentState state)
new(ClaimTypes.Email, userInfo.Email)
];

if (userInfo.IsAdministrator)
{
claims =
[
..claims,
new Claim(ClaimNames.IsAdministrator, string.Empty)
];
}

_authenticationStateTask = Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims,
nameof(PersistentAuthenticationStateProvider)))));
Expand Down
7 changes: 4 additions & 3 deletions ControlR.Web.Client/UserInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace ControlR.Web.Client;
// to expose more information about the authenticated user to the client.
public class UserInfo
{
public required string UserId { get; set; }
public required string Email { get; set; }
}
public required string UserId { get; set; }
public required string Email { get; set; }
public required bool IsAdministrator { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using System.Diagnostics;
using System.Security.Claims;
using ControlR.Web.Client;
using ControlR.Web.Client.Extensions;
using ControlR.Web.Server.Data;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Security.Claims;

namespace ControlR.Web.Server.Components.Account;

Expand All @@ -16,94 +17,95 @@ namespace ControlR.Web.Server.Components.Account;
// authentication state to the client which is then fixed for the lifetime of the WebAssembly application.
internal sealed class PersistingRevalidatingAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
private readonly IdentityOptions _options;
private readonly IServiceScopeFactory _scopeFactory;
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription _subscription;

private Task<AuthenticationState>? _authenticationStateTask;

public PersistingRevalidatingAuthenticationStateProvider(
PersistentComponentState persistentComponentState,
ILoggerFactory loggerFactory,
IServiceScopeFactory serviceScopeFactory,
IOptions<IdentityOptions> optionsAccessor)
: base(loggerFactory)
{
_scopeFactory = serviceScopeFactory;
_state = persistentComponentState;
_options = optionsAccessor.Value;
private readonly IdentityOptions _options;
private readonly IServiceScopeFactory _scopeFactory;
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription _subscription;

AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
}
private Task<AuthenticationState>? _authenticationStateTask;

protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
public PersistingRevalidatingAuthenticationStateProvider(
PersistentComponentState persistentComponentState,
ILoggerFactory loggerFactory,
IServiceScopeFactory serviceScopeFactory,
IOptions<IdentityOptions> optionsAccessor)
: base(loggerFactory)
{
_scopeFactory = serviceScopeFactory;
_state = persistentComponentState;
_options = optionsAccessor.Value;

protected override void Dispose(bool disposing)
{
_subscription.Dispose();
AuthenticationStateChanged -= OnAuthenticationStateChanged;
base.Dispose(disposing);
}
AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
}

protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState,
CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);

protected override void Dispose(bool disposing)
{
_subscription.Dispose();
AuthenticationStateChanged -= OnAuthenticationStateChanged;
base.Dispose(disposing);
}

protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState,
CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}

private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
_authenticationStateTask = task;
}

private async Task OnPersistingAsync()
{
if (_authenticationStateTask is null)
{
_authenticationStateTask = task;
throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}().");
}

private async Task OnPersistingAsync()
{
if (_authenticationStateTask is null)
{
throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}().");
}
var authenticationState = await _authenticationStateTask;
var principal = authenticationState.User;

var authenticationState = await _authenticationStateTask;
var principal = authenticationState.User;
if (principal.Identity?.IsAuthenticated == true)
{
var userId = principal.FindFirst(_options.ClaimsIdentity.UserIdClaimType)?.Value;
var email = principal.FindFirst(_options.ClaimsIdentity.EmailClaimType)?.Value;
var isAdministrator = principal.IsAdministrator();

if (principal.Identity?.IsAuthenticated == true)
if (userId != null && email != null)
{
_state.PersistAsJson(nameof(UserInfo), new UserInfo
{
var userId = principal.FindFirst(_options.ClaimsIdentity.UserIdClaimType)?.Value;
var email = principal.FindFirst(_options.ClaimsIdentity.EmailClaimType)?.Value;

if (userId != null && email != null)
{
_state.PersistAsJson(nameof(UserInfo), new UserInfo
{
UserId = userId,
Email = email,
});
}
}
UserId = userId,
Email = email,
IsAdministrator = isAdministrator
});
}
}
}

private async Task<bool> ValidateSecurityStampAsync(UserManager<AppUser> userManager, ClaimsPrincipal principal)
private async Task<bool> ValidateSecurityStampAsync(UserManager<AppUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
return false;
}
}

if (!userManager.SupportsUserSecurityStamp)
{
return true;
}

var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}

0 comments on commit 7adb7c9

Please sign in to comment.