From d18a033b1ee6d923a72d440718c5d496b57c2ffc Mon Sep 17 00:00:00 2001 From: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Date: Fri, 24 May 2019 15:28:37 +0100 Subject: [PATCH] Integrate AuthorizeView with actual authorization (#10487) --- .../src/Hosting/WebAssemblyHostBuilder.cs | 7 + .../src/Services/WebAssemblyConsoleLogger.cs | 34 +++ .../src/Services/WebAssemblyLoggerFactory.cs | 23 ++ .../Microsoft.AspNetCore.Components.csproj | 1 + ...etCore.Components.netstandard2.0.Manual.cs | 8 +- .../src/Auth/AuthorizeDataAdapter.cs | 39 +++ .../Components/src/Auth/AuthorizeView.razor | 49 +++- .../Microsoft.AspNetCore.Components.csproj | 1 + .../Components/test/Auth/AuthorizeViewTest.cs | 275 ++++++++++++++++-- src/Components/Shared/test/TestRenderer.cs | 2 +- src/Components/test/E2ETest/Tests/AuthTest.cs | 52 +++- .../AuthTest/AuthorizeViewCases.razor | 34 ++- .../ClientSideAuthenticationStateData.cs | 2 +- .../ServerAuthenticationStateProvider.cs | 2 +- .../test/testassets/BasicTestApp/Startup.cs | 6 + .../TestServer/Controllers/UserController.cs | 20 +- .../TestServer/Pages/Authentication.cshtml | 19 +- .../test/testassets/TestServer/Startup.cs | 6 + 18 files changed, 529 insertions(+), 51 deletions(-) create mode 100644 src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs create mode 100644 src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs create mode 100644 src/Components/Components/src/Auth/AuthorizeDataAdapter.cs diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs index 8cf720e1ca6..0b741cbd22c 100644 --- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Blazor.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; namespace Microsoft.AspNetCore.Blazor.Hosting @@ -92,6 +94,7 @@ namespace Microsoft.AspNetCore.Blazor.Hosting services.AddSingleton<IComponentContext, WebAssemblyComponentContext>(); services.AddSingleton<IUriHelper>(WebAssemblyUriHelper.Instance); services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance); + services.AddSingleton<ILoggerFactory, WebAssemblyLoggerFactory>(); services.AddSingleton<HttpClient>(s => { // Creating the URI helper needs to wait until the JS Runtime is initialized, so defer it. @@ -102,6 +105,10 @@ namespace Microsoft.AspNetCore.Blazor.Hosting }; }); + // Needed for authorization + services.AddOptions(); + services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(WebAssemblyConsoleLogger<>))); + foreach (var configureServicesAction in _configureServicesActions) { configureServicesAction(_BrowserHostBuilderContext, services); diff --git a/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs b/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs new file mode 100644 index 00000000000..c86c1cf30be --- /dev/null +++ b/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Blazor.Services +{ + internal class WebAssemblyConsoleLogger<T> : ILogger<T>, ILogger + { + public IDisposable BeginScope<TState>(TState state) + { + return NoOpDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Warning; + } + + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) + { + var formattedMessage = formatter(state, exception); + Console.WriteLine($"[{logLevel}] {formattedMessage}"); + } + + private class NoOpDisposable : IDisposable + { + public static NoOpDisposable Instance = new NoOpDisposable(); + + public void Dispose() { } + } + } +} diff --git a/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs b/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs new file mode 100644 index 00000000000..73458387e74 --- /dev/null +++ b/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Blazor.Services +{ + internal class WebAssemblyLoggerFactory : ILoggerFactory + { + public void AddProvider(ILoggerProvider provider) + { + // No-op + } + + public ILogger CreateLogger(string categoryName) + => new WebAssemblyConsoleLogger<object>(); + + public void Dispose() + { + // No-op + } + } +} diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj index bb08a3ecfed..4e878d13e32 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj @@ -5,6 +5,7 @@ </PropertyGroup> <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'"> <Compile Include="Microsoft.AspNetCore.Components.netstandard2.0.cs" /> + <Reference Include="Microsoft.AspNetCore.Authorization" /> <Reference Include="Microsoft.JSInterop" /> <Reference Include="System.ComponentModel.Annotations" /> </ItemGroup> diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs index 72db875d487..73ee830f5ce 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs @@ -59,7 +59,13 @@ namespace Microsoft.AspNetCore.Components [Microsoft.AspNetCore.Components.ParameterAttribute] public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } [Microsoft.AspNetCore.Components.ParameterAttribute] - public Microsoft.AspNetCore.Components.RenderFragment NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } + public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public string Policy { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public string Roles { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public object Resource { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } } protected override void BuildRenderTree(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { } [System.Diagnostics.DebuggerStepThroughAttribute] protected override System.Threading.Tasks.Task OnParametersSetAsync() { throw null; } diff --git a/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs b/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs new file mode 100644 index 00000000000..3da3e762616 --- /dev/null +++ b/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.Components +{ + // This is so the AuthorizeView can avoid implementing IAuthorizeData (even privately) + internal class AuthorizeDataAdapter : IAuthorizeData + { + private readonly AuthorizeView _component; + + public AuthorizeDataAdapter(AuthorizeView component) + { + _component = component ?? throw new ArgumentNullException(nameof(component)); + } + + public string Policy + { + get => _component.Policy; + set => throw new NotSupportedException(); + } + + public string Roles + { + get => _component.Roles; + set => throw new NotSupportedException(); + } + + // AuthorizeView doesn't expose any such parameter, as it wouldn't be used anyway, + // since we already have the ClaimsPrincipal by the time AuthorizeView gets involved. + public string AuthenticationSchemes + { + get => null; + set => throw new NotSupportedException(); + } + } +} diff --git a/src/Components/Components/src/Auth/AuthorizeView.razor b/src/Components/Components/src/Auth/AuthorizeView.razor index ae7b1682d54..0514ba3f9c3 100644 --- a/src/Components/Components/src/Auth/AuthorizeView.razor +++ b/src/Components/Components/src/Auth/AuthorizeView.razor @@ -1,20 +1,26 @@ @namespace Microsoft.AspNetCore.Components +@using System.Security.Claims +@using Microsoft.AspNetCore.Authorization +@inject IAuthorizationService AuthorizationService +@inject IAuthorizationPolicyProvider AuthorizationPolicyProvider @if (currentAuthenticationState == null) { @Authorizing } -else if (IsAuthorized()) +else if (isAuthorized) { @((Authorized ?? ChildContent)?.Invoke(currentAuthenticationState)) } else { - @NotAuthorized + @(NotAuthorized?.Invoke(currentAuthenticationState)) } @functions { + private IAuthorizeData[] selfAsAuthorizeData; private AuthenticationState currentAuthenticationState; + private bool isAuthorized; [CascadingParameter] private Task<AuthenticationState> AuthenticationState { get; set; } @@ -26,7 +32,7 @@ else /// <summary> /// The content that will be displayed if the user is not authorized. /// </summary> - [Parameter] public RenderFragment NotAuthorized { get; private set; } + [Parameter] public RenderFragment<AuthenticationState> NotAuthorized { get; private set; } /// <summary> /// The content that will be displayed if the user is authorized. @@ -39,6 +45,29 @@ else /// </summary> [Parameter] public RenderFragment Authorizing { get; private set; } + /// <summary> + /// The policy name that determines whether the content can be displayed. + /// </summary> + [Parameter] public string Policy { get; private set; } + + /// <summary> + /// A comma delimited list of roles that are allowed to display the content. + /// </summary> + [Parameter] public string Roles { get; private set; } + + /// <summary> + /// The resource to which access is being controlled. + /// </summary> + [Parameter] public object Resource { get; private set; } + + protected override void OnInit() + { + selfAsAuthorizeData = new[] + { + new AuthorizeDataAdapter((AuthorizeView)(object)this) + }; + } + protected override async Task OnParametersSetAsync() { // We allow 'ChildContent' for convenience in basic cases, and 'Authorized' for symmetry @@ -54,15 +83,17 @@ else currentAuthenticationState = null; // Then render in completed state + // Importantly, we *don't* call StateHasChanged between the following async steps, + // otherwise we'd display an incorrect UI state while waiting for IsAuthorizedAsync currentAuthenticationState = await AuthenticationState; + isAuthorized = await IsAuthorizedAsync(currentAuthenticationState.User); } - private bool IsAuthorized() + private async Task<bool> IsAuthorizedAsync(ClaimsPrincipal user) { - // TODO: Support various authorization condition parameters, equivalent to those offered - // by the [Authorize] attribute, e.g., "Roles" and "Policy". This is on hold until we're - // able to reference the policy evaluator APIs from this package. - - return currentAuthenticationState.User?.Identity?.IsAuthenticated == true; + var policy = await AuthorizationPolicy.CombineAsync( + AuthorizationPolicyProvider, selfAsAuthorizeData); + var result = await AuthorizationService.AuthorizeAsync(user, Resource, policy); + return result.Succeeded; } } diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 46121def31b..abdf16892e5 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -10,6 +10,7 @@ </PropertyGroup> <ItemGroup> + <Reference Include="Microsoft.AspNetCore.Authorization" /> <Reference Include="Microsoft.JSInterop" /> <Reference Include="System.ComponentModel.Annotations" /> </ItemGroup> diff --git a/src/Components/Components/test/Auth/AuthorizeViewTest.cs b/src/Components/Components/test/Auth/AuthorizeViewTest.cs index 5617c3b32d1..60c3f445d6a 100644 --- a/src/Components/Components/test/Auth/AuthorizeViewTest.cs +++ b/src/Components/Components/test/Auth/AuthorizeViewTest.cs @@ -2,14 +2,18 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Security.Claims; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Microsoft.AspNetCore.Components @@ -24,7 +28,8 @@ namespace Microsoft.AspNetCore.Components public void RendersNothingIfNotAuthorized() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( childContent: context => builder => builder.AddContent(0, "This should not be rendered")); @@ -36,18 +41,27 @@ namespace Microsoft.AspNetCore.Components // Assert var diff = renderer.Batches.Single().GetComponentDiffs<AuthorizeView>().Single(); Assert.Empty(diff.Edits); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Null(call.user.Identity); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } [Fact] public void RendersNotAuthorizedContentIfNotAuthorized() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( - childContent: - context => builder => builder.AddContent(0, "This should not be rendered"), notAuthorizedContent: - builder => builder.AddContent(0, "You are not authorized")); + context => builder => builder.AddContent(0, $"You are not authorized, even though we know you are {context.User.Identity.Name}")); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); // Act renderer.AssignRootComponentId(rootComponent); @@ -60,7 +74,16 @@ namespace Microsoft.AspNetCore.Components Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); AssertFrame.Text( renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], - "You are not authorized"); + "You are not authorized, even though we know you are Nellie"); + }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); }); } @@ -68,7 +91,9 @@ namespace Microsoft.AspNetCore.Components public void RendersNothingIfAuthorizedButNoChildContentOrAuthorizedContentProvided() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView(); rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); @@ -79,13 +104,24 @@ namespace Microsoft.AspNetCore.Components // Assert var diff = renderer.Batches.Single().GetComponentDiffs<AuthorizeView>().Single(); Assert.Empty(diff.Edits); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } [Fact] public void RendersChildContentIfAuthorized() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( childContent: context => builder => builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); @@ -104,13 +140,24 @@ namespace Microsoft.AspNetCore.Components renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], "You are authenticated as Nellie"); }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } [Fact] public void RendersAuthorizedContentIfAuthorized() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( authorizedContent: context => builder => builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); @@ -129,13 +176,24 @@ namespace Microsoft.AspNetCore.Components renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], "You are authenticated as Nellie"); }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } [Fact] public void RespondsToChangeInAuthorizationState() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( childContent: context => builder => builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); @@ -147,6 +205,7 @@ namespace Microsoft.AspNetCore.Components rootComponent.TriggerRender(); var authorizeViewComponentId = renderer.Batches.Single() .GetComponentFrames<AuthorizeView>().Single().ComponentId; + authorizationService.AuthorizeCalls.Clear(); // Act rootComponent.AuthenticationState = CreateAuthenticationState("Ronaldo"); @@ -164,13 +223,23 @@ namespace Microsoft.AspNetCore.Components batch.ReferenceFrames[edit.ReferenceFrameIndex], "You are authenticated as Ronaldo"); }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Ronaldo", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } [Fact] public void ThrowsIfBothChildContentAndAuthorizedContentProvided() { // Arrange - var renderer = new TestRenderer(); + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); var rootComponent = WrapInAuthorizeView( authorizedContent: context => builder => { }, childContent: context => builder => { }); @@ -187,12 +256,12 @@ namespace Microsoft.AspNetCore.Components { // Arrange var @event = new ManualResetEventSlim(); - var renderer = new TestRenderer() - { - OnUpdateDisplayComplete = () => { @event.Set(); }, - }; + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); + renderer.OnUpdateDisplayComplete = () => { @event.Set(); }; var rootComponent = WrapInAuthorizeView( - notAuthorizedContent: builder => builder.AddContent(0, "You are not authorized")); + notAuthorizedContent: + context => builder => builder.AddContent(0, "You are not authorized")); var authTcs = new TaskCompletionSource<AuthenticationState>(); rootComponent.AuthenticationState = authTcs.Task; @@ -228,10 +297,10 @@ namespace Microsoft.AspNetCore.Components { // Arrange var @event = new ManualResetEventSlim(); - var renderer = new TestRenderer() - { - OnUpdateDisplayComplete = () => { @event.Set(); }, - }; + var authorizationService = new TestAuthorizationService(); + authorizationService.NextResult = AuthorizationResult.Success(); + var renderer = CreateTestRenderer(authorizationService); + renderer.OnUpdateDisplayComplete = () => { @event.Set(); }; var rootComponent = WrapInAuthorizeView( authorizingContent: builder => builder.AddContent(0, "Auth pending..."), authorizedContent: context => builder => builder.AddContent(0, $"Hello, {context.User.Identity.Name}!")); @@ -276,13 +345,96 @@ namespace Microsoft.AspNetCore.Components batch2.ReferenceFrames[edit.ReferenceFrameIndex], "Hello, Monsieur!"); }); + + // Assert: The IAuthorizationService was given expected criteria + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Monsieur", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); + } + + [Fact] + public void IncludesPolicyInAuthorizeCall() + { + // Arrange + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); + var rootComponent = WrapInAuthorizeView(policy: "MyTestPolicy"); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, + req => Assert.Equal("MyTestPolicy", ((TestPolicyRequirement)req).PolicyName)); + }); + } + + [Fact] + public void IncludesRolesInAuthorizeCall() + { + // Arrange + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); + var rootComponent = WrapInAuthorizeView(roles: "SuperTestRole1, SuperTestRole2"); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Null(call.resource); + Assert.Collection(call.requirements, req => Assert.Equal( + new[] { "SuperTestRole1", "SuperTestRole2" }, + ((RolesAuthorizationRequirement)req).AllowedRoles)); + }); + } + + [Fact] + public void IncludesResourceInAuthorizeCall() + { + // Arrange + var authorizationService = new TestAuthorizationService(); + var renderer = CreateTestRenderer(authorizationService); + var resource = new object(); + var rootComponent = WrapInAuthorizeView(resource: resource); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + Assert.Collection(authorizationService.AuthorizeCalls, call => + { + Assert.Equal("Nellie", call.user.Identity.Name); + Assert.Same(resource, call.resource); + Assert.Collection(call.requirements, req => + Assert.IsType<DenyAnonymousAuthorizationRequirement>(req)); + }); } private static TestAuthStateProviderComponent WrapInAuthorizeView( RenderFragment<AuthenticationState> childContent = null, RenderFragment<AuthenticationState> authorizedContent = null, - RenderFragment notAuthorizedContent = null, - RenderFragment authorizingContent = null) + RenderFragment<AuthenticationState> notAuthorizedContent = null, + RenderFragment authorizingContent = null, + string policy = null, + string roles = null, + object resource = null) { return new TestAuthStateProviderComponent(builder => { @@ -291,6 +443,9 @@ namespace Microsoft.AspNetCore.Components builder.AddAttribute(2, nameof(AuthorizeView.Authorized), authorizedContent); builder.AddAttribute(3, nameof(AuthorizeView.NotAuthorized), notAuthorizedContent); builder.AddAttribute(4, nameof(AuthorizeView.Authorizing), authorizingContent); + builder.AddAttribute(5, nameof(AuthorizeView.Policy), policy); + builder.AddAttribute(6, nameof(AuthorizeView.Roles), roles); + builder.AddAttribute(7, nameof(AuthorizeView.Resource), resource); builder.CloseComponent(); }); } @@ -311,11 +466,31 @@ namespace Microsoft.AspNetCore.Components { builder.OpenComponent<CascadingValue<Task<AuthenticationState>>>(0); builder.AddAttribute(1, nameof(CascadingValue<Task<AuthenticationState>>.Value), AuthenticationState); - builder.AddAttribute(2, RenderTreeBuilder.ChildContent, _childContent); + builder.AddAttribute(2, RenderTreeBuilder.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent<NeverReRenderComponent>(0); + builder.AddAttribute(1, RenderTreeBuilder.ChildContent, _childContent); + builder.CloseComponent(); + })); builder.CloseComponent(); } } + // This is useful to show that the reason why a CascadingValue refreshes is because the + // value itself changed, not just that we're re-rendering the entire tree and have to + // recurse into all descendants because we're passing ChildContent + class NeverReRenderComponent : ComponentBase + { + [Parameter] RenderFragment ChildContent { get; set; } + + protected override bool ShouldRender() => false; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, ChildContent); + } + } + public static Task<AuthenticationState> CreateAuthenticationState(string username) => Task.FromResult(new AuthenticationState( new ClaimsPrincipal(new TestIdentity { Name = username }))); @@ -328,5 +503,59 @@ namespace Microsoft.AspNetCore.Components public string Name { get; set; } } + + public TestRenderer CreateTestRenderer(IAuthorizationService authorizationService) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(authorizationService); + serviceCollection.AddSingleton<IAuthorizationPolicyProvider>(new TestAuthorizationPolicyProvider()); + return new TestRenderer(serviceCollection.BuildServiceProvider()); + } + + private class TestAuthorizationService : IAuthorizationService + { + public AuthorizationResult NextResult { get; set; } + = AuthorizationResult.Failed(); + + public List<(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements)> AuthorizeCalls { get; } + = new List<(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements)>(); + + public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements) + { + AuthorizeCalls.Add((user, resource, requirements)); + + // The TestAuthorizationService doesn't actually apply any authorization requirements + // It just returns the specified NextResult, since we're not trying to test the logic + // in DefaultAuthorizationService or similar here. So it's up to tests to set a desired + // NextResult and assert that the expected criteria were passed by inspecting AuthorizeCalls. + return Task.FromResult(NextResult); + } + + public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName) + => throw new NotImplementedException(); + } + + private class TestAuthorizationPolicyProvider : IAuthorizationPolicyProvider + { + private readonly AuthorizationOptions options = new AuthorizationOptions(); + + public Task<AuthorizationPolicy> GetDefaultPolicyAsync() + => Task.FromResult(options.DefaultPolicy); + + public Task<AuthorizationPolicy> GetFallbackPolicyAsync() + => Task.FromResult(options.FallbackPolicy); + + public Task<AuthorizationPolicy> GetPolicyAsync(string policyName) => Task.FromResult( + new AuthorizationPolicy(new[] + { + new TestPolicyRequirement { PolicyName = policyName } + }, + new[] { $"TestScheme:{policyName}" })); + } + + public class TestPolicyRequirement : IAuthorizationRequirement + { + public string PolicyName { get; set; } + } } } diff --git a/src/Components/Shared/test/TestRenderer.cs b/src/Components/Shared/test/TestRenderer.cs index f2a99ccdd45..d838ea007b7 100644 --- a/src/Components/Shared/test/TestRenderer.cs +++ b/src/Components/Shared/test/TestRenderer.cs @@ -81,7 +81,7 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers { if (!ShouldHandleExceptions) { - throw exception; + ExceptionDispatchInfo.Capture(exception).Throw(); } HandledExceptions.Add(exception); diff --git a/src/Components/test/E2ETest/Tests/AuthTest.cs b/src/Components/test/E2ETest/Tests/AuthTest.cs index cc3139b6abd..3e9e6ef478d 100644 --- a/src/Components/test/E2ETest/Tests/AuthTest.cs +++ b/src/Components/test/E2ETest/Tests/AuthTest.cs @@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests [Fact] public void CascadingAuthenticationState_Unauthenticated() { - SignInAs(null); + SignInAs(null, null); var appElement = MountAndNavigateToAuthTest(CascadingAuthenticationStateLink); @@ -44,7 +44,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests [Fact] public void CascadingAuthenticationState_Authenticated() { - SignInAs("someone cool"); + SignInAs("someone cool", null); var appElement = MountAndNavigateToAuthTest(CascadingAuthenticationStateLink); @@ -56,20 +56,58 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests [Fact] public void AuthorizeViewCases_NoAuthorizationRule_Unauthenticated() { - SignInAs(null); - MountAndNavigateToAuthTest(AuthorizeViewCases); + SignInAs(null, null); + var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); WaitUntilExists(By.CssSelector("#no-authorization-rule .not-authorized")); + Browser.Equal("You're not authorized, anonymous", () => + appElement.FindElement(By.CssSelector("#no-authorization-rule .not-authorized")).Text); } [Fact] public void AuthorizeViewCases_NoAuthorizationRule_Authenticated() { - SignInAs("Some User"); + SignInAs("Some User", null); var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("Welcome, Some User!", () => appElement.FindElement(By.CssSelector("#no-authorization-rule .authorized")).Text); } + [Fact] + public void AuthorizeViewCases_RequireRole_Authenticated() + { + SignInAs("Some User", "IrrelevantRole,TestRole"); + var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); + Browser.Equal("Welcome, Some User!", () => + appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text); + } + + [Fact] + public void AuthorizeViewCases_RequireRole_Unauthenticated() + { + SignInAs("Some User", "IrrelevantRole"); + var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); + Browser.Equal("You're not authorized, Some User", () => + appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text); + } + + [Fact] + public void AuthorizeViewCases_RequirePolicy_Authenticated() + { + SignInAs("Bert", null); + var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); + Browser.Equal("Welcome, Bert!", () => + appElement.FindElement(By.CssSelector("#authorize-policy .authorized")).Text); + } + + [Fact] + public void AuthorizeViewCases_RequirePolicy_Unauthenticated() + { + SignInAs("Mallory", null); + var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); + Browser.Equal("You're not authorized, Mallory", () => + appElement.FindElement(By.CssSelector("#authorize-policy .not-authorized")).Text); + } + IWebElement MountAndNavigateToAuthTest(string authLinkText) { Navigate(ServerPathBase); @@ -79,12 +117,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests return appElement; } - void SignInAs(string usernameOrNull) + void SignInAs(string usernameOrNull, string rolesOrNull) { const string authenticationPageUrl = "/Authentication"; var baseRelativeUri = usernameOrNull == null ? $"{authenticationPageUrl}?signout=true" - : $"{authenticationPageUrl}?username={usernameOrNull}"; + : $"{authenticationPageUrl}?username={usernameOrNull}&roles={rolesOrNull}"; Navigate(baseRelativeUri); WaitUntilExists(By.CssSelector("h1#authentication")); } diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor index d78c70f72a0..00b39652f26 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor @@ -11,7 +11,39 @@ <p class="authorized">Welcome, @context.User.Identity.Name!</p> </Authorized> <NotAuthorized> - <p class="not-authorized">You're not logged in.</p> + <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p> + </NotAuthorized> + </AuthorizeView> +</div> + +<div id="authorize-role"> + <h3>Scenario: Require role</h3> + + <AuthorizeView Roles="TestRole"> + <Authorizing> + <p class="authorizing">Authorizing...</p> + </Authorizing> + <Authorized> + <p class="authorized">Welcome, @context.User.Identity.Name!</p> + </Authorized> + <NotAuthorized> + <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p> + </NotAuthorized> + </AuthorizeView> +</div> + +<div id="authorize-policy"> + <h3>Scenario: Require policy</h3> + + <AuthorizeView Policy="NameMustStartWithB"> + <Authorizing> + <p class="authorizing">Authorizing...</p> + </Authorizing> + <Authorized> + <p class="authorized">Welcome, @context.User.Identity.Name!</p> + </Authorized> + <NotAuthorized> + <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p> </NotAuthorized> </AuthorizeView> </div> diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs b/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs index 15178b5d820..f4845803acc 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs @@ -12,6 +12,6 @@ namespace BasicTestApp.AuthTest public string UserName { get; set; } - public Dictionary<string, string> ExposedClaims { get; set; } + public List<(string Type, string Value)> ExposedClaims { get; set; } } } diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs b/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs index 08639f9254f..40750c9c9db 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs @@ -29,7 +29,7 @@ namespace BasicTestApp.AuthTest if (data.IsAuthenticated) { var claims = new[] { new Claim(ClaimTypes.Name, data.UserName) } - .Concat(data.ExposedClaims.Select(c => new Claim(c.Key, c.Value))); + .Concat(data.ExposedClaims.Select(c => new Claim(c.Type, c.Value))); identity = new ClaimsIdentity(claims, "Server authentication"); } else diff --git a/src/Components/test/testassets/BasicTestApp/Startup.cs b/src/Components/test/testassets/BasicTestApp/Startup.cs index 73084e0260a..a97b966f696 100644 --- a/src/Components/test/testassets/BasicTestApp/Startup.cs +++ b/src/Components/test/testassets/BasicTestApp/Startup.cs @@ -15,6 +15,12 @@ namespace BasicTestApp public void ConfigureServices(IServiceCollection services) { services.AddSingleton<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); + + services.AddAuthorizationCore(options => + { + options.AddPolicy("NameMustStartWithB", policy => + policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false)); + }); } public void Configure(IComponentsApplicationBuilder app) diff --git a/src/Components/test/testassets/TestServer/Controllers/UserController.cs b/src/Components/test/testassets/TestServer/Controllers/UserController.cs index 16764e4080a..da6a4244190 100644 --- a/src/Components/test/testassets/TestServer/Controllers/UserController.cs +++ b/src/Components/test/testassets/TestServer/Controllers/UserController.cs @@ -1,4 +1,6 @@ +using System; using System.Linq; +using System.Security.Claims; using BasicTestApp.AuthTest; using Microsoft.AspNetCore.Mvc; @@ -7,22 +9,28 @@ namespace Components.TestServer.Controllers [Route("api/[controller]")] public class UserController : Controller { + // Servers are not expected to expose everything from the server-side ClaimsPrincipal + // to the client. It's up to the developer to choose what kind of authentication state + // data is needed on the client so it can display suitable options in the UI. + // In this class, we inform the client only about certain roles and certain other claims. + static string[] ExposedRoles = new[] { "IrrelevantRole", "TestRole" }; + // GET api/user [HttpGet] public ClientSideAuthenticationStateData Get() { - // Servers are not expected to expose everything from the server-side ClaimsPrincipal - // to the client. It's up to the developer to choose what kind of authentication state - // data is needed on the client so it can display suitable options in the UI. - return new ClientSideAuthenticationStateData { IsAuthenticated = User.Identity.IsAuthenticated, UserName = User.Identity.Name, ExposedClaims = User.Claims - .Where(c => c.Type == "test-claim") - .ToDictionary(c => c.Type, c => c.Value) + .Where(c => c.Type == "test-claim" || IsExposedRole(c)) + .Select(c => (c.Type, c.Value)).ToList() }; } + + private bool IsExposedRole(Claim claim) + => claim.Type == ClaimTypes.Role + && ExposedRoles.Contains(claim.Value); } } diff --git a/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml b/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml index 3f6cbdefd58..165618a019a 100644 --- a/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml +++ b/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml @@ -26,6 +26,10 @@ User name: <input name="username" /> </p> + <p> + Roles: + <input name="roles" /> + </p> <p> <button type="submit">Submit</button> </p> @@ -37,7 +41,11 @@ <p> Authenticated: <strong>@User.Identity.IsAuthenticated</strong> Username: <strong>@User.Identity.Name</strong> - </p> + Roles: + <strong> + @string.Join(", ", User.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray()) + </strong> + </p>foreach <a href="Authentication?signout=true">Sign out</a> </fieldset> </body> @@ -61,6 +69,15 @@ new Claim("test-claim", "Test claim value"), }; + var roles = Request.Query["roles"]; + if (!string.IsNullOrEmpty(roles)) + { + foreach (var role in roles.ToString().Split(',')) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + } + await HttpContext.SignInAsync( new ClaimsPrincipal(new ClaimsIdentity(claims, "FakeAuthenticationType"))); diff --git a/src/Components/test/testassets/TestServer/Startup.cs b/src/Components/test/testassets/TestServer/Startup.cs index 26da1af8e05..0075a8dfac7 100644 --- a/src/Components/test/testassets/TestServer/Startup.cs +++ b/src/Components/test/testassets/TestServer/Startup.cs @@ -30,6 +30,12 @@ namespace TestServer }); services.AddServerSideBlazor(); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); + + services.AddAuthorization(options => + { + options.AddPolicy("NameMustStartWithB", policy => + policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false)); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. -- GitLab