From 73bd09dc1c8abe2ff71cd33679fa17ebd04ff189 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Mon, 8 Jan 2018 11:59:30 -0800 Subject: [PATCH] Add CombineAuthorizeFilters option --- .../Authorization/AuthorizeFilter.cs | 61 ++++++++++++++++-- .../MvcOptions.cs | 8 +++ .../Authorization/AuthorizeFilterTest.cs | 62 +++++++++++++++++++ .../CombineAuthorizeTests.cs | 43 +++++++++++++ .../GlobalAuthorizationFilterTest.cs | 26 ++++++++ .../AuthorizeFilterIntegrationTest.cs | 5 +- .../Controllers/AdministrationController.cs | 22 +++++++ .../CountingPolicyEvaluator.cs | 24 +++++++ .../SecurityWebSite/SecurityWebSite.csproj | 1 + test/WebSites/SecurityWebSite/Startup.cs | 7 ++- ...obalAuthorizeAndCombineAuthorizeFilters.cs | 47 ++++++++++++++ .../StartupWithGlobalDenyAnonymousFilter.cs | 5 +- 12 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/CombineAuthorizeTests.cs create mode 100644 test/WebSites/SecurityWebSite/CountingPolicyEvaluator.cs create mode 100644 test/WebSites/SecurityWebSite/StartupWithGlobalAuthorizeAndCombineAuthorizeFilters.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs index 356276852d..a355477396 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Authorization/AuthorizeFilter.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.Authorization { @@ -22,6 +23,9 @@ namespace Microsoft.AspNetCore.Mvc.Authorization /// public class AuthorizeFilter : IAsyncAuthorizationFilter, IFilterFactory { + private MvcOptions _mvcOptions; + private AuthorizationPolicy _effectivePolicy; + /// /// Initializes a new instance. /// @@ -104,15 +108,46 @@ namespace Microsoft.AspNetCore.Mvc.Authorization bool IFilterFactory.IsReusable => true; - /// - public virtual async Task OnAuthorizationAsync(AuthorizationFilterContext context) + private async Task GetEffectivePolicyAsync(AuthorizationFilterContext context) { - if (context == null) + if (_effectivePolicy != null) { - throw new ArgumentNullException(nameof(context)); + return _effectivePolicy; } var effectivePolicy = Policy; + + if (_mvcOptions == null) + { + _mvcOptions = context.HttpContext.RequestServices.GetRequiredService>().Value; + } + + if (_mvcOptions.CombineAuthorizeFilters) + { + if (!context.IsEffectivePolicy(this)) + { + return null; + } + + // Combine all authorize filters into single effective policy that's only run on the closest filter + AuthorizationPolicyBuilder builder = null; + for (var i = 0; i < context.Filters.Count; i++) + { + if (ReferenceEquals(this, context.Filters[i])) + { + continue; + } + + if (context.Filters[i] is AuthorizeFilter authorizeFilter) + { + builder = builder ?? new AuthorizationPolicyBuilder(effectivePolicy); + builder.Combine(authorizeFilter.Policy); + } + } + + effectivePolicy = builder?.Build() ?? effectivePolicy; + } + if (effectivePolicy == null) { if (PolicyProvider == null) @@ -126,6 +161,24 @@ namespace Microsoft.AspNetCore.Mvc.Authorization effectivePolicy = await AuthorizationPolicy.CombineAsync(PolicyProvider, AuthorizeData); } + // We can cache the effective policy when there is no custom policy provider + if (PolicyProvider == null) + { + _effectivePolicy = effectivePolicy; + } + + return effectivePolicy; + } + + /// + public virtual async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var effectivePolicy = await GetEffectivePolicyAsync(context); if (effectivePolicy == null) { return; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs index 4c164d162f..4e6e0c19df 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs @@ -5,6 +5,7 @@ using System; using System.Collections; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -242,6 +243,13 @@ namespace Microsoft.AspNetCore.Mvc /// public bool RequireHttpsPermanent { get; set; } + /// + /// Gets or sets a value that determines if policies on instances of + /// will be combined into a single effective policy. This was always to be the intended behavior, + /// but was not the case. + /// + public bool CombineAuthorizeFilters { get; set;} + IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)_switches).GetEnumerator(); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs index 7222cb16ab..f989ec77a6 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Authorization/AuthorizeFilterTest.cs @@ -221,6 +221,68 @@ namespace Microsoft.AspNetCore.Mvc.Authorization Assert.Null(authorizationContext.Result); } + [Fact] + public async Task AuthorizationFilterCombinesMultipleFilters() + { + // Arrange + var authorizeFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => true).Build()); + var authorizationContext = GetAuthorizationContext(anonymous: false, registerServices: s => s.Configure(o => o.CombineAuthorizeFilters = true)); + // Effective policy should fail, if both are combined + authorizationContext.Filters.Add(authorizeFilter); + var secondFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => false).Build()); + authorizationContext.Filters.Add(secondFilter); + + // Act + await secondFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.IsType(authorizationContext.Result); + } + + [Fact] + public async Task AuthorizationFilterIgnoresFirstFilterWhenCombining() + { + // Arrange + var authorizeFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => false).Build()); + var authorizationContext = GetAuthorizationContext(anonymous: false, registerServices: s => s.Configure(o => o.CombineAuthorizeFilters = true)); + // Effective policy should fail, if both are combined + authorizationContext.Filters.Add(authorizeFilter); + var secondFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => false).Build()); + authorizationContext.Filters.Add(secondFilter); + + // Act + await authorizeFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.Null(authorizationContext.Result); + } + + [Fact] + public async Task AuthorizationFilterCombinesDerivedFilters() + { + // Arrange + var authorizeFilter = new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAssertion(a => true).Build()); + var authorizationContext = GetAuthorizationContext(anonymous: false, registerServices: s => s.Configure(o => o.CombineAuthorizeFilters = true)); + // Effective policy should fail, if both are combined + authorizationContext.Filters.Add(authorizeFilter); + authorizationContext.Filters.Add(new DerivedAuthorizeFilter()); + authorizationContext.Filters.Add(new DerivedAuthorizeFilter()); + var lastFilter = new DerivedAuthorizeFilter(); + authorizationContext.Filters.Add(lastFilter); + + // Act + await lastFilter.OnAuthorizationAsync(authorizationContext); + + // Assert + Assert.IsType(authorizationContext.Result); + } + + public class DerivedAuthorizeFilter : AuthorizeFilter + { + public DerivedAuthorizeFilter() : base(new AuthorizationPolicyBuilder().RequireAssertion(a => false).Build()) + { } + } + [Fact] public async Task AuthZResourceShouldBeAuthorizationFilterContext() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CombineAuthorizeTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CombineAuthorizeTests.cs new file mode 100644 index 0000000000..854306e4f9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/CombineAuthorizeTests.cs @@ -0,0 +1,43 @@ +// 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 System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using SecurityWebSite; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class CombineAuthorizeTests : IClassFixture> + { + public CombineAuthorizeTests(MvcTestFixture fixture) + { + Client = fixture.Client; + } + + public HttpClient Client { get; } + + [Fact] + public async Task CanAuthorizeWithOnlyCookie2() + { + // Arrange & Act + var response = await Client.PostAsync("http://localhost/Administration/SignInCookie2", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.Contains("Set-Cookie")); + + var cookie2 = response.Headers.GetValues("Set-Cookie").SingleOrDefault(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Administration/EitherCookie"); + request.Headers.Add("Cookie", cookie2); + + response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Administration.EitherCookie:AuthorizeCount=1", body); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs index 9eecde77ed..94ea6c7033 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/GlobalAuthorizationFilterTest.cs @@ -1,6 +1,7 @@ // 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.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -42,5 +43,30 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests var body = await response.Content.ReadAsStringAsync(); Assert.Equal("Administration.AllowAnonymousAction", body); } + + [Fact] + public async Task AuthorizationPoliciesDoNotCombineByDefault() + { + // Arrange & Act + var response = await Client.PostAsync("http://localhost/Administration/SignInCookie2", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.Contains("Set-Cookie")); + + var cookie2 = response.Headers.GetValues("Set-Cookie").SingleOrDefault(); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Administration/EitherCookie"); + request.Headers.Add("Cookie", cookie2); + + // Will fail because default cookie is not sent so [Authorize] fails + response = await Client.SendAsync(request); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.NotNull(response.Headers.Location); + Assert.Equal( + "http://localhost/Home/Login?ReturnUrl=%2FAdministration%2FEitherCookie", + response.Headers.Location.ToString()); + } + } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs index f0b585a6b9..31dcf75a53 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/AuthorizeFilterIntegrationTest.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.ObjectPool; using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTests @@ -70,8 +71,8 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests serviceCollection.AddMvc(); serviceCollection .AddTransient() - .AddTransient, Logger>(); - + .AddTransient, Logger>() + .AddSingleton(); return serviceCollection.BuildServiceProvider(); } diff --git a/test/WebSites/SecurityWebSite/Controllers/AdministrationController.cs b/test/WebSites/SecurityWebSite/Controllers/AdministrationController.cs index ac7be06b5e..89ea310500 100644 --- a/test/WebSites/SecurityWebSite/Controllers/AdministrationController.cs +++ b/test/WebSites/SecurityWebSite/Controllers/AdministrationController.cs @@ -1,8 +1,13 @@ // 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.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; namespace SecurityWebSite.Controllers { @@ -15,10 +20,27 @@ namespace SecurityWebSite.Controllers return Content("Administration.Index"); } + // Either cookie should allow access to this action (if CombineAuthorizeFilters is true) + // If CombineAuthorizeFilters is false, the main cookie is required. + [Authorize(AuthenticationSchemes = "Cookie2")] + public IActionResult EitherCookie() + { + var countEvaluator = (CountingPolicyEvaluator)HttpContext.RequestServices.GetRequiredService(); + return Content("Administration.EitherCookie:AuthorizeCount="+countEvaluator.AuthorizeCount); + } + [AllowAnonymous] public IActionResult AllowAnonymousAction() { return Content("Administration.AllowAnonymousAction"); } + + [AllowAnonymous] + public async Task SignInCookie2() + { + await HttpContext.SignInAsync("Cookie2", new ClaimsPrincipal(new ClaimsIdentity("Cookie2"))); + return Content("SignInCookie2"); + } + } } diff --git a/test/WebSites/SecurityWebSite/CountingPolicyEvaluator.cs b/test/WebSites/SecurityWebSite/CountingPolicyEvaluator.cs new file mode 100644 index 0000000000..0b4aaadea2 --- /dev/null +++ b/test/WebSites/SecurityWebSite/CountingPolicyEvaluator.cs @@ -0,0 +1,24 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http; + +namespace SecurityWebSite +{ + public class CountingPolicyEvaluator : PolicyEvaluator + { + public int AuthorizeCount { get; private set; } + + public CountingPolicyEvaluator(IAuthorizationService authorization) : base(authorization) { } + + public override Task AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context, object resource) { + AuthorizeCount++; + return base.AuthorizeAsync(policy, authenticationResult, context, resource); + } + } +} diff --git a/test/WebSites/SecurityWebSite/SecurityWebSite.csproj b/test/WebSites/SecurityWebSite/SecurityWebSite.csproj index bfe6424834..5486e54caf 100644 --- a/test/WebSites/SecurityWebSite/SecurityWebSite.csproj +++ b/test/WebSites/SecurityWebSite/SecurityWebSite.csproj @@ -7,6 +7,7 @@ + diff --git a/test/WebSites/SecurityWebSite/Startup.cs b/test/WebSites/SecurityWebSite/Startup.cs index 9f427a1cf9..7dc96a5c2a 100644 --- a/test/WebSites/SecurityWebSite/Startup.cs +++ b/test/WebSites/SecurityWebSite/Startup.cs @@ -1,8 +1,9 @@ // 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.AspNetCore.Builder; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; namespace SecurityWebSite @@ -19,7 +20,9 @@ namespace SecurityWebSite { options.LoginPath = "/Home/Login"; options.LogoutPath = "/Home/Logout"; - }); + }).AddCookie("Cookie2"); + + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/test/WebSites/SecurityWebSite/StartupWithGlobalAuthorizeAndCombineAuthorizeFilters.cs b/test/WebSites/SecurityWebSite/StartupWithGlobalAuthorizeAndCombineAuthorizeFilters.cs new file mode 100644 index 0000000000..434260777a --- /dev/null +++ b/test/WebSites/SecurityWebSite/StartupWithGlobalAuthorizeAndCombineAuthorizeFilters.cs @@ -0,0 +1,47 @@ +// 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.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace SecurityWebSite +{ + public class StartupWithGlobalAuthorizeAndCombineAuthorizeFilters + { + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + // Add framework services. + services.AddMvc(o => + { + o.CombineAuthorizeFilters = true; + o.Filters.Add(new AuthorizeFilter()); + }); + + services.AddAntiforgery(); + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => + { + options.LoginPath = "/Home/Login"; + options.LogoutPath = "/Home/Logout"; + }).AddCookie("Cookie2"); + + services.AddScoped(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) + { + app.UseAuthentication(); + + app.UseMvc(routes => + { + routes.MapRoute( + name: "default", + template: "{controller=Home}/{action=Index}/{id?}"); + }); + } + } +} diff --git a/test/WebSites/SecurityWebSite/StartupWithGlobalDenyAnonymousFilter.cs b/test/WebSites/SecurityWebSite/StartupWithGlobalDenyAnonymousFilter.cs index af49233670..4fa7b0e884 100644 --- a/test/WebSites/SecurityWebSite/StartupWithGlobalDenyAnonymousFilter.cs +++ b/test/WebSites/SecurityWebSite/StartupWithGlobalDenyAnonymousFilter.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -18,12 +19,14 @@ namespace SecurityWebSite { options.LoginPath = "/Home/Login"; options.LogoutPath = "/Home/Logout"; - }); + }).AddCookie("Cookie2"); services.AddMvc(o => { o.Filters.Add(new AuthorizeFilter()); }); + + services.AddScoped(); } public void Configure(IApplicationBuilder app)