1
0

Added White-/Blacklist Attributes, DateOnly/TimeOnly converters, TcpClientMoq

This commit is contained in:
2022-06-15 19:48:47 +02:00
parent f331be521f
commit 65bca0a922
15 changed files with 570 additions and 16 deletions

View File

@@ -38,11 +38,11 @@
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Newtonsoft.Json" Version="10.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="10.0.1" />
<PackageReference Include="Unclassified.DeepConvert" Version="1.3.0" /> <PackageReference Include="Unclassified.DeepConvert" Version="1.4.0" />
<PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.2"> <PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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
{
/// <summary>
/// Implements an IP filter. The defined addresses are blocked.
/// </summary>
public class IPBlacklistAttribute : ActionFilterAttribute
{
/// <summary>
/// Gets or sets a value indicating whether local (localhost) access is blocked (Default: false).
/// </summary>
public bool RestrictLocalAccess { get; set; }
/// <summary>
/// Gets or sets a configuration key where the blocked IP addresses are defined.
/// </summary>
/// <remarks>
/// JSON configuration example:<br/>
/// {<br/>
/// "ConfigurationKey": [<br/>
/// "10.0.0.0/8",<br/>
/// "172.16.0.0/12",<br/>
/// "fd00:123:abc::13"<br/>
/// ]<br/>
/// }
/// </remarks>
public string ConfigurationKey { get; set; }
/// <summary>
/// Gets or sets a comma separated list of blocked IP addresses.
/// </summary>
public string RestrictedIpAddresses { get; set; }
/// <inheritdoc/>
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<IConfiguration>();
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);
}
}
}

View File

@@ -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
{
/// <summary>
/// Implements an IP filter. Only defined addresses are allowed to access.
/// </summary>
public class IPWhitelistAttribute : ActionFilterAttribute
{
/// <summary>
/// Gets or sets a value indicating whether local (localhost) access is granted (Default: true).
/// </summary>
public bool AllowLocalAccess { get; set; } = true;
/// <summary>
/// Gets or sets a configuration key where the allowed IP addresses are allowed.
/// </summary>
/// <remarks>
/// JSON configuration example:<br/>
/// {<br/>
/// "ConfigurationKey": [<br/>
/// "10.0.0.0/8",<br/>
/// "172.16.0.0/12",<br/>
/// "fd00:123:abc::13"<br/>
/// ]<br/>
/// }
/// </remarks>
public string ConfigurationKey { get; set; }
/// <summary>
/// Gets or sets a comma separated list of allowed IP addresses.
/// </summary>
public string AllowedIpAddresses { get; set; }
/// <inheritdoc/>
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<IConfiguration>();
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);
}
}
}

View File

@@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Http
/// <summary> /// <summary>
/// Retrieves the antiforgery token. /// Retrieves the antiforgery token.
/// </summary> /// </summary>
/// <param name="httpContext">The web context.</param> /// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
/// <returns>Name and value of the token.</returns> /// <returns>Name and value of the token.</returns>
public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext) public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext)
{ {
@@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Http
/// <summary> /// <summary>
/// Returns the remote ip address. /// Returns the remote ip address.
/// </summary> /// </summary>
/// <param name="httpContext">The web context.</param> /// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
/// <param name="headerName">The name of the header to resolve the <see cref="IPAddress"/> when behind a proxy (Default: X-Forwarded-For).</param> /// <param name="headerName">The name of the header to resolve the <see cref="IPAddress"/> when behind a proxy (Default: X-Forwarded-For).</param>
/// <returns>The ip address of the client.</returns> /// <returns>The ip address of the client.</returns>
public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For") public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For")
@@ -39,10 +39,22 @@ namespace Microsoft.AspNetCore.Http
return remote; return remote;
} }
/// <summary>
/// Returns whether the request was made locally.
/// </summary>
/// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
/// <param name="headerName">The name of the header to resolve the <see cref="IPAddress"/> when behind a proxy (Default: X-Forwarded-For).</param>
/// <returns></returns>
public static bool IsLocalRequest(this HttpContext httpContext, string headerName = "X-Forwarded-For")
{
var remoteIpAddress = httpContext.GetRemoteIpAddress(headerName);
return httpContext.Connection.LocalIpAddress.Equals(remoteIpAddress);
}
/// <summary> /// <summary>
/// Tries to retrieve the return url. /// Tries to retrieve the return url.
/// </summary> /// </summary>
/// <param name="httpContext"></param> /// <param name="httpContext">The current <see cref="HttpContext"/>.</param>
/// <returns></returns> /// <returns></returns>
public static string GetReturnUrl(this HttpContext httpContext) public static string GetReturnUrl(this HttpContext httpContext)
{ {

View File

@@ -56,7 +56,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.2"> <PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -0,0 +1,28 @@
#if NET6_0_OR_GREATER
using System;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AMWD.Common.EntityFrameworkCore.Converters
{
/// <summary>
/// Defines the conversion from a <see cref="DateOnly"/> object to a <see cref="DateTime"/> which can be handled by the database engine.
/// </summary>
/// <remarks>
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
/// </remarks>
public class DateOnlyConverter : ValueConverter<DateOnly, DateTime>
{
/// <summary>
/// Initializes a new instance of the <see cref="DateOnlyConverter"/> class.
/// </summary>
public DateOnlyConverter()
: base(
d => d.ToDateTime(TimeOnly.MinValue),
d => DateOnly.FromDateTime(d)
)
{ }
}
}
#endif

View File

@@ -0,0 +1,28 @@
#if NET6_0_OR_GREATER
using System;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AMWD.Common.EntityFrameworkCore.Converters
{
/// <summary>
/// Defines the conversion from a nullable <see cref="DateOnly"/> object to a nullable <see cref="DateTime"/> which can be handled by the database engine.
/// </summary>
/// <remarks>
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
/// </remarks>
public class NullableDateOnlyConverter : ValueConverter<DateOnly?, DateTime?>
{
/// <summary>
/// Initializes a new instance of the <see cref="NullableDateOnlyConverter"/> class.
/// </summary>
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

View File

@@ -0,0 +1,28 @@
#if NET6_0_OR_GREATER
using System;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AMWD.Common.EntityFrameworkCore.Converters
{
/// <summary>
/// Defines the conversion from a nullable <see cref="TimeOnly"/> object to a nullable <see cref="TimeSpan"/> which can be handled by the database engine.
/// </summary>
/// <remarks>
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
/// </remarks>
public class NullableTimeOnlyConverter : ValueConverter<TimeOnly?, TimeSpan?>
{
/// <summary>
/// Initializes a new instance of the <see cref="NullableTimeOnlyConverter"/> class.
/// </summary>
public NullableTimeOnlyConverter()
: base(
d => d == null ? null : new TimeSpan?(d.Value.ToTimeSpan()),
d => d == null ? null : new TimeOnly?(TimeOnly.FromTimeSpan(d.Value))
)
{ }
}
}
#endif

View File

@@ -0,0 +1,28 @@
#if NET6_0_OR_GREATER
using System;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AMWD.Common.EntityFrameworkCore.Converters
{
/// <summary>
/// Defines the conversion from a <see cref="TimeOnly"/> object to a <see cref="TimeSpan"/> which can be handled by the database engine.
/// </summary>
/// <remarks>
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
/// </remarks>
public class TimeOnlyConverter : ValueConverter<TimeOnly, TimeSpan>
{
/// <summary>
/// Initializes a new instance of the <see cref="TimeOnlyConverter"/> class.
/// </summary>
public TimeOnlyConverter()
: base(
t => t.ToTimeSpan(),
t => TimeOnly.FromTimeSpan(t)
)
{ }
}
}
#endif

View File

@@ -0,0 +1,43 @@
#if NET6_0_OR_GREATER
using System;
using AMWD.Common.EntityFrameworkCore.Converters;
using Microsoft.EntityFrameworkCore;
namespace AMWD.Common.EntityFrameworkCore.Extensions
{
/// <summary>
/// Extensions for the <see cref="ModelConfigurationBuilder"/> of entity framework core.
/// </summary>
public static class ModelConfigurationBuilderExtensions
{
/// <summary>
/// Adds converters for the <see cref="DateOnly"/> and <see cref="TimeOnly"/> datatypes introduced with .NET 6.0.
/// </summary>
/// <remarks>
/// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0.
/// </remarks>
/// <param name="builder">The <see cref="ModelConfigurationBuilder"/> instance.</param>
/// <returns>The <see cref="ModelConfigurationBuilder"/> instance after applying the converters.</returns>
public static ModelConfigurationBuilder AddDateOnlyTimeOnlyConverters(this ModelConfigurationBuilder builder)
{
builder.Properties<DateOnly>()
.HaveConversion<DateOnlyConverter>()
.HaveColumnType("date");
builder.Properties<DateOnly?>()
.HaveConversion<NullableDateOnlyConverter>()
.HaveColumnType("date");
builder.Properties<TimeOnly>()
.HaveConversion<TimeOnlyConverter>()
.HaveColumnType("time");
builder.Properties<TimeOnly?>()
.HaveConversion<NullableTimeOnlyConverter>()
.HaveColumnType("time");
return builder;
}
}
}
#endif

View File

@@ -36,8 +36,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="Moq" Version="4.15.1" />
<PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.2"> <PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -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
{
/// <summary>
/// Wrapps the <see cref="Mock{TcpClient}"/> including the setup.
/// </summary>
public class TcpClientMoq
{
private readonly Mock<NetworkStream> streamMock;
/// <summary>
/// Initializes a new instance of the <see cref="TcpClientMoq"/> class.
/// </summary>
public TcpClientMoq()
{
Callbacks = new();
Response = new byte[0];
streamMock = new();
streamMock
.Setup(s => s.WriteAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Callback<byte[], int, int, CancellationToken>((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<byte[]>(), It.IsAny<int>(), It.IsAny<int>()))
.Callback<byte[], int, int>((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<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Callback<byte[], int, int, CancellationToken>((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<byte[]>(), It.IsAny<int>(), It.IsAny<int>()))
.Callback<byte[], int, int>((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);
}
/// <summary>
/// Gets the mocked <see cref="TcpClient"/>.
/// </summary>
public Mock<TcpClient> Mock { get; }
/// <summary>
/// Gets the placed request.
/// </summary>
public List<TcpClientCallback> Callbacks { get; }
/// <summary>
/// Gets the byte response, that should be "sent".
/// </summary>
public byte[] Response { get; set; }
/// <summary>
/// Resets the <see cref="Response"/> and <see cref="Callbacks"/>.
/// </summary>
public void Reset()
{
Response = new byte[0];
Callbacks.Clear();
}
/// <summary>
/// Verifies the number of calls writing asynchronous to the stream.
/// </summary>
/// <param name="times">Number of calls.</param>
public void VerifyWriteAsync(Times times)
=> streamMock.Verify(s => s.WriteAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), times);
/// <summary>
/// Verifies the number of calls writing synchronous to the stream.
/// </summary>
/// <param name="times">Number of calls.</param>
public void VerifyWriteSync(Times times)
=> streamMock.Verify(s => s.Write(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()), times);
/// <summary>
/// Verifies the number of calls reading asynchronous from the stream.
/// </summary>
/// <param name="times">Number of calls.</param>
public void VerifyReadAsync(Times times)
=> streamMock.Verify(s => s.ReadAsync(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), times);
/// <summary>
/// Verifies the number of calls reading synchronous from the stream.
/// </summary>
/// <param name="times">Number of calls.</param>
public void VerifyReadSync(Times times)
=> streamMock.Verify(s => s.Read(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>()), times);
/// <summary>
/// Represents the placed TCP request.
/// </summary>
public class TcpClientCallback
{
/// <summary>
/// Gets or sets the type (a/synchronous call).
/// </summary>
public WriteType Type { get; set; }
/// <summary>
/// Gets or sets the buffer content.
/// </summary>
public byte[] Buffer { get; set; }
/// <summary>
/// Gets or sets the offset.
/// </summary>
public int Offset { get; set; }
/// <summary>
/// Gets or sets the byte count.
/// </summary>
public int Count { get; set; }
/// <summary>
/// Lists the possible request types.
/// </summary>
public enum WriteType
{
/// <summary>
/// The request was synchronous.
/// </summary>
Synchronous = 1,
/// <summary>
/// The request was asynchronous.
/// </summary>
Asynchronous = 2
}
}
}
}

View File

@@ -7,13 +7,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="3.1.0"> <PackageReference Include="coverlet.msbuild" Version="3.1.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" /> <PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" /> <PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
<PackageReference Include="ReflectionMagic" Version="4.1.0" /> <PackageReference Include="ReflectionMagic" Version="4.1.0" />
</ItemGroup> </ItemGroup>

View File

@@ -38,8 +38,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Dns" Version="7.0.0" /> <PackageReference Include="Dns" Version="7.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="10.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="10.0.1" />
<PackageReference Include="Unclassified.DeepConvert" Version="1.3.0" /> <PackageReference Include="Unclassified.DeepConvert" Version="1.4.0" />
<PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.2"> <PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -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/), 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). 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_ _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 ## [v1.4.3](https://git.am-wd.de/AM.WD/common/compare/v1.4.2...v1.4.3) - 2022-05-12
### Fixed ### Fixed