diff --git a/.build/release.props b/.build/release.props index 6ec2fe7..d101a20 100644 --- a/.build/release.props +++ b/.build/release.props @@ -6,9 +6,9 @@ DarkLoop DarkLoop - All rights reserved DarkLoop's Azure Functions Authorization - false + true 4.0.0.0 - 4.1.0 + 4.1.1 $(Version).0 https://github.com/dark-loop/functions-authorize https://github.com/dark-loop/functions-authorize/blob/master/LICENSE diff --git a/ChangeLog.md b/ChangeLog.md index dfc3db3..93c4d43 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,6 +1,9 @@ # Change log Change log stars with version 3.1.3 +## 4.1.1 +After authenticate but before authorize IAuthenticateResultFeature and IHttpAuthenticationFeature are now both set in HttpContext.Features and (for isolated Azure Functions) FunctionContext.Features. + ## 4.1.0 - ### [Breaking] Removing support for `Bearer` scheme and adding `FunctionsBearer` Recent security updates in the Azure Functions runtime are clashing with the use of the default, well known `Bearer` scheme.
diff --git a/src/abstractions/FunctionAuthorizationFeature.cs b/src/abstractions/FunctionAuthorizationFeature.cs new file mode 100644 index 0000000..f5edee2 --- /dev/null +++ b/src/abstractions/FunctionAuthorizationFeature.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) DarkLoop. All rights reserved. +// + +using DarkLoop.Azure.Functions.Authorization.Internal; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Features.Authentication; +using System.Security.Claims; + +namespace DarkLoop.Azure.Functions.Authorization +{ + // This was designed with maximum compatibility with ASP.NET core. It keeps + // two separate features in sync with each other automatically. + internal sealed class FunctionAuthorizationFeature : IAuthenticateResultFeature, IHttpAuthenticationFeature + { + private ClaimsPrincipal? _principal; + private AuthenticateResult? _authenticateResult; + + /// + /// Construct an instance of the feature with the given AuthenticateResult + /// + /// + public FunctionAuthorizationFeature(AuthenticateResult result) + { + Check.NotNull(result, nameof(result)); + + AuthenticateResult = result; + } + + /// + public AuthenticateResult? AuthenticateResult + { + get => _authenticateResult; + set + { + _authenticateResult = value; + _principal = value?.Principal; + } + } + + /// + public ClaimsPrincipal? User + { + get => _principal; + set + { + _authenticateResult = null; + _principal = value; + } + } + } +} diff --git a/src/abstractions/Internal/FunctionsFeatureCollectionExtension.cs b/src/abstractions/Internal/FunctionsFeatureCollectionExtension.cs new file mode 100644 index 0000000..db64504 --- /dev/null +++ b/src/abstractions/Internal/FunctionsFeatureCollectionExtension.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) DarkLoop. All rights reserved. +// + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Features.Authentication; + +namespace DarkLoop.Azure.Functions.Authorization.Internal +{ + // This functionality is used internally to emulate Asp.net's treatment of AuthenticateResult + internal static class FunctionsFeatureCollectionExtension + { + /// + /// Store the given AuthenticateResult in the IFeatureCollection accessible via + /// IAuthenticateResultFeature and IHttpAuthenticationFeature + /// + /// The feature collection to add to + /// The authentication to expose in the feature collection + /// The object associated with the features + public static FunctionAuthorizationFeature SetAuthenticationFeatures(this IFeatureCollection features, AuthenticateResult result) + { + // A single object is used to handle both of these features so that they stay in sync. + // This is in line with what asp core normally does. + var feature = new FunctionAuthorizationFeature(result); + + features.Set(feature); + features.Set(feature); + + return feature; + } + } +} diff --git a/src/in-proc/FunctionsAuthorizationExecutor.cs b/src/in-proc/FunctionsAuthorizationExecutor.cs index cb656b3..3ed1667 100644 --- a/src/in-proc/FunctionsAuthorizationExecutor.cs +++ b/src/in-proc/FunctionsAuthorizationExecutor.cs @@ -79,6 +79,8 @@ public async Task ExecuteAuthorizationAsync(FunctionExecutingContext context, Ht var authenticateResult = await _policyEvaluator.AuthenticateAsync(filter.Policy, httpContext); + httpContext.Features.SetAuthenticationFeatures(authenticateResult); + // still authenticating in case token is sent to set context user but skipping authorization if (filter.AllowAnonymous) { diff --git a/src/isolated/FunctionsAuthorizationMiddleware.cs b/src/isolated/FunctionsAuthorizationMiddleware.cs index dc2f58a..7490e79 100644 --- a/src/isolated/FunctionsAuthorizationMiddleware.cs +++ b/src/isolated/FunctionsAuthorizationMiddleware.cs @@ -6,9 +6,11 @@ using System.Threading.Tasks; using DarkLoop.Azure.Functions.Authorization.Internal; using DarkLoop.Azure.Functions.Authorization.Properties; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Middleware; using Microsoft.Extensions.Logging; @@ -83,6 +85,12 @@ public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next var authenticateResult = await _policyEvaluator.AuthenticateAsync(filter.Policy, httpContext); + var authenticateFeature = httpContext.Features.SetAuthenticationFeatures(authenticateResult); + + // We also make the features available in the FunctionContext + context.Features.Set(authenticateFeature); + context.Features.Set(authenticateFeature); + if (filter.AllowAnonymous) { await next(context); diff --git a/test/Common.Tests/HttpUtils.cs b/test/Common.Tests/HttpUtils.cs index f15de2b..12808f1 100644 --- a/test/Common.Tests/HttpUtils.cs +++ b/test/Common.Tests/HttpUtils.cs @@ -25,11 +25,12 @@ public static HttpContext SetupHttpContext(IServiceProvider services) var streamReader = PipeReader.Create(requestStream); var requestHeaders = new HeaderDictionary(); var streamWriter = PipeWriter.Create(responseStream); + var features = new FeatureCollection(); httpContextMock.SetupGet(x => x.RequestServices).Returns(services); httpContextMock.SetupGet(x => x.Request).Returns(requestMock.Object); httpContextMock.SetupGet(x => x.Response).Returns(responseMock.Object); - httpContextMock.SetupGet(x => x.Features).Returns(Mock.Of()); + httpContextMock.SetupGet(x => x.Features).Returns(features); httpContextMock.SetupGet(x => x.Items).Returns(new Dictionary()); requestMock.SetupGet(x => x.RouteValues).Returns(new RouteValueDictionary()); requestMock.SetupGet(x => x.Body).Returns(requestStream); @@ -43,5 +44,6 @@ public static HttpContext SetupHttpContext(IServiceProvider services) return httpContextMock.Object; } + } } diff --git a/test/InProc.Tests/FunctionsAuthorizationExecutorTests.cs b/test/InProc.Tests/FunctionsAuthorizationExecutorTests.cs index c6584c3..65de4ca 100644 --- a/test/InProc.Tests/FunctionsAuthorizationExecutorTests.cs +++ b/test/InProc.Tests/FunctionsAuthorizationExecutorTests.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Azure.WebJobs.Host; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -237,6 +238,46 @@ public async Task AuthorizationExecutorShouldThrowWhenFailedAuthenticationAndDoe policyEvaluatorMock.Verify(evaluator => evaluator.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [TestMethod("AuthorizationExecutor: should set http features on authenticate success or failure")] + public async Task AuthorizationExecutorShouldSetHttpFeaturesOnSuccessOrFailure() + { + // Arrange + var options = _services!.GetRequiredService>(); + options.CurrentValue.AuthorizationDisabled = false; + + var authorizationProviderMock = new Mock(); + authorizationProviderMock + .Setup(provider => provider.GetAuthorizationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new FunctionAuthorizationFilter(new AuthorizationPolicyBuilder().RequireAssertion(_ => false).Build(), false)); + + var policyEvaluatorMock = new Mock(); + policyEvaluatorMock + .Setup(evaluator => evaluator.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AuthenticateResult.Success(new AuthenticationTicket(new System.Security.Claims.ClaimsPrincipal(), ""))); + policyEvaluatorMock + .Setup(evaluator => evaluator.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(PolicyAuthorizationResult.Success()); + + var executor = new FunctionsAuthorizationExecutor( + authorizationProviderMock.Object, + _services!.GetRequiredService(), + _services!.GetRequiredService(), + policyEvaluatorMock.Object, + _services!.GetRequiredService>(), + _services!.GetRequiredService>()); + + var functionId = Guid.NewGuid(); + var httpContext = HttpUtils.SetupHttpContext(_services!); + var context = SetupExecutingContext(functionId, "TestFunction", httpContext.Request); + + // Act + await executor.ExecuteAuthorizationAsync(context, httpContext); + + // Assert + Assert.IsNotNull(httpContext.Features.Get()?.AuthenticateResult); + Assert.IsNotNull(httpContext.Features.Get()?.User); + } + private static FunctionExecutingContext SetupExecutingContext(Guid functionId, string functionName, HttpRequest request) { var args = new Dictionary diff --git a/test/Isolated.Tests/Fakes/FakeInvocationFeatures.cs b/test/Isolated.Tests/Fakes/FakeInvocationFeatures.cs new file mode 100644 index 0000000..f2b7351 --- /dev/null +++ b/test/Isolated.Tests/Fakes/FakeInvocationFeatures.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) DarkLoop. All rights reserved. +// + +using Microsoft.Azure.Functions.Worker; +using System.Collections; + +namespace Isolated.Tests.Fakes +{ + internal sealed class FakeInvocationFeatures : IInvocationFeatures + { + private Dictionary _underlyingSet = new Dictionary(); + + public T? Get() + { + _underlyingSet.TryGetValue(typeof(T), out var feature); + + return (T?)feature; + } + + public IEnumerator> GetEnumerator() + { + return _underlyingSet.GetEnumerator(); + } + + public void Set(T instance) + { + _underlyingSet.Add(typeof(T), instance); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _underlyingSet.GetEnumerator(); + } + } +} diff --git a/test/Isolated.Tests/FunctionsAuthorizationMiddlewareTests.cs b/test/Isolated.Tests/FunctionsAuthorizationMiddlewareTests.cs index e166c25..b77273a 100644 --- a/test/Isolated.Tests/FunctionsAuthorizationMiddlewareTests.cs +++ b/test/Isolated.Tests/FunctionsAuthorizationMiddlewareTests.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -237,6 +238,50 @@ await middleware.Invoke(context, async fc => policyEvaluatorMock.Verify(evaluator => evaluator.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [TestMethod("AuthorizationMiddleware: on success should set appropriate HttpContext features")] + public async Task AuthorizationMiddlewareOnSuccessShouldSetAppropriateHttpContextFeatures() + { + // Arrange + var authorizationProviderMock = new Mock(); + authorizationProviderMock + .Setup(provider => provider.GetAuthorizationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new FunctionAuthorizationFilter(new AuthorizationPolicyBuilder().RequireAssertion(_ => false).Build(), false)); + + var policyEvaluatorMock = new Mock(); + policyEvaluatorMock + .Setup(evaluator => evaluator.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AuthenticateResult.Success(new AuthenticationTicket(new System.Security.Claims.ClaimsPrincipal(), "fakeauth"))); + policyEvaluatorMock + .Setup(evaluator => evaluator.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(PolicyAuthorizationResult.Success()); + + var middleware = new FunctionsAuthorizationMiddleware( + authorizationProviderMock.Object, + _services!.GetRequiredService(), + _services!.GetRequiredService(), + policyEvaluatorMock.Object, + _services!.GetRequiredService>(), + _services!.GetRequiredService>()); + + var functionId = "098039841"; + var entryPoint = $"{typeof(FakeFunctionClass).FullName}.TestFunction"; + var httpContext = HttpUtils.SetupHttpContext(_services!); + var context = SetupFunctionContext(functionId, "TestFunction", entryPoint, "httpTrigger", "request", httpContext); + + // Act + await middleware.Invoke(context, async fc => + { + await Task.CompletedTask; + }); + + // Assert + Assert.IsNotNull(httpContext.Features.Get()?.AuthenticateResult); + Assert.IsNotNull(httpContext.Features.Get()?.User); + + Assert.IsNotNull(context.Features.Get()?.AuthenticateResult); + Assert.IsNotNull(context.Features.Get()?.User); + } + private FunctionContext SetupFunctionContext( string functionId, string functionName, string entryPoint, string triggerType, string boundTriggerParamName, HttpContext? httpContext = null) { @@ -254,10 +299,11 @@ private FunctionContext SetupFunctionContext( } var context = new Mock(); + var features = new FakeInvocationFeatures(); context.Setup(context => context.FunctionId).Returns(functionId); context.Setup(context => context.FunctionDefinition.Name).Returns(functionName); context.Setup(context => context.FunctionDefinition.EntryPoint).Returns(entryPoint); - context.Setup(context => context.Features).Returns(Mock.Of()); + context.Setup(context => context.Features).Returns(features); context.Setup(context => context.Items).Returns(items); context .Setup(contextMock => contextMock.FunctionDefinition.InputBindings)