diff --git a/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj index 8aeae5d..e36b394 100644 --- a/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj +++ b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj @@ -38,11 +38,11 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs new file mode 100644 index 0000000..9dbd87a --- /dev/null +++ b/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AMWD.Common.AspNetCore.Attributes +{ + /// + /// Implements an IP filter. The defined addresses are blocked. + /// + public class IPBlacklistAttribute : ActionFilterAttribute + { + /// + /// Gets or sets a value indicating whether local (localhost) access is blocked (Default: false). + /// + public bool RestrictLocalAccess { get; set; } + + /// + /// Gets or sets a configuration key where the blocked IP addresses are defined. + /// + /// + /// JSON configuration example:
+ /// {
+ /// "ConfigurationKey": [
+ /// "10.0.0.0/8",
+ /// "172.16.0.0/12",
+ /// "fd00:123:abc::13"
+ /// ]
+ /// } + ///
+ public string ConfigurationKey { get; set; } + + /// + /// Gets or sets a comma separated list of blocked IP addresses. + /// + public string RestrictedIpAddresses { get; set; } + + /// + public override void OnActionExecuting(ActionExecutingContext context) + { + base.OnActionExecuting(context); + + if (!RestrictLocalAccess && context.HttpContext.IsLocalRequest()) + return; + + var remoteIpAddress = context.HttpContext.GetRemoteIpAddress(); + if (!string.IsNullOrWhiteSpace(RestrictedIpAddresses)) + { + string[] ipAddresses = RestrictedIpAddresses.Split(','); + foreach (string ipAddress in ipAddresses) + { + if (string.IsNullOrWhiteSpace(ipAddress)) + continue; + + if (MatchesIpAddress(ipAddress, remoteIpAddress)) + { + context.Result = new ForbidResult(); + return; + } + } + } + + var configuration = context.HttpContext.RequestServices.GetService(); + if (!string.IsNullOrWhiteSpace(ConfigurationKey) && configuration != null) + { + var section = configuration.GetSection(ConfigurationKey); + if (!section.Exists()) + return; + + foreach (var child in section.GetChildren()) + { + if (string.IsNullOrWhiteSpace(child.Value)) + continue; + + if (MatchesIpAddress(child.Value, remoteIpAddress)) + { + context.Result = new ForbidResult(); + return; + } + } + } + } + + private static bool MatchesIpAddress(string configIpAddress, IPAddress remoteIpAddress) + { + if (configIpAddress.Contains('/')) + { + string[] ipNetworkParts = configIpAddress.Split('/'); + var ip = IPAddress.Parse(ipNetworkParts.First()); + int prefix = int.Parse(ipNetworkParts.Last()); + + var net = new IPNetwork(ip, prefix); + return net.Contains(remoteIpAddress); + } + + return IPAddress.Parse(configIpAddress).Equals(remoteIpAddress); + } + } +} diff --git a/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs new file mode 100644 index 0000000..568fe6d --- /dev/null +++ b/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs @@ -0,0 +1,102 @@ +using System.Linq; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AMWD.Common.AspNetCore.Attributes +{ + /// + /// Implements an IP filter. Only defined addresses are allowed to access. + /// + public class IPWhitelistAttribute : ActionFilterAttribute + { + /// + /// Gets or sets a value indicating whether local (localhost) access is granted (Default: true). + /// + public bool AllowLocalAccess { get; set; } = true; + + /// + /// Gets or sets a configuration key where the allowed IP addresses are allowed. + /// + /// + /// JSON configuration example:
+ /// {
+ /// "ConfigurationKey": [
+ /// "10.0.0.0/8",
+ /// "172.16.0.0/12",
+ /// "fd00:123:abc::13"
+ /// ]
+ /// } + ///
+ public string ConfigurationKey { get; set; } + + /// + /// Gets or sets a comma separated list of allowed IP addresses. + /// + public string AllowedIpAddresses { get; set; } + + /// + public override void OnActionExecuting(ActionExecutingContext context) + { + base.OnActionExecuting(context); + + if (AllowLocalAccess && context.HttpContext.IsLocalRequest()) + return; + + var remoteIpAddress = context.HttpContext.GetRemoteIpAddress(); + if (!string.IsNullOrWhiteSpace(AllowedIpAddresses)) + { + string[] ipAddresses = AllowedIpAddresses.Split(','); + foreach (string ipAddress in ipAddresses) + { + if (string.IsNullOrWhiteSpace(ipAddress)) + continue; + + if (MatchesIpAddress(ipAddress, remoteIpAddress)) + return; + } + } + + var configuration = context.HttpContext.RequestServices.GetService(); + if (!string.IsNullOrWhiteSpace(ConfigurationKey) && configuration != null) + { + var section = configuration.GetSection(ConfigurationKey); + if (!section.Exists()) + { + context.Result = new ForbidResult(); + return; + } + + foreach (var child in section.GetChildren()) + { + if (string.IsNullOrWhiteSpace(child.Value)) + continue; + + if (MatchesIpAddress(child.Value, remoteIpAddress)) + return; + } + } + + context.Result = new ForbidResult(); + } + + private static bool MatchesIpAddress(string configIpAddress, IPAddress remoteIpAddress) + { + if (configIpAddress.Contains('/')) + { + string[] ipNetworkParts = configIpAddress.Split('/'); + var ip = IPAddress.Parse(ipNetworkParts.First()); + int prefix = int.Parse(ipNetworkParts.Last()); + + var net = new IPNetwork(ip, prefix); + return net.Contains(remoteIpAddress); + } + + return IPAddress.Parse(configIpAddress).Equals(remoteIpAddress); + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs index 0f0c011..1e593c7 100644 --- a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Http /// /// Retrieves the antiforgery token. /// - /// The web context. + /// The current . /// Name and value of the token. public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext) { @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Http /// /// Returns the remote ip address. /// - /// The web context. + /// The current . /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). /// The ip address of the client. public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For") @@ -39,10 +39,22 @@ namespace Microsoft.AspNetCore.Http return remote; } + /// + /// Returns whether the request was made locally. + /// + /// The current . + /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// + public static bool IsLocalRequest(this HttpContext httpContext, string headerName = "X-Forwarded-For") + { + var remoteIpAddress = httpContext.GetRemoteIpAddress(headerName); + return httpContext.Connection.LocalIpAddress.Equals(remoteIpAddress); + } + /// /// Tries to retrieve the return url. /// - /// + /// The current . /// public static string GetReturnUrl(this HttpContext httpContext) { diff --git a/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj index 31fb867..28866d4 100644 --- a/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj +++ b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj @@ -56,7 +56,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs new file mode 100644 index 0000000..460e6a7 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs @@ -0,0 +1,28 @@ +#if NET6_0_OR_GREATER + +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AMWD.Common.EntityFrameworkCore.Converters +{ + /// + /// Defines the conversion from a object to a which can be handled by the database engine. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + public class DateOnlyConverter : ValueConverter + { + /// + /// Initializes a new instance of the class. + /// + public DateOnlyConverter() + : base( + d => d.ToDateTime(TimeOnly.MinValue), + d => DateOnly.FromDateTime(d) + ) + { } + } +} + +#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs new file mode 100644 index 0000000..2587912 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs @@ -0,0 +1,28 @@ +#if NET6_0_OR_GREATER + +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AMWD.Common.EntityFrameworkCore.Converters +{ + /// + /// Defines the conversion from a nullable object to a nullable which can be handled by the database engine. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + public class NullableDateOnlyConverter : ValueConverter + { + /// + /// Initializes a new instance of the class. + /// + public NullableDateOnlyConverter() + : base( + d => d == null ? null : new DateTime?(d.Value.ToDateTime(TimeOnly.MinValue)), + d => d == null ? null : new DateOnly?(DateOnly.FromDateTime(d.Value)) + ) + { } + } +} + +#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs new file mode 100644 index 0000000..bafc8ca --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs @@ -0,0 +1,28 @@ +#if NET6_0_OR_GREATER + +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AMWD.Common.EntityFrameworkCore.Converters +{ + /// + /// Defines the conversion from a nullable object to a nullable which can be handled by the database engine. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + public class NullableTimeOnlyConverter : ValueConverter + { + /// + /// Initializes a new instance of the class. + /// + public NullableTimeOnlyConverter() + : base( + d => d == null ? null : new TimeSpan?(d.Value.ToTimeSpan()), + d => d == null ? null : new TimeOnly?(TimeOnly.FromTimeSpan(d.Value)) + ) + { } + } +} + +#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs new file mode 100644 index 0000000..acb4ccc --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs @@ -0,0 +1,28 @@ +#if NET6_0_OR_GREATER + +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AMWD.Common.EntityFrameworkCore.Converters +{ + /// + /// Defines the conversion from a object to a which can be handled by the database engine. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + public class TimeOnlyConverter : ValueConverter + { + /// + /// Initializes a new instance of the class. + /// + public TimeOnlyConverter() + : base( + t => t.ToTimeSpan(), + t => TimeOnly.FromTimeSpan(t) + ) + { } + } +} + +#endif diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..8c8f8b1 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,43 @@ +#if NET6_0_OR_GREATER + +using System; +using AMWD.Common.EntityFrameworkCore.Converters; +using Microsoft.EntityFrameworkCore; + +namespace AMWD.Common.EntityFrameworkCore.Extensions +{ + /// + /// Extensions for the of entity framework core. + /// + public static class ModelConfigurationBuilderExtensions + { + /// + /// Adds converters for the and datatypes introduced with .NET 6.0. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + /// The instance. + /// The instance after applying the converters. + public static ModelConfigurationBuilder AddDateOnlyTimeOnlyConverters(this ModelConfigurationBuilder builder) + { + builder.Properties() + .HaveConversion() + .HaveColumnType("date"); + builder.Properties() + .HaveConversion() + .HaveColumnType("date"); + + builder.Properties() + .HaveConversion() + .HaveColumnType("time"); + builder.Properties() + .HaveConversion() + .HaveColumnType("time"); + + return builder; + } + } +} + +#endif diff --git a/AMWD.Common.Moq/AMWD.Common.Moq.csproj b/AMWD.Common.Moq/AMWD.Common.Moq.csproj index b0afde3..e4e6c8f 100644 --- a/AMWD.Common.Moq/AMWD.Common.Moq.csproj +++ b/AMWD.Common.Moq/AMWD.Common.Moq.csproj @@ -36,8 +36,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/AMWD.Common.Moq/TcpClientMoq.cs b/AMWD.Common.Moq/TcpClientMoq.cs new file mode 100644 index 0000000..cc1eced --- /dev/null +++ b/AMWD.Common.Moq/TcpClientMoq.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace AMWD.Common.Moq +{ + /// + /// Wrapps the including the setup. + /// + public class TcpClientMoq + { + private readonly Mock streamMock; + + /// + /// Initializes a new instance of the class. + /// + public TcpClientMoq() + { + Callbacks = new(); + Response = new byte[0]; + + streamMock = new(); + streamMock + .Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((buffer, offset, count, _) => + { + var callback = new TcpClientCallback + { + Buffer = new byte[count], + Offset = offset, + Count = count, + Type = TcpClientCallback.WriteType.Asynchronous + }; + Array.Copy(buffer, offset, callback.Buffer, 0, count); + + Callbacks.Add(callback); + }) + .Returns(Task.CompletedTask); + streamMock + .Setup(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((buffer, offset, count) => + { + var callback = new TcpClientCallback + { + Buffer = new byte[count], + Offset = offset, + Count = count, + Type = TcpClientCallback.WriteType.Synchronous + }; + Array.Copy(buffer, offset, callback.Buffer, 0, count); + + Callbacks.Add(callback); + }); + + streamMock + .Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((buffer, offset, count, _) => + { + byte[] bytes = Response ?? new byte[0]; + Array.Copy(bytes, 0, buffer, offset, Math.Min(bytes.Length, count)); + }) + .ReturnsAsync(Response?.Length ?? 0); + streamMock + .Setup(s => s.Read(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((buffer, offset, count) => + { + byte[] bytes = Response ?? new byte[0]; + Array.Copy(bytes, 0, buffer, offset, Math.Min(bytes.Length, count)); + }) + .Returns(Response?.Length ?? 0); + + Mock = new(); + Mock + .Setup(c => c.GetStream()) + .Returns(streamMock.Object); + } + + /// + /// Gets the mocked . + /// + public Mock Mock { get; } + + /// + /// Gets the placed request. + /// + public List Callbacks { get; } + + /// + /// Gets the byte response, that should be "sent". + /// + public byte[] Response { get; set; } + + /// + /// Resets the and . + /// + public void Reset() + { + Response = new byte[0]; + Callbacks.Clear(); + } + + /// + /// Verifies the number of calls writing asynchronous to the stream. + /// + /// Number of calls. + public void VerifyWriteAsync(Times times) + => streamMock.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); + + /// + /// Verifies the number of calls writing synchronous to the stream. + /// + /// Number of calls. + public void VerifyWriteSync(Times times) + => streamMock.Verify(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny()), times); + + /// + /// Verifies the number of calls reading asynchronous from the stream. + /// + /// Number of calls. + public void VerifyReadAsync(Times times) + => streamMock.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); + + /// + /// Verifies the number of calls reading synchronous from the stream. + /// + /// Number of calls. + public void VerifyReadSync(Times times) + => streamMock.Verify(s => s.Read(It.IsAny(), It.IsAny(), It.IsAny()), times); + + /// + /// Represents the placed TCP request. + /// + public class TcpClientCallback + { + /// + /// Gets or sets the type (a/synchronous call). + /// + public WriteType Type { get; set; } + + /// + /// Gets or sets the buffer content. + /// + public byte[] Buffer { get; set; } + + /// + /// Gets or sets the offset. + /// + public int Offset { get; set; } + + /// + /// Gets or sets the byte count. + /// + public int Count { get; set; } + + /// + /// Lists the possible request types. + /// + public enum WriteType + { + /// + /// The request was synchronous. + /// + Synchronous = 1, + + /// + /// The request was asynchronous. + /// + Asynchronous = 2 + } + } + } +} diff --git a/AMWD.Common.Tests/AMWD.Common.Tests.csproj b/AMWD.Common.Tests/AMWD.Common.Tests.csproj index 5e7b74c..bf1fc7d 100644 --- a/AMWD.Common.Tests/AMWD.Common.Tests.csproj +++ b/AMWD.Common.Tests/AMWD.Common.Tests.csproj @@ -7,13 +7,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/AMWD.Common/AMWD.Common.csproj b/AMWD.Common/AMWD.Common.csproj index aaf036e..3122dbd 100644 --- a/AMWD.Common/AMWD.Common.csproj +++ b/AMWD.Common/AMWD.Common.csproj @@ -38,8 +38,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ffcbb..a8798d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,16 @@ 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). -## [Unreleased](https://git.am-wd.de/AM.WD/common/compare/v1.4.3...master) - 0000-00-00 +## [Unreleased](https://git.am-wd.de/AM.WD/common/compare/v1.5.0...master) - 0000-00-00 _nothing changed yet_ +## [v1.5.0](https://git.am-wd.de/AM.WD/common/compare/v1.4.3...v1.5.0) - 2022-06-15 +### Added +- `TcpClientMoq` to test communication via a `TcpClient` +- EntityFramework Core Converters for new `DateOnly` and `TimeOnly` datatypes when using SQL Server on .NET 6.0 (Bug on Microsoft's EntityFramework) +- `HttpContext.IsLocalRequest()` to determine whether the request was from local or remote. +- `IPWhitelistAttribute` and `IPBlacklistAttribute` to allow/restrict access on specific controllers/actions. + ## [v1.4.3](https://git.am-wd.de/AM.WD/common/compare/v1.4.2...v1.4.3) - 2022-05-12 ### Fixed