diff --git a/src/Security/Authorization/Core/src/AuthorizationFailure.cs b/src/Security/Authorization/Core/src/AuthorizationFailure.cs index 722329b865fb..64a5c8d1093d 100644 --- a/src/Security/Authorization/Core/src/AuthorizationFailure.cs +++ b/src/Security/Authorization/Core/src/AuthorizationFailure.cs @@ -15,7 +15,7 @@ public class AuthorizationFailure private AuthorizationFailure() { } /// - /// Failure was due to being called. + /// Failure was due to being called. /// public bool FailCalled { get; private set; } @@ -25,13 +25,29 @@ private AuthorizationFailure() { } public IEnumerable FailedRequirements { get; private set; } = Array.Empty(); /// - /// Return a failure due to being called. + /// Allows to flow more detailed reasons for why authorization failed. + /// + public IEnumerable Reasons { get; private set; } = Array.Empty(); + + /// + /// Return a failure due to being called. /// /// The failure. public static AuthorizationFailure ExplicitFail() + => new AuthorizationFailure + { + FailCalled = true + }; + + /// + /// Return a failure due to being called. + /// + /// The failure. + public static AuthorizationFailure Failed(IEnumerable reasons) => new AuthorizationFailure { FailCalled = true, + Reasons = reasons }; /// diff --git a/src/Security/Authorization/Core/src/AuthorizationFailureReason.cs b/src/Security/Authorization/Core/src/AuthorizationFailureReason.cs new file mode 100644 index 000000000000..b8c30f67d1d9 --- /dev/null +++ b/src/Security/Authorization/Core/src/AuthorizationFailureReason.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Authorization +{ + /// + /// Encapsulates a reason why authorization failed. + /// + public class AuthorizationFailureReason + { + /// + /// Creates a new failure reason. + /// + /// The handler responsible for this failure reason. + /// The message describing the failure. + public AuthorizationFailureReason(IAuthorizationHandler handler, string message) + { + Handler = handler; + Message = message; + } + + /// + /// A message describing the failure reason. + /// + public string Message { get; set; } + + /// + /// The responsible for this failure reason. + /// + public IAuthorizationHandler Handler { get; set; } + } +} diff --git a/src/Security/Authorization/Core/src/AuthorizationHandlerContext.cs b/src/Security/Authorization/Core/src/AuthorizationHandlerContext.cs index 2ad35047c4b3..c89cc5cd2160 100644 --- a/src/Security/Authorization/Core/src/AuthorizationHandlerContext.cs +++ b/src/Security/Authorization/Core/src/AuthorizationHandlerContext.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Authorization public class AuthorizationHandlerContext { private readonly HashSet _pendingRequirements; + private readonly List _failedReasons; private bool _failCalled; private bool _succeedCalled; @@ -37,6 +38,7 @@ public AuthorizationHandlerContext( User = user; Resource = resource; _pendingRequirements = new HashSet(requirements); + _failedReasons = new List(); } /// @@ -59,6 +61,11 @@ public AuthorizationHandlerContext( /// public virtual IEnumerable PendingRequirements { get { return _pendingRequirements; } } + /// + /// Gets the reasons why authorization has failed. + /// + public virtual IEnumerable FailureReasons { get { return _failedReasons; } } + /// /// Flag indicating whether the current authorization processing has failed. /// @@ -84,6 +91,20 @@ public virtual void Fail() _failCalled = true; } + /// + /// Called to indicate will + /// never return true, even if all requirements are met. + /// + /// Optional for why authorization failed. + public virtual void Fail(AuthorizationFailureReason reason) + { + Fail(); + if (reason != null) + { + _failedReasons.Add(reason); + } + } + /// /// Called to mark the specified as being /// successfully evaluated. diff --git a/src/Security/Authorization/Core/src/DefaultAuthorizationEvaluator.cs b/src/Security/Authorization/Core/src/DefaultAuthorizationEvaluator.cs index 0055568f6f44..eb612716d282 100644 --- a/src/Security/Authorization/Core/src/DefaultAuthorizationEvaluator.cs +++ b/src/Security/Authorization/Core/src/DefaultAuthorizationEvaluator.cs @@ -17,7 +17,7 @@ public AuthorizationResult Evaluate(AuthorizationHandlerContext context) => context.HasSucceeded ? AuthorizationResult.Success() : AuthorizationResult.Failed(context.HasFailed - ? AuthorizationFailure.ExplicitFail() + ? AuthorizationFailure.Failed(context.FailureReasons) : AuthorizationFailure.Failed(context.PendingRequirements)); } } diff --git a/src/Security/Authorization/Core/src/PublicAPI.Unshipped.txt b/src/Security/Authorization/Core/src/PublicAPI.Unshipped.txt index 613684928ccb..bed63d21e9d2 100644 --- a/src/Security/Authorization/Core/src/PublicAPI.Unshipped.txt +++ b/src/Security/Authorization/Core/src/PublicAPI.Unshipped.txt @@ -1,3 +1,13 @@ #nullable enable *REMOVED*static Microsoft.AspNetCore.Authorization.AuthorizationServiceExtensions.AuthorizeAsync(this Microsoft.AspNetCore.Authorization.IAuthorizationService! service, System.Security.Claims.ClaimsPrincipal! user, object! resource, Microsoft.AspNetCore.Authorization.IAuthorizationRequirement! requirement) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Authorization.AuthorizationFailure.Reasons.get -> System.Collections.Generic.IEnumerable! +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason.AuthorizationFailureReason(Microsoft.AspNetCore.Authorization.IAuthorizationHandler! handler, string! message) -> void +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason.Handler.get -> Microsoft.AspNetCore.Authorization.IAuthorizationHandler! +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason.Handler.set -> void +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason.Message.get -> string! +Microsoft.AspNetCore.Authorization.AuthorizationFailureReason.Message.set -> void +static Microsoft.AspNetCore.Authorization.AuthorizationFailure.Failed(System.Collections.Generic.IEnumerable! reasons) -> Microsoft.AspNetCore.Authorization.AuthorizationFailure! static Microsoft.AspNetCore.Authorization.AuthorizationServiceExtensions.AuthorizeAsync(this Microsoft.AspNetCore.Authorization.IAuthorizationService! service, System.Security.Claims.ClaimsPrincipal! user, object? resource, Microsoft.AspNetCore.Authorization.IAuthorizationRequirement! requirement) -> System.Threading.Tasks.Task! +virtual Microsoft.AspNetCore.Authorization.AuthorizationHandlerContext.Fail(Microsoft.AspNetCore.Authorization.AuthorizationFailureReason! reason) -> void +virtual Microsoft.AspNetCore.Authorization.AuthorizationHandlerContext.FailureReasons.get -> System.Collections.Generic.IEnumerable! diff --git a/src/Security/Authorization/test/DefaultAuthorizationServiceTests.cs b/src/Security/Authorization/test/DefaultAuthorizationServiceTests.cs index 41e42e0c952c..ebd2dbcbd78a 100644 --- a/src/Security/Authorization/test/DefaultAuthorizationServiceTests.cs +++ b/src/Security/Authorization/test/DefaultAuthorizationServiceTests.cs @@ -174,6 +174,54 @@ public Task HandleAsync(AuthorizationHandlerContext context) } } + private class ReasonableFailHandler : IAuthorizationHandler + { + private string _reason; + + public ReasonableFailHandler(string reason) => _reason = reason; + + public bool Invoked { get; set; } + + public Task HandleAsync(AuthorizationHandlerContext context) + { + Invoked = true; + context.Fail(new AuthorizationFailureReason(this, _reason)); + return Task.FromResult(0); + } + } + + [Fact] + public async Task CanFailWithReasons() + { + var handler1 = new ReasonableFailHandler("1"); + var handler2 = new FailHandler(); + var handler3 = new ReasonableFailHandler("3"); + var authorizationService = BuildAuthorizationService(services => + { + services.AddSingleton(handler1); + services.AddSingleton(handler2); + services.AddSingleton(handler3); + services.AddAuthorization(options => + { + options.AddPolicy("Custom", policy => policy.Requirements.Add(new CustomRequirement())); + }); + }); + + // Act + var allowed = await authorizationService.AuthorizeAsync(new ClaimsPrincipal(), "Custom"); + + // Assert + Assert.False(allowed.Succeeded); + Assert.NotNull(allowed.Failure); + Assert.Equal(2, allowed.Failure.Reasons.Count()); + var first = allowed.Failure.Reasons.First(); + Assert.Equal("1", first.Message); + Assert.Equal(handler1, first.Handler); + var second = allowed.Failure.Reasons.Last(); + Assert.Equal("3", second.Message); + Assert.Equal(handler3, second.Handler); + } + [Fact] public async Task Authorize_ShouldFailWhenAllRequirementsNotHandled() { diff --git a/src/Security/Authorization/test/DenyAnonymousAuthorizationRequirementTests.cs b/src/Security/Authorization/test/DenyAnonymousAuthorizationRequirementTests.cs index ed0dac0de827..11824a78a67b 100644 --- a/src/Security/Authorization/test/DenyAnonymousAuthorizationRequirementTests.cs +++ b/src/Security/Authorization/test/DenyAnonymousAuthorizationRequirementTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; using Microsoft.AspNetCore.Authorization.Infrastructure; using Xunit; diff --git a/src/Security/Authorization/test/NameAuthorizationRequirementTests.cs b/src/Security/Authorization/test/NameAuthorizationRequirementTests.cs index cc653d27634d..f8ef42b7d161 100644 --- a/src/Security/Authorization/test/NameAuthorizationRequirementTests.cs +++ b/src/Security/Authorization/test/NameAuthorizationRequirementTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; using Microsoft.AspNetCore.Authorization.Infrastructure; using Xunit; diff --git a/src/Security/Authorization/test/OperationAuthorizationRequirementTests.cs b/src/Security/Authorization/test/OperationAuthorizationRequirementTests.cs index 75b3c7392da9..0c70f5860e9f 100644 --- a/src/Security/Authorization/test/OperationAuthorizationRequirementTests.cs +++ b/src/Security/Authorization/test/OperationAuthorizationRequirementTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; using Microsoft.AspNetCore.Authorization.Infrastructure; using Xunit; diff --git a/src/Security/Authorization/test/RolesAuthorizationRequirementTests.cs b/src/Security/Authorization/test/RolesAuthorizationRequirementTests.cs index b2cab60b3f36..f69212e04704 100644 --- a/src/Security/Authorization/test/RolesAuthorizationRequirementTests.cs +++ b/src/Security/Authorization/test/RolesAuthorizationRequirementTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; using Microsoft.AspNetCore.Authorization.Infrastructure; using Xunit; diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Handlers/SampleWithFailureReasonRequirementHandler.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Handlers/SampleWithFailureReasonRequirementHandler.cs new file mode 100644 index 000000000000..29ef971649d4 --- /dev/null +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Handlers/SampleWithFailureReasonRequirementHandler.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using CustomAuthorizationFailureResponse.Authorization.Requirements; +using Microsoft.AspNetCore.Authorization; + +namespace CustomAuthorizationFailureResponse.Authorization.Handlers +{ + public class SampleWithFailureReasonRequirementHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SampleFailReasonRequirement requirement) + { + context.Fail(new AuthorizationFailureReason(this, "This is a way to provide more failure reasons.")); + return Task.CompletedTask; + } + } +} diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Requirements/SampleFailReasonRequirement.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Requirements/SampleFailReasonRequirement.cs new file mode 100644 index 000000000000..6926dcb70504 --- /dev/null +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/Requirements/SampleFailReasonRequirement.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Authorization; + +namespace CustomAuthorizationFailureResponse.Authorization.Requirements +{ + public class SampleFailReasonRequirement : IAuthorizationRequirement + { + } +} diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SampleAuthorizationMiddlewareResultHandler.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SampleAuthorizationMiddlewareResultHandler.cs index 2a128498c20a..be7e8d311f1d 100644 --- a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SampleAuthorizationMiddlewareResultHandler.cs +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SampleAuthorizationMiddlewareResultHandler.cs @@ -31,6 +31,14 @@ public async Task HandleAsync( // if the authorization was forbidden, let's use custom logic to handle that. if (policyAuthorizationResult.Forbidden && policyAuthorizationResult.AuthorizationFailure != null) { + if (policyAuthorizationResult.AuthorizationFailure.Reasons.Any()) + { + await httpContext.Response.WriteAsync(policyAuthorizationResult.AuthorizationFailure.Reasons.First().Message); + + // return right away as the default implementation would overwrite the status code + return; + } + // as an example, let's return 404 if specific requirement has failed if (policyAuthorizationResult.AuthorizationFailure.FailedRequirements.Any(requirement => requirement is SampleRequirement)) { diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SamplePolicyNames.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SamplePolicyNames.cs index bd09ea18b96e..392313b31ca7 100644 --- a/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SamplePolicyNames.cs +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Authorization/SamplePolicyNames.cs @@ -7,5 +7,6 @@ public static class SamplePolicyNames { public const string CustomPolicy = "Custom"; public const string CustomPolicyWithCustomForbiddenMessage = "CustomPolicyWithCustomForbiddenMessage"; + public const string FailureReasonPolicy = "FailureReason"; } } diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Controllers/SampleController.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Controllers/SampleController.cs index ed6834b501b9..7f43c30e91ad 100644 --- a/src/Security/samples/CustomAuthorizationFailureResponse/Controllers/SampleController.cs +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Controllers/SampleController.cs @@ -24,5 +24,12 @@ public string GetWithCustomPolicy() { return "Hello world from GetWithCustomPolicy"; } + + [HttpGet("failureReason")] + [Authorize(Policy = SamplePolicyNames.FailureReasonPolicy)] + public string FailureReason() + { + return "Hello world from FailureReason"; + } } } diff --git a/src/Security/samples/CustomAuthorizationFailureResponse/Startup.cs b/src/Security/samples/CustomAuthorizationFailureResponse/Startup.cs index f970cde5d43d..aac2e2eab250 100644 --- a/src/Security/samples/CustomAuthorizationFailureResponse/Startup.cs +++ b/src/Security/samples/CustomAuthorizationFailureResponse/Startup.cs @@ -37,6 +37,9 @@ public void ConfigureServices(IServiceCollection services) { options.AddPolicy(SamplePolicyNames.CustomPolicy, policy => policy.AddRequirements(new SampleRequirement())); + + options.AddPolicy(SamplePolicyNames.FailureReasonPolicy, policy => + policy.AddRequirements(new SampleFailReasonRequirement())); options.AddPolicy(SamplePolicyNames.CustomPolicyWithCustomForbiddenMessage, policy => policy.AddRequirements(new SampleWithCustomMessageRequirement())); @@ -44,6 +47,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); }