From 7adb7c9be1834ff69d5ce31a0df7fd9eabe75806 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Sat, 21 Sep 2024 12:50:15 -0700 Subject: [PATCH] Get authorization policies to work client-side. --- .../Auth/AuthorizationPolicies.cs | 8 +- ControlR.Web.Client/Auth/ClaimNames.cs | 3 +- .../Components/Layout/NavMenu.razor | 64 +++---- .../Extensions/ClaimsPrincipalExtensions.cs | 17 +- .../PersistentAuthenticationStateProvider.cs | 9 + ControlR.Web.Client/UserInfo.cs | 7 +- ...RevalidatingAuthenticationStateProvider.cs | 158 +++++++++--------- 7 files changed, 123 insertions(+), 143 deletions(-) diff --git a/ControlR.Web.Client/Auth/AuthorizationPolicies.cs b/ControlR.Web.Client/Auth/AuthorizationPolicies.cs index bd663dde..bacd67a0 100644 --- a/ControlR.Web.Client/Auth/AuthorizationPolicies.cs +++ b/ControlR.Web.Client/Auth/AuthorizationPolicies.cs @@ -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(); +} \ No newline at end of file diff --git a/ControlR.Web.Client/Auth/ClaimNames.cs b/ControlR.Web.Client/Auth/ClaimNames.cs index 78822e72..1d6b06db 100644 --- a/ControlR.Web.Client/Auth/ClaimNames.cs +++ b/ControlR.Web.Client/Auth/ClaimNames.cs @@ -3,5 +3,4 @@ public static class ClaimNames { public const string IsAdministrator = nameof(IsAdministrator); - public const string Username = nameof(Username); -} +} \ No newline at end of file diff --git a/ControlR.Web.Client/Components/Layout/NavMenu.razor b/ControlR.Web.Client/Components/Layout/NavMenu.razor index 0a0ff648..ed9e188b 100644 --- a/ControlR.Web.Client/Components/Layout/NavMenu.razor +++ b/ControlR.Web.Client/Components/Layout/NavMenu.razor @@ -1,7 +1,7 @@ @implements IDisposable -@inject ILazyDi NavMan -@inject ILazyDi AuthState +@inject NavigationManager NavMan +@inject AuthenticationStateProvider AuthState @inject ILogger Logger @@ -15,20 +15,23 @@ Settings - @if (_isServerAdmin) - { - - Server Admin - - - Server Admin - - } - About + + +
+ + Admin Area + +
+ + Server Admin + +
+
+ @@ -59,7 +62,6 @@ @code { private string? _currentUrl; - private bool _isServerAdmin; private bool _isAuthenticated; [Parameter] @@ -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 stateTask) @@ -108,7 +91,6 @@ { var state = await stateTask; _isAuthenticated = state.User.IsAuthenticated(); - _isServerAdmin = state.User.IsAdministrator(); await InvokeAsync(StateHasChanged); } catch (Exception ex) @@ -119,7 +101,7 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs e) { - _currentUrl = NavMan.Value.ToBaseRelativePath(e.Location); + _currentUrl = NavMan.ToBaseRelativePath(e.Location); StateHasChanged(); } diff --git a/ControlR.Web.Client/Extensions/ClaimsPrincipalExtensions.cs b/ControlR.Web.Client/Extensions/ClaimsPrincipalExtensions.cs index 064f5a8d..03c6380b 100644 --- a/ControlR.Web.Client/Extensions/ClaimsPrincipalExtensions.cs +++ b/ControlR.Web.Client/Extensions/ClaimsPrincipalExtensions.cs @@ -1,5 +1,4 @@ -using System.Diagnostics.CodeAnalysis; -using System.Security.Claims; +using System.Security.Claims; namespace ControlR.Web.Client.Extensions; @@ -7,23 +6,11 @@ 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); - } } \ No newline at end of file diff --git a/ControlR.Web.Client/PersistentAuthenticationStateProvider.cs b/ControlR.Web.Client/PersistentAuthenticationStateProvider.cs index 9c1f8502..485aaa84 100644 --- a/ControlR.Web.Client/PersistentAuthenticationStateProvider.cs +++ b/ControlR.Web.Client/PersistentAuthenticationStateProvider.cs @@ -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))))); diff --git a/ControlR.Web.Client/UserInfo.cs b/ControlR.Web.Client/UserInfo.cs index 423fdc44..093e66bd 100644 --- a/ControlR.Web.Client/UserInfo.cs +++ b/ControlR.Web.Client/UserInfo.cs @@ -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; } +} \ No newline at end of file diff --git a/ControlR.Web.Server/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs b/ControlR.Web.Server/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs index 80e73ee1..2b7df40d 100644 --- a/ControlR.Web.Server/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs +++ b/ControlR.Web.Server/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs @@ -1,4 +1,7 @@ +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; @@ -6,8 +9,6 @@ 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; @@ -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? _authenticationStateTask; - - public PersistingRevalidatingAuthenticationStateProvider( - PersistentComponentState persistentComponentState, - ILoggerFactory loggerFactory, - IServiceScopeFactory serviceScopeFactory, - IOptions 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? _authenticationStateTask; - protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + public PersistingRevalidatingAuthenticationStateProvider( + PersistentComponentState persistentComponentState, + ILoggerFactory loggerFactory, + IServiceScopeFactory serviceScopeFactory, + IOptions 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 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>(); - 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 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>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } - private void OnAuthenticationStateChanged(Task task) + private void OnAuthenticationStateChanged(Task 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 ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + private async Task ValidateSecurityStampAsync(UserManager 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; + } +} \ No newline at end of file