diff --git a/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj b/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj index 44160cbd..6c15ab03 100644 --- a/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj +++ b/src/NLog.Web.AspNetCore/NLog.Web.AspNetCore.csproj @@ -75,7 +75,7 @@ NLog 5 release post: https://nlog-project.org/2021/08/25/nlog-5-0-preview1-ready $(DefineConstants);ASP_NET_CORE;ASP_NET_CORE3 - + diff --git a/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs b/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs new file mode 100644 index 00000000..b63f5cfc --- /dev/null +++ b/src/Shared/LayoutRenderers/AspNetResponseCookieLayoutRenderer.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog.LayoutRenderers; +using NLog.Web.Enums; +using NLog.Web.Internal; +#if !ASP_NET_CORE +using System.Collections.Specialized; +using System.Web; +using Cookies = System.Web.HttpCookieCollection; +#else +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Microsoft.AspNetCore.Http; +#endif + +namespace NLog.Web.LayoutRenderers +{ + /// + /// ASP.NET Response Cookie + /// + /// + /// Example usage of ${aspnet-response-cookie} + /// + /// ${aspnet-response-cookie:OutputFormat=Flat} + /// ${aspnet-response-cookie:OutputFormat=JsonArray} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary:CookieNames=username} + /// ${aspnet-response-cookie:OutputFormat=JsonDictionary:Exclude=access_token} + /// + /// + [LayoutRenderer("aspnet-response-cookie")] + public class AspNetResponseCookieLayoutRenderer : AspNetLayoutMultiValueRendererBase + { + /// + /// Cookie names to be rendered. + /// If null or empty array, all cookies will be rendered. + /// + public List CookieNames { get; set; } + + /// + /// Gets or sets the keys to exclude from the output. If omitted, none are excluded. + /// + /// +#if ASP_NET_CORE + public ISet Exclude { get; set; } +#else + public HashSet Exclude { get; set; } +#endif + + /// + /// Initializes a new instance of the class. + /// + public AspNetResponseCookieLayoutRenderer() + { + Exclude = new HashSet(new[] { "AUTH", "SESS_ID" }, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Renders the ASP.NET Cookie appends it to the specified . + /// + /// The to append the rendered data to. + /// Logging event. + protected override void DoAppend(StringBuilder builder, LogEventInfo logEvent) + { + var httpResponse = HttpContextAccessor.HttpContext.TryGetResponse(); + if (httpResponse == null) + { + return; + } + + var cookies = GetCookies(httpResponse); + if (cookies.Count > 0) + { + bool checkForExclude = (CookieNames == null || CookieNames.Count == 0) && Exclude?.Count > 0; + var cookieValues = GetCookieValues(cookies, checkForExclude); + SerializePairs(cookieValues, builder, logEvent); + } + } + +#if !ASP_NET_CORE + + /// + /// Method to get cookies for .NET Framework + /// + /// + /// + private Cookies GetCookies(HttpResponseBase response) + { + return response.Cookies; + } + + private List GetCookieNames(HttpCookieCollection cookies) + { + return CookieNames?.Count > 0 ? CookieNames : cookies.Keys.Cast().ToList(); + } + + private IEnumerable> GetCookieValues(HttpCookieCollection cookies, bool checkForExclude) + { + var response = new List>(); + var cookieNames = GetCookieNames(cookies); + foreach (var cookieName in cookieNames) + { + if (checkForExclude && Exclude.Contains(cookieName)) + continue; + + var httpCookie = cookies[cookieName]; + if (httpCookie == null) + { + continue; + } + response.AddRange(GetCookieValue(httpCookie,cookieName)); + } + return response; + } + + private IEnumerable> GetCookieValue(HttpCookie httpCookie, string cookieName) + { + if (OutputFormat != AspNetRequestLayoutOutputFormat.Flat) + { + // Split multi-valued cookie, as allowed for in the HttpCookie API for backwards compatibility with classic ASP + var isFirst = true; + foreach (var multiValueKey in httpCookie.Values.AllKeys) + { + var cookieKey = multiValueKey; + if (isFirst) + { + cookieKey = cookieName; + isFirst = false; + } + yield return new KeyValuePair(cookieKey, httpCookie.Values[multiValueKey]); + } + } + else + { + yield return new KeyValuePair(cookieName, httpCookie.Value); + } + } + +#else + /// + /// Method to get cookies for all ASP.NET Core versions + /// + /// + /// + private static IList GetCookies(HttpResponse response) + { + var queryResults = response.Headers[HeaderNames.SetCookie]; + if (queryResults.Count > 0 && SetCookieHeaderValue.TryParseList(queryResults, out var result)) + return result; + else + return Array.Empty(); + } + + private List GetCookieNames(IEnumerable cookies) + { + return CookieNames?.Count > 0 ? CookieNames : cookies.Select(row => row.Name.ToString()).ToList(); + } + + private IEnumerable> GetCookieValues(IEnumerable cookies, bool checkForExclude) + { + var cookieNames = GetCookieNames(cookies); + foreach (var cookieName in cookieNames) + { + if (checkForExclude && Exclude.Contains(cookieName)) + continue; + + var httpCookie = cookies.SingleOrDefault(cookie => cookie.Name.ToString() == cookieName); + if (httpCookie == null) + { + continue; + } + + yield return new KeyValuePair(cookieName, httpCookie.Value.ToString()); + } + } +#endif + } +} \ No newline at end of file diff --git a/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs b/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs new file mode 100644 index 00000000..49a9b938 --- /dev/null +++ b/tests/Shared/LayoutRenderers/AspNetResponseCookieLayoutRendererTests.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog.Web.LayoutRenderers; +using NLog.Web.Enums; +using Xunit; +#if !ASP_NET_CORE +using NSubstitute; +using System.Web; +#else +using Microsoft.Extensions.Primitives; +#endif + +namespace NLog.Web.Tests.LayoutRenderers +{ + public class AspNetResponseCookieLayoutRendererTests : TestInvolvingAspNetHttpContext + { + [Fact] + public void NullKeyRendersAllCookies() + { + var expectedResult = "key=TEST,Key1=TEST1"; + var renderer = CreateRenderer(); + renderer.CookieNames = null; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void NullKeyRendersAllCookiesExceptExcluded() + { + var expectedResult = "Key1=TEST1"; + var renderer = CreateRenderer(); + renderer.CookieNames = null; + renderer.Exclude.Add("key"); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Flat_Formatting() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.Flat; + renderer.CookieNames = new List { "notfound" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Json_Formatting() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + renderer.CookieNames = new List { "notfound" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting() + { + var expectedResult = "key=TEST,Key1=TEST1"; + + var renderer = CreateRenderer(); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting_separators() + { + var expectedResult = "key:TEST|Key1:TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = ":"; + renderer.ItemSeparator = "|"; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Flat_Formatting_separators_layouts() + { + var expectedResult = "key>TEST" + Environment.NewLine + "Key1>TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = "${event-properties:valueSeparator1}"; + renderer.ItemSeparator = "${newline}"; + + var logEventInfo = new LogEventInfo(); + logEventInfo.Properties["valueSeparator1"] = ">"; + + // Act + string result = renderer.Render(logEventInfo); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Flat_Formatting() + { + var expectedResult = "key=TEST"; + + var renderer = CreateRenderer(addSecondCookie: false); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"}]"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Cookie_Json_Formatting_no_array() + { + var expectedResult = "{\"key\":\"TEST\"}"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonDictionary; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"}]"; + + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_Json_Formatting_no_array() + { + var expectedResult = "{\"key\":\"TEST\",\"Key1\":\"TEST1\"}"; + + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonDictionary; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Flat_Formatting_ValuesOnly() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.Flat; + renderer.CookieNames = new List { "notfound" }; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyNotFoundRendersEmptyString_Json_Formatting_ValuesOnly() + { + var renderer = CreateRenderer(); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + renderer.CookieNames = new List { "notfound" }; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Empty(result); + } + + [Fact] + public void KeyFoundRendersValue_Cookie_Multiple_Items_Flat_Formatting_ValuesOnly() + { + var expectedResult = "TEST,TEST1"; + + var renderer = CreateRenderer(); + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Cookie_Multiple_Items_Flat_Formatting_separators_ValuesOnly() + { + var expectedResult = "TEST|TEST1"; + + var renderer = CreateRenderer(); + renderer.ValueSeparator = ":"; + renderer.ItemSeparator = "|"; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Single_Item_Flat_Formatting_ValuesOnly() + { + var expectedResult = "TEST"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(AspNetRequestLayoutOutputFormat.JsonArray)] + [InlineData(AspNetRequestLayoutOutputFormat.JsonDictionary)] + public void KeyFoundRendersValue_Single_Item_JsonArray_Formatting_ValuesOnly(AspNetRequestLayoutOutputFormat outputFormat) + { + var expectedResult = "[\"TEST\"]"; + + var renderer = CreateRenderer(addSecondCookie: false); + renderer.OutputFormat = outputFormat; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(AspNetRequestLayoutOutputFormat.JsonArray)] + [InlineData(AspNetRequestLayoutOutputFormat.JsonDictionary)] + public void KeyFoundRendersValue_Multiple_Items_Json_Formatting_ValuesOnly(AspNetRequestLayoutOutputFormat outputFormat) + { + var expectedResult = "[\"TEST\",\"TEST1\"]"; + + var renderer = CreateRenderer(); + + renderer.OutputFormat = outputFormat; + renderer.ValuesOnly = true; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + //no multivalue cookie keys in ASP.NET core +#if !ASP_NET_CORE + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_And_Cookie_Values_Flat_Formatting() + { + var expectedResult = "key=TEST,key2=Test&key3=Test456"; + + var renderer = CreateRenderer(addSecondCookie: true, addMultiValueCookieKey: true); + renderer.CookieNames = new List { "key", "key2" }; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void KeyFoundRendersValue_Multiple_Cookies_And_Cookie_Values_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"},{\"key2\":\"Test\"},{\"key3\":\"Test456\"}]"; + var renderer = CreateRenderer(addSecondCookie: true, addMultiValueCookieKey: true); + renderer.OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray; + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(expectedResult, result); + } +#endif + +#if !ASP_NET_CORE //todo + + [Fact] + public void CommaSeperatedCookieNamesTest_Multiple_Cookie_Values_Flat_Formatting() + { + // Arrange + var expectedResult = "key=TEST&Key1=TEST1"; + + var cookie = new HttpCookie("key", "TEST") { ["Key1"] = "TEST1" }; + + var layoutRender = new AspNetRequestCookieLayoutRenderer() + { + CookieNames = new List { "key", "key1" } + }; + + var httpContextAccessorMock = CreateHttpContextAccessorMockWithCookie(cookie); + layoutRender.HttpContextAccessor = httpContextAccessorMock; + + // Act + var result = layoutRender.Render(LogEventInfo.CreateNullEvent()); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public void CommaSeperatedCookieNamesTest_Multiple_Cookie_Values_Json_Formatting() + { + var expectedResult = "[{\"key\":\"TEST\"},{\"Key1\":\"TEST1\"}]"; + + var cookie = new HttpCookie("key", "TEST") { ["Key1"] = "TEST1" }; + + var layoutRender = new AspNetRequestCookieLayoutRenderer() + { + CookieNames = new List { "key", "key1" }, + OutputFormat = AspNetRequestLayoutOutputFormat.JsonArray + }; + + var httpContextAccessorMock = CreateHttpContextAccessorMockWithCookie(cookie); + layoutRender.HttpContextAccessor = httpContextAccessorMock; + + var result = layoutRender.Render(LogEventInfo.CreateNullEvent()); + + Assert.Equal(expectedResult, result); + } + + private static IHttpContextAccessor CreateHttpContextAccessorMockWithCookie(HttpCookie cookie) + { + var httpCookieCollection = new HttpCookieCollection { cookie }; + var httpContextAccessorMock = Substitute.For(); + httpContextAccessorMock.HttpContext.Request.Cookies.Returns(httpCookieCollection); + return httpContextAccessorMock; + } + +#endif + + /// + /// Create cookie renderer with mocked HTTP context + /// + /// Add second cookie + /// Make cookie multi-value by adding a second value to it + /// Created cookie layout renderer + /// + /// The parameter allows creation of multi-valued cookies with the same key, + /// as provided in the HttpCookie API for backwards compatibility with classic ASP. + /// This is not supported in ASP.NET Core. For further details, see: + /// https://docs.microsoft.com/en-us/dotnet/api/system.web.httpcookie.item?view=netframework-4.7.1#System_Web_HttpCookie_Item_System_String_ + /// https://stackoverflow.com/a/43831482/6651 + /// https://github.com/aspnet/HttpAbstractions/issues/831 + /// + private AspNetResponseCookieLayoutRenderer CreateRenderer(bool addSecondCookie = true, bool addMultiValueCookieKey = false) + { + var cookieNames = new List(); +#if ASP_NET_CORE + var httpContext = HttpContext; +#else + var httpContext = Substitute.For(); +#endif + +#if ASP_NET_CORE + void AddCookie(string key, string value) + { + cookieNames.Add(key); + + httpContext.Response.Cookies.Append(key,value); + } + + AddCookie("key", "TEST"); + + if (addSecondCookie) + { + AddCookie("Key1", "TEST1"); + } + + if (addMultiValueCookieKey) + { + throw new NotSupportedException("Multi-valued cookie keys are not supported in ASP.NET Core"); + } + +#else + + var cookie1 = new HttpCookie("key", "TEST"); + var cookies = new HttpCookieCollection { cookie1 }; + cookieNames.Add("key"); + + if (addSecondCookie) + { + var cookie2 = new HttpCookie("Key1", "TEST1"); + cookies.Add(cookie2); + cookieNames.Add("Key1"); + } + + if (addMultiValueCookieKey) + { + var multiValueCookie = new HttpCookie("key2", "Test"); + multiValueCookie["key3"] = "Test456"; + cookies.Add(multiValueCookie); + cookieNames.Add("key2"); + } + + httpContext.Response.Cookies.Returns(cookies); +#endif + + var renderer = new AspNetResponseCookieLayoutRenderer(); + renderer.HttpContextAccessor = new FakeHttpContextAccessor(httpContext); + renderer.CookieNames = cookieNames; + return renderer; + } + } +}