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/Attributes/IPWhitelistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs similarity index 94% rename from AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs rename to AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs index ef2f9bb..4441633 100644 --- a/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters /// /// Implements an IP filter. Only defined addresses are allowed to access. /// - public class IPWhitelistAttribute : ActionFilterAttribute + public class IPAllowListAttribute : ActionFilterAttribute { /// /// Gets or sets a value indicating whether local (localhost) access is granted (Default: true). diff --git a/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs similarity index 85% rename from AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs rename to AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs index 9ca9c88..36d3e33 100644 --- a/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs @@ -10,12 +10,12 @@ namespace Microsoft.AspNetCore.Mvc.Filters /// /// Implements an IP filter. The defined addresses are blocked. /// - public class IPBlacklistAttribute : ActionFilterAttribute + public class IPBlockListAttribute : ActionFilterAttribute { /// /// Gets or sets a value indicating whether local (localhost) access is blocked (Default: false). /// - public bool RestrictLocalAccess { get; set; } + public bool BlockLocalAccess { get; set; } /// /// Gets or sets a configuration key where the blocked IP addresses are defined. @@ -35,7 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters /// /// Gets or sets a comma separated list of blocked IP addresses. /// - public string RestrictedIpAddresses { get; set; } + public string BlockedIpAddresses { get; set; } /// public override void OnActionExecuting(ActionExecutingContext context) @@ -43,13 +43,13 @@ namespace Microsoft.AspNetCore.Mvc.Filters base.OnActionExecuting(context); context.HttpContext.Items["RemoteAddress"] = context.HttpContext.GetRemoteIpAddress(); - if (!RestrictLocalAccess && context.HttpContext.IsLocalRequest()) + if (!BlockLocalAccess && context.HttpContext.IsLocalRequest()) return; var remoteIpAddress = context.HttpContext.GetRemoteIpAddress(); - if (!string.IsNullOrWhiteSpace(RestrictedIpAddresses)) + if (!string.IsNullOrWhiteSpace(BlockedIpAddresses)) { - string[] ipAddresses = RestrictedIpAddresses.Split(','); + string[] ipAddresses = BlockedIpAddresses.Split(','); foreach (string ipAddress in ipAddresses) { if (string.IsNullOrWhiteSpace(ipAddress)) diff --git a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs index d630d61..13f49c3 100644 --- a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Linq; +using System.Net; using Microsoft.AspNetCore.Antiforgery; using Microsoft.Extensions.DependencyInjection; @@ -9,30 +10,57 @@ namespace Microsoft.AspNetCore.Http /// public static class HttpContextExtensions { + // Search these additional headers for a remote client ip address. + private static readonly string[] defaultIpHeaderNames = new[] + { + "X-Forwarded-For", // commonly used on all known proxies + "X-Real-IP", // wide-spread alternative to X-Forwarded-For + "CF-Connecting-IP" // set by Cloudflare + }; + /// /// Retrieves the antiforgery token. /// /// The current . - /// Name and value of the token. - public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext) + /// FormName, HeaderName and Value of the antiforgery token. + public static (string FormName, string HeaderName, string Value) GetAntiforgeryToken(this HttpContext httpContext) { - var af = httpContext.RequestServices.GetService(); - var set = af?.GetAndStoreTokens(httpContext); + var antiforgery = httpContext.RequestServices.GetService(); + var tokenSet = antiforgery?.GetAndStoreTokens(httpContext); - return (Name: set?.FormFieldName, Value: set?.RequestToken); + return (tokenSet?.FormFieldName, tokenSet?.HeaderName, tokenSet?.RequestToken); } /// /// Returns the remote ip address. /// + /// + /// Searches for additional headers in the following order: + /// + /// X-Forwarded-For + /// X-Real-IP + /// CF-Connecting-IP + /// + /// /// The current . - /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// The name of the header to resolve the when behind a proxy. /// The ip address of the client. - public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For") + public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string ipHeaderName = null) { - string forwardedHeader = httpContext.Request.Headers[headerName].ToString(); - if (!string.IsNullOrWhiteSpace(forwardedHeader) && IPAddress.TryParse(forwardedHeader, out var forwarded)) - return forwarded; + string forwardedForAddress = null; + + var headerNames = string.IsNullOrWhiteSpace(ipHeaderName) ? defaultIpHeaderNames : new[] { ipHeaderName }.Concat(defaultIpHeaderNames); + foreach (string headerName in headerNames) + { + if (!httpContext.Request.Headers.ContainsKey(headerName)) + continue; + + forwardedForAddress = httpContext.Request.Headers[headerName].ToString(); + break; + } + + if (!string.IsNullOrWhiteSpace(forwardedForAddress) && IPAddress.TryParse(forwardedForAddress, out var remoteAddress)) + return remoteAddress; return httpContext.Connection.RemoteIpAddress; } @@ -40,12 +68,20 @@ namespace Microsoft.AspNetCore.Http /// /// Returns whether the request was made locally. /// + /// + /// Searches for additional headers in the following order: + /// + /// X-Forwarded-For + /// X-Real-IP + /// CF-Connecting-IP + /// + /// /// The current . - /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// The name of the header to resolve the when behind a proxy. /// - public static bool IsLocalRequest(this HttpContext httpContext, string headerName = "X-Forwarded-For") + public static bool IsLocalRequest(this HttpContext httpContext, string ipHeaderName = null) { - var remoteIpAddress = httpContext.GetRemoteIpAddress(headerName); + var remoteIpAddress = httpContext.GetRemoteIpAddress(ipHeaderName); return httpContext.Connection.LocalIpAddress.Equals(remoteIpAddress); } 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/AMWD.Common/Extensions/JsonExtensions.cs b/AMWD.Common/Extensions/JsonExtensions.cs index 95dfaa1..7d91a91 100644 --- a/AMWD.Common/Extensions/JsonExtensions.cs +++ b/AMWD.Common/Extensions/JsonExtensions.cs @@ -146,13 +146,26 @@ namespace Newtonsoft.Json if (lvlObj == null) return defaultValue; - lvlObj = lvlObj[level]; + string lvl = level; + if (lvlObj.Type == JTokenType.Object) + { + foreach (var prop in ((JObject)lvlObj).Properties()) + { + if (prop.Name.Equals(lvl, System.StringComparison.OrdinalIgnoreCase)) + { + lvl = prop.Name; + break; + } + } + } + + lvlObj = lvlObj[lvl]; } if (lvlObj == null) return defaultValue; - return DeepConvert.ChangeType(lvlObj is JValue ? ((JValue)lvlObj).Value : lvlObj.Value()); + return DeepConvert.ChangeType(lvlObj is JValue lvlValue ? lvlValue.Value : lvlObj.Value()); } /// diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b572b0..537fb80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ 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.11.0...main) - 0000-00-00 +## [Upcoming](https://git.am-wd.de/AM.WD/common/compare/v2.0.0...main) - 0000-00-00 + +_no changes yet_ + + +## [v2.0.0](https://git.am-wd.de/AM.WD/common/compare/v1.13.0...v2.0.0) - 2023-08-08 ### Added @@ -16,6 +21,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for .NET Core 3.1 +## [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 + +- Renamed `IPBlacklistAttribute` to `IPBlockListAttribute` +- Renamed `IPWhitelistAttribute` to `IPAllowListAttribute` +- `HttpContextExtensions` + - `GetAntiforgeryToken()` now returns the header name also + - `GetRemoteIpAddress()` checks following additional headers by default: + - `X-Forwarded-For` + - `X-Real-IP` + - `CF-Connecting-IP` + + +## [v1.11.1](https://git.am-wd.de/AM.WD/common/compare/v1.11.0...v1.11.1) - 2023-05-11 + +### Fixed + +- `JsonExtensions.GetValue()` now is case insensitive and detects the correct property name of a `JObject`. + + ## [v1.11.0](https://git.am-wd.de/AM.WD/common/compare/v1.10.0...v1.11.0) - 2023-03-29 ### Added diff --git a/Directory.Build.targets b/Directory.Build.targets index 84ed1ef..39a9eb1 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -6,8 +6,4 @@ - - - - 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/Attributes/IPWhitelistAttributeTests.cs b/UnitTests/AspNetCore/Attributes/IPAllowListAttributeTests.cs similarity index 91% rename from UnitTests/AspNetCore/Attributes/IPWhitelistAttributeTests.cs rename to UnitTests/AspNetCore/Attributes/IPAllowListAttributeTests.cs index 0545aac..12b2bc3 100644 --- a/UnitTests/AspNetCore/Attributes/IPWhitelistAttributeTests.cs +++ b/UnitTests/AspNetCore/Attributes/IPAllowListAttributeTests.cs @@ -14,7 +14,7 @@ namespace UnitTests.AspNetCore.Attributes { [TestClass] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public class IPWhitelistAttributeTests + public class IPAllowListAttributeTests { private Dictionary requestHeaders; private Dictionary itemsCallback; @@ -37,7 +37,7 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse("192.168.178.1"); - var attribute = new IPWhitelistAttribute(); + var attribute = new IPAllowListAttribute(); var context = GetContext(remote); // act @@ -57,7 +57,7 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse("192.168.178.1"); - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowedIpAddresses = "192.168.178:1" }; @@ -79,7 +79,7 @@ namespace UnitTests.AspNetCore.Attributes public void ShouldAllowLocalAccess() { // arrange - var attribute = new IPWhitelistAttribute(); + var attribute = new IPAllowListAttribute(); var context = GetContext(); // act @@ -95,7 +95,7 @@ namespace UnitTests.AspNetCore.Attributes public void ShouldDenyLocalAccess() { // arrange - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = false }; @@ -120,7 +120,7 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse(address); - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = false, AllowedIpAddresses = ",127.0.0.0/8,192.168.178.10" @@ -154,7 +154,7 @@ namespace UnitTests.AspNetCore.Attributes configExists = true; allowedIpsConfig.Add("127.0.0.0/8"); allowedIpsConfig.Add("192.168.178.10"); - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = true, ConfigurationKey = configKey @@ -178,7 +178,7 @@ namespace UnitTests.AspNetCore.Attributes configExists = true; allowedIpsConfig.Add(""); allowedIpsConfig.Add("192.168.178.10"); - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = false, ConfigurationKey = configKey @@ -206,7 +206,7 @@ namespace UnitTests.AspNetCore.Attributes configKey = "White:List"; configExists = true; allowedIpsConfig.Add("192.168.178.10"); - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = false, ConfigurationKey = configKey @@ -239,7 +239,7 @@ namespace UnitTests.AspNetCore.Attributes // arrange configKey = "White:List"; configExists = false; - var attribute = new IPWhitelistAttribute + var attribute = new IPAllowListAttribute { AllowLocalAccess = false, ConfigurationKey = configKey diff --git a/UnitTests/AspNetCore/Attributes/IPBlacklistAttributeTests.cs b/UnitTests/AspNetCore/Attributes/IPBlockListAttributeTests.cs similarity index 87% rename from UnitTests/AspNetCore/Attributes/IPBlacklistAttributeTests.cs rename to UnitTests/AspNetCore/Attributes/IPBlockListAttributeTests.cs index 5ca53c5..0062362 100644 --- a/UnitTests/AspNetCore/Attributes/IPBlacklistAttributeTests.cs +++ b/UnitTests/AspNetCore/Attributes/IPBlockListAttributeTests.cs @@ -14,7 +14,7 @@ namespace UnitTests.AspNetCore.Attributes { [TestClass] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public class IPBlacklistAttributeTests + public class IPBlockListAttributeTests { private Dictionary requestHeaders; private Dictionary itemsCallback; @@ -37,7 +37,7 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse("192.168.178.1"); - var attribute = new IPBlacklistAttribute(); + var attribute = new IPBlockListAttribute(); var context = GetContext(remote); // act @@ -54,9 +54,9 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse("192.168.178.1"); - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictedIpAddresses = "192.168.178:1" + BlockedIpAddresses = "192.168.178:1" }; var context = GetContext(remote); @@ -73,10 +73,10 @@ namespace UnitTests.AspNetCore.Attributes public void ShouldAllowLocalAccess() { // arrange - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = false, - RestrictedIpAddresses = "127.0.0.0/8" + BlockLocalAccess = false, + BlockedIpAddresses = "127.0.0.0/8" }; var context = GetContext(); @@ -93,10 +93,10 @@ namespace UnitTests.AspNetCore.Attributes public void ShouldBlockLocalAccess() { // arrange - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = true, - RestrictedIpAddresses = ",127.0.0.0/8" + BlockLocalAccess = true, + BlockedIpAddresses = ",127.0.0.0/8" }; var context = GetContext(); @@ -119,10 +119,10 @@ namespace UnitTests.AspNetCore.Attributes { // arrange var remote = IPAddress.Parse(address); - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = true, - RestrictedIpAddresses = "127.0.0.0/8,192.168.178.10" + BlockLocalAccess = true, + BlockedIpAddresses = "127.0.0.0/8,192.168.178.10" }; var context = GetContext(remote); @@ -153,9 +153,9 @@ namespace UnitTests.AspNetCore.Attributes configExists = true; restrictedIpsConfig.Add("127.0.0.0/8"); restrictedIpsConfig.Add("192.168.178.10"); - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = false, + BlockLocalAccess = false, ConfigurationKey = configKey }; var context = GetContext(); @@ -178,9 +178,9 @@ namespace UnitTests.AspNetCore.Attributes restrictedIpsConfig.Add(""); restrictedIpsConfig.Add("127.0.0.0/8"); restrictedIpsConfig.Add("192.168.178.10"); - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = true, + BlockLocalAccess = true, ConfigurationKey = configKey }; var context = GetContext(); @@ -207,9 +207,9 @@ namespace UnitTests.AspNetCore.Attributes configExists = true; restrictedIpsConfig.Add("127.0.0.0/8"); restrictedIpsConfig.Add("192.168.178.10"); - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = true, + BlockLocalAccess = true, ConfigurationKey = configKey }; var remote = IPAddress.Parse(address); @@ -240,9 +240,9 @@ namespace UnitTests.AspNetCore.Attributes // arrange configKey = "Black:List"; configExists = false; - var attribute = new IPBlacklistAttribute + var attribute = new IPBlockListAttribute { - RestrictLocalAccess = true, + BlockLocalAccess = true, ConfigurationKey = configKey }; var context = GetContext(); diff --git a/UnitTests/AspNetCore/Extensions/HttpContextExtensionsTests.cs b/UnitTests/AspNetCore/Extensions/HttpContextExtensionsTests.cs index 247459b..9a6dc52 100644 --- a/UnitTests/AspNetCore/Extensions/HttpContextExtensionsTests.cs +++ b/UnitTests/AspNetCore/Extensions/HttpContextExtensionsTests.cs @@ -14,7 +14,8 @@ namespace UnitTests.AspNetCore.Extensions { private Mock sessionMock; - private string tokenName; + private string tokenFormName; + private string tokenHeaderName; private string tokenValue; private Dictionary requestHeaders; @@ -26,7 +27,8 @@ namespace UnitTests.AspNetCore.Extensions [TestInitialize] public void InitializeTests() { - tokenName = null; + tokenFormName = null; + tokenHeaderName = null; tokenValue = null; requestHeaders = new Dictionary(); @@ -42,34 +44,38 @@ namespace UnitTests.AspNetCore.Extensions public void ShouldReturnAntiforgery() { // arrange - tokenName = "af-token"; + tokenFormName = "af-token"; + tokenHeaderName = "af-header"; tokenValue = "security_first"; var context = GetContext(); // act - var result = context.GetAntiforgeryToken(); + var (formName, headerName, value) = context.GetAntiforgeryToken(); // assert - Assert.AreEqual(tokenName, result.Name); - Assert.AreEqual(tokenValue, result.Value); + Assert.AreEqual(tokenFormName, formName); + Assert.AreEqual(tokenHeaderName, headerName); + Assert.AreEqual(tokenValue, value); } [TestMethod] public void ShouldReturnAntiforgeryNullService() { // arrange - tokenName = "af-token"; + tokenFormName = "af-token"; + tokenHeaderName = "af-header"; tokenValue = "security_first"; var context = GetContext(hasAntiforgery: false); // act - var result = context.GetAntiforgeryToken(); + var (formName, headerName, value) = context.GetAntiforgeryToken(); // assert - Assert.AreEqual(null, result.Name); - Assert.AreEqual(null, result.Value); + Assert.IsNull(formName); + Assert.IsNull(headerName); + Assert.IsNull(value); } [TestMethod] @@ -79,11 +85,12 @@ namespace UnitTests.AspNetCore.Extensions var context = GetContext(); // act - var result = context.GetAntiforgeryToken(); + var (formName, headerName, value) = context.GetAntiforgeryToken(); // assert - Assert.AreEqual(null, result.Name); - Assert.AreEqual(null, result.Value); + Assert.IsNull(formName); + Assert.IsNull(headerName); + Assert.IsNull(value); } #endregion Antiforgery @@ -105,13 +112,16 @@ namespace UnitTests.AspNetCore.Extensions Assert.AreEqual(remote, result); } - [TestMethod] - public void ShouldReturnDefaultHeader() + [DataTestMethod] + [DataRow("X-Forwarded-For")] + [DataRow("X-Real-IP")] + [DataRow("CF-Connecting-IP")] + public void ShouldReturnDefaultHeader(string headerName) { // arrange remote = IPAddress.Parse("1.2.3.4"); var header = IPAddress.Parse("5.6.7.8"); - requestHeaders.Add("X-Forwarded-For", header.ToString()); + requestHeaders.Add(headerName, header.ToString()); var context = GetContext(); @@ -130,12 +140,14 @@ namespace UnitTests.AspNetCore.Extensions remote = IPAddress.Parse("1.2.3.4"); string headerName = "FooBar"; var headerIp = IPAddress.Parse("5.6.7.8"); + requestHeaders.Add(headerName, headerIp.ToString()); + requestHeaders.Add("X-Forwarded-For", remote.ToString()); var context = GetContext(); // act - var result = context.GetRemoteIpAddress(headerName: headerName); + var result = context.GetRemoteIpAddress(ipHeaderName: headerName); // assert Assert.AreNotEqual(remote, result); @@ -221,7 +233,7 @@ namespace UnitTests.AspNetCore.Extensions var context = GetContext(); // act - bool result = context.IsLocalRequest(headerName: headerName); + bool result = context.IsLocalRequest(ipHeaderName: headerName); // assert Assert.IsTrue(result); @@ -254,7 +266,7 @@ namespace UnitTests.AspNetCore.Extensions var context = GetContext(); // act - bool result = context.IsLocalRequest(headerName: headerName); + bool result = context.IsLocalRequest(ipHeaderName: headerName); // assert Assert.IsFalse(result); @@ -385,7 +397,7 @@ namespace UnitTests.AspNetCore.Extensions var antiforgeryMock = new Mock(); antiforgeryMock .Setup(af => af.GetAndStoreTokens(It.IsAny())) - .Returns(string.IsNullOrWhiteSpace(tokenName) ? null : new AntiforgeryTokenSet(tokenValue, tokenValue, tokenName, tokenName)); + .Returns(() => string.IsNullOrWhiteSpace(tokenValue) ? null : new AntiforgeryTokenSet(tokenValue, tokenValue, tokenFormName, tokenHeaderName)); requestServicesMock .Setup(rs => rs.GetService(typeof(IAntiforgery))) 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; + } + } +} diff --git a/UnitTests/Common/Extensions/JsonExtensionsTests.cs b/UnitTests/Common/Extensions/JsonExtensionsTests.cs index 76faa5d..39bb0c3 100644 --- a/UnitTests/Common/Extensions/JsonExtensionsTests.cs +++ b/UnitTests/Common/Extensions/JsonExtensionsTests.cs @@ -260,7 +260,7 @@ namespace UnitTests.Common.Extensions // act string topLevelString = jObj.GetValue("stringValue"); decimal topLevelDecimal = jObj.GetValue("decimalValue"); - int subLevelInteger = jObj.GetValue("object:integerValue"); + int subLevelInteger = jObj.GetValue("object:IntegerValue"); string subLevelString = jObj.GetValue("object:stringValue"); string notExistingOnTopLevel = jObj.GetValue("fancyValue"); @@ -288,7 +288,7 @@ namespace UnitTests.Common.Extensions // act string topLevelString = jObj.GetValue("stringValue", "Test String"); decimal topLevelDecimal = jObj.GetValue("decimalValue", 13.24m); - int subLevelInteger = jObj.GetValue("object:integerValue", 55); + int subLevelInteger = jObj.GetValue("object:IntegerValue", 55); string subLevelString = jObj.GetValue("object:stringValue", "Yeah!"); string notExistingOnTopLevel = jObj.GetValue("fancyValue", "Party!");