diff --git a/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs b/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs index 3a357d5..56a17ad 100644 --- a/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs @@ -4,7 +4,7 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using System.Threading.Tasks; -using AMWD.Common.AspNetCore.BasicAuthentication; +using AMWD.Common.AspNetCore.Security.BasicAuthentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs similarity index 95% rename from AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs rename to AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs index 6d0f33b..8ce5bc3 100644 --- a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace AMWD.Common.AspNetCore.BasicAuthentication +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication { /// /// Implements the for Basic Authentication. diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs similarity index 95% rename from AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs rename to AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs index 4e14801..35268f8 100644 --- a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace AMWD.Common.AspNetCore.BasicAuthentication +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication { /// /// Implements a basic authentication. diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs similarity index 91% rename from AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs rename to AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs index 47fd1ef..0d440d1 100644 --- a/AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs @@ -3,7 +3,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; -namespace AMWD.Common.AspNetCore.BasicAuthentication +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication { /// /// Interface representing the validation of a basic authentication. diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs new file mode 100644 index 0000000..cf56de0 --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Extnsion for to enable folder protection. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public static class ProtectedPathExtensions + { + /// + /// Provide protected paths even for static files. + /// + /// The . + /// The with path and policy name. + public static IApplicationBuilder UseProtectedPath(this IApplicationBuilder app, ProtectedPathOptions options) + => app.UseMiddleware(options); + } +} diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs new file mode 100644 index 0000000..3982333 --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Implements a check to provide protected paths. + /// + public class ProtectedPathMiddleware + { + private readonly RequestDelegate next; + private readonly PathString path; + private readonly string policyName; + + /// + /// Initializes a new instance of the class. + /// + /// The following delegate in the process chain. + /// The options to configure the middleware. + public ProtectedPathMiddleware(RequestDelegate next, ProtectedPathOptions options) + { + this.next = next; + path = options.Path; + policyName = options.PolicyName; + } + + /// + /// The delegate invokation. + /// Performs the protection check. + /// + /// The corresponding HTTP context. + /// The . + /// An awaitable task. + public async Task InvokeAsync(HttpContext httpContext, IAuthorizationService authorizationService) + { + if (httpContext.Request.Path.StartsWithSegments(path)) + { + var result = await authorizationService.AuthorizeAsync(httpContext.User, null, policyName); + if (!result.Succeeded) + { + await httpContext.ChallengeAsync(); + return; + } + } + await next.Invoke(httpContext); + } + } +} diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs new file mode 100644 index 0000000..aa46d86 --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Options to define which folder should be protected. + /// + public class ProtectedPathOptions + { + /// + /// Gets or sets the path to the protected folder. + /// + public PathString Path { get; set; } + + /// + /// Gets or sets the policy name to use. + /// + public string PolicyName { get; set; } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ccaa58..aed38b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Upcoming](https://git.am-wd.de/AM.WD/common/compare/v1.12.0...main) - 0000-00-00 +## [Upcoming](https://git.am-wd.de/AM.WD/common/compare/v1.13.0...main) - 0000-00-00 _no changes yet_ +## [v1.13.0](https://git.am-wd.de/AM.WD/common/compare/v1.12.0...v1.13.0) - 2023-06-27 + +### Added + +- `ProtectedPathMiddleware` to secure even static file paths + +### Changed + +- Moved `BasicAuthentication`* into sub-namespace `Security` + + ## [v1.12.0](https://git.am-wd.de/AM.WD/common/compare/v1.11.1...v1.12.0) - 2023-06-01 ### Changed diff --git a/UnitTests/AspNetCore/Attributes/BasicAuthenticationAttributeTests.cs b/UnitTests/AspNetCore/Attributes/BasicAuthenticationAttributeTests.cs index 31b490c..6984486 100644 --- a/UnitTests/AspNetCore/Attributes/BasicAuthenticationAttributeTests.cs +++ b/UnitTests/AspNetCore/Attributes/BasicAuthenticationAttributeTests.cs @@ -5,7 +5,7 @@ using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; -using AMWD.Common.AspNetCore.BasicAuthentication; +using AMWD.Common.AspNetCore.Security.BasicAuthentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/UnitTests/AspNetCore/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs b/UnitTests/AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs similarity index 95% rename from UnitTests/AspNetCore/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs rename to UnitTests/AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs index 3166753..e942f39 100644 --- a/UnitTests/AspNetCore/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs +++ b/UnitTests/AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddlewareTests.cs @@ -6,13 +6,13 @@ using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; -using AMWD.Common.AspNetCore.BasicAuthentication; +using AMWD.Common.AspNetCore.Security.BasicAuthentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; -namespace UnitTests.AspNetCore.BasicAuthentication +namespace UnitTests.AspNetCore.Security.BasicAuthentication { [TestClass] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] diff --git a/UnitTests/AspNetCore/Security/PathProtection/ProtectedPathMiddlewareTests.cs b/UnitTests/AspNetCore/Security/PathProtection/ProtectedPathMiddlewareTests.cs new file mode 100644 index 0000000..e31c9ff --- /dev/null +++ b/UnitTests/AspNetCore/Security/PathProtection/ProtectedPathMiddlewareTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using AMWD.Common.AspNetCore.Security.PathProtection; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace UnitTests.AspNetCore.Security.PathProtection +{ + [TestClass] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public class ProtectedPathMiddlewareTests + { + private Mock nextMock; + private Mock httpContextMock; + private Mock authorizationServiceMock; + private Mock authenticationServiceMock; + + private ProtectedPathOptions options; + + [TestInitialize] + public void InitializeTest() + { + options = new ProtectedPathOptions + { + Path = "/secure/protected", + PolicyName = "Protection" + }; + } + + [TestMethod] + public async Task ShouldValidateAccessSuccessful() + { + // arrange + var middleware = GetMiddleware(); + var context = GetHttpContext(options.Path); + var auth = GetAuthService(true); + + // act + await middleware.InvokeAsync(context, auth); + + // assert + authorizationServiceMock.Verify(s => s.AuthorizeAsync(It.IsAny(), It.IsAny(), options.PolicyName), Times.Once); + authorizationServiceMock.VerifyNoOtherCalls(); + + authenticationServiceMock.Verify(s => s.ChallengeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + authenticationServiceMock.VerifyNoOtherCalls(); + + nextMock.Verify(n => n.Invoke(It.IsAny()), Times.Once); + nextMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldNotValidate() + { + // arrange + var middleware = GetMiddleware(); + var context = GetHttpContext("/some/path"); + var auth = GetAuthService(true); + + // act + await middleware.InvokeAsync(context, auth); + + // assert + authorizationServiceMock.Verify(s => s.AuthorizeAsync(It.IsAny(), It.IsAny(), options.PolicyName), Times.Never); + authorizationServiceMock.VerifyNoOtherCalls(); + + authenticationServiceMock.Verify(s => s.ChallengeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + authenticationServiceMock.VerifyNoOtherCalls(); + + nextMock.Verify(n => n.Invoke(It.IsAny()), Times.Once); + nextMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldValidateAccessFailure() + { + // arrange + var middleware = GetMiddleware(); + var context = GetHttpContext(options.Path); + var auth = GetAuthService(false); + + // act + await middleware.InvokeAsync(context, auth); + + // assert + authorizationServiceMock.Verify(s => s.AuthorizeAsync(It.IsAny(), It.IsAny(), options.PolicyName), Times.Once); + authorizationServiceMock.VerifyNoOtherCalls(); + + authenticationServiceMock.Verify(s => s.ChallengeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + authenticationServiceMock.VerifyNoOtherCalls(); + + nextMock.Verify(n => n.Invoke(It.IsAny()), Times.Never); + nextMock.VerifyNoOtherCalls(); + } + + private ProtectedPathMiddleware GetMiddleware() + { + nextMock = new Mock(); + return new ProtectedPathMiddleware(nextMock.Object, options); + } + + private HttpContext GetHttpContext(string requestPath) + { + var requestMock = new Mock(); + requestMock + .Setup(r => r.Path) + .Returns(new PathString(requestPath)); + + authenticationServiceMock = new Mock(); + + var requestServicesMock = new Mock(); + requestServicesMock + .Setup(s => s.GetService(typeof(IAuthenticationService))) + .Returns(authenticationServiceMock.Object); + + httpContextMock = new Mock(); + httpContextMock + .Setup(c => c.Request) + .Returns(requestMock.Object); + httpContextMock + .Setup(c => c.RequestServices) + .Returns(requestServicesMock.Object); + + return httpContextMock.Object; + } + + private IAuthorizationService GetAuthService(bool success) + { + authorizationServiceMock = new Mock(); + authorizationServiceMock + .Setup(service => service.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => success ? AuthorizationResult.Success() : AuthorizationResult.Failed()); + + return authorizationServiceMock.Object; + } + } +}