From 4931f2768d3230bd159840c4bc8f775b4d0e6f50 Mon Sep 17 00:00:00 2001 From: Osama Bashir Date: Tue, 31 Oct 2023 23:31:04 +0300 Subject: [PATCH 1/2] Add Basic authentication filter --- README.md | 15 ++++ .../BasicAuthenticationFilter.cs | 87 +++++++++++++++++++ .../BasicAuthenticationFilterTests.cs | 71 +++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs create mode 100644 tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs diff --git a/README.md b/README.md index e5964540..eb088637 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,21 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) } ``` +## Basic Authentication + +If you need to add basic authentication to your serilog-ui instance, you can use the `BasicAuthenticationFilter`. Here's how to configure it in your `Startup.Configure` method: + +```csharp +app.UseSerilogUi(options => +{ + options.Authorization.Filters = new IUiAuthorizationFilter[] + { + new BasicAuthenticationFilter { User = "User", Pass = "P@ss" } + }; + options.Authorization.RunAuthorizationFilterOnAppRoutes = true; +}); +``` + ### For further configuration: [:fast_forward:](https://github.com/serilog-contrib/serilog-ui/wiki/Install:-Configuration-Options) ## Running the Tests: [:test_tube:](https://github.com/serilog-contrib/serilog-ui/wiki/Development:-Testing) diff --git a/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs b/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs new file mode 100644 index 00000000..22f24e34 --- /dev/null +++ b/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs @@ -0,0 +1,87 @@ +using System; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Serilog.Ui.Web.Authorization; + +public class BasicAuthenticationFilter : IUiAuthorizationFilter +{ + public string User { get; set; } + public string Pass { get; set; } + + private const string AuthenticationScheme = "Basic"; + private const string AuthenticationCookieName = "SerilogAuth"; + + public bool Authorize(HttpContext httpContext) + { + var header = httpContext.Request.Headers["Authorization"]; + var isAuthenticated = false; + + if (header == "null" || string.IsNullOrEmpty(header)) + { + var authCookie = httpContext.Request.Cookies[AuthenticationCookieName]; + if (!string.IsNullOrWhiteSpace(authCookie)) + { + var hashedCredentials = EncryptCredentials(User, Pass); + isAuthenticated = string.Equals(authCookie, hashedCredentials, StringComparison.OrdinalIgnoreCase); + } + } + else + { + var authValues = AuthenticationHeaderValue.Parse(header); + + if (IsBasicAuthentication(authValues)) + { + var tokens = ExtractAuthenticationTokens(authValues); + + if (CredentialsMatch(tokens)) + { + isAuthenticated = true; + var hashedCredentials = EncryptCredentials(User, Pass); + httpContext.Response.Cookies.Append(AuthenticationCookieName, hashedCredentials); + } + } + } + + if (!isAuthenticated) + { + SetChallengeResponse(httpContext); + } + + return isAuthenticated; + } + + public string EncryptCredentials(string user, string pass) + { + var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes($"{user}:{pass}")); + var hashedCredentials = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + return hashedCredentials; + } + + private static bool IsBasicAuthentication(AuthenticationHeaderValue authValues) + { + return AuthenticationScheme.Equals(authValues.Scheme, StringComparison.InvariantCultureIgnoreCase); + } + + private static (string, string) ExtractAuthenticationTokens(AuthenticationHeaderValue authValues) + { + var parameter = Encoding.UTF8.GetString(Convert.FromBase64String(authValues.Parameter)); + var parts = parameter.Split(':'); + return (parts[0], parts[1]); + } + + private bool CredentialsMatch((string Username, string Password) tokens) + { + return tokens.Username == User && tokens.Password == Pass; + } + + private void SetChallengeResponse(HttpContext httpContext) + { + httpContext.Response.StatusCode = 401; + httpContext.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"SeriLog Ui\""); + httpContext.Response.WriteAsync("Authentication is required."); + } +} \ No newline at end of file diff --git a/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs b/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs new file mode 100644 index 00000000..3ae3c8fc --- /dev/null +++ b/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using System.Threading.Tasks; +using Xunit; + +namespace Serilog.Ui.Web.Authorization.Tests; + +public class BasicAuthenticationFilterTests +{ + [Fact] + public async Task Authorize_WithValidCredentials_ShouldReturnTrue() + { + // Arrange + var filter = new BasicAuthenticationFilter + { + User = "User", + Pass = "P@ss" + }; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Authorization"] = "Basic VXNlcjpQQHNz"; // Base64 encoded "User:P@ss" + + // Act + var result = filter.Authorize(httpContext); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task Authorize_WithInvalidCredentials_ShouldReturnFalse() + { + // Arrange + var filter = new BasicAuthenticationFilter + { + User = "User", + Pass = "P@ss" + }; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Authorization"] = "Basic QWRtaW46dXNlcg=="; // Base64 encoded "Admin:user" + + // Act + var result = filter.Authorize(httpContext); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task Authorize_WithMissingAuthorizationHeader_ShouldSetChallengeResponse() + { + // Arrange + var filter = new BasicAuthenticationFilter + { + User = "User", + Pass = "P@ss" + }; + + var httpContext = new DefaultHttpContext(); + + // Act + var result = filter.Authorize(httpContext); + + // Assert + result.Should().BeFalse(); + httpContext.Response.StatusCode.Should().Be(401); + httpContext.Response.Headers[HeaderNames.WWWAuthenticate].Should().Contain("Basic realm=\"Hangfire Dashboard\""); + } +} \ No newline at end of file From 6b5508bbd2b86779b61612019154f9527eaeb8e9 Mon Sep 17 00:00:00 2001 From: Osama Bashir Date: Tue, 7 Nov 2023 02:08:14 +0300 Subject: [PATCH 2/2] code improvments --- .../Authorization/BasicAuthenticationFilter.cs | 10 +++++----- .../Authorization/BasicAuthenticationFilterTests.cs | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs b/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs index 22f24e34..1f4606ba 100644 --- a/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs +++ b/src/Serilog.Ui.Web/Authorization/BasicAuthenticationFilter.cs @@ -12,7 +12,7 @@ public class BasicAuthenticationFilter : IUiAuthorizationFilter public string Pass { get; set; } private const string AuthenticationScheme = "Basic"; - private const string AuthenticationCookieName = "SerilogAuth"; + internal const string AuthenticationCookieName = "SerilogAuth"; public bool Authorize(HttpContext httpContext) { @@ -25,7 +25,7 @@ public bool Authorize(HttpContext httpContext) if (!string.IsNullOrWhiteSpace(authCookie)) { var hashedCredentials = EncryptCredentials(User, Pass); - isAuthenticated = string.Equals(authCookie, hashedCredentials, StringComparison.OrdinalIgnoreCase); + isAuthenticated = authCookie.Equals(hashedCredentials, StringComparison.OrdinalIgnoreCase); } } else @@ -53,9 +53,9 @@ public bool Authorize(HttpContext httpContext) return isAuthenticated; } - public string EncryptCredentials(string user, string pass) + private string EncryptCredentials(string user, string pass) { - var sha256 = SHA256.Create(); + using var sha256 = SHA256.Create(); var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes($"{user}:{pass}")); var hashedCredentials = BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); return hashedCredentials; @@ -81,7 +81,7 @@ private bool CredentialsMatch((string Username, string Password) tokens) private void SetChallengeResponse(HttpContext httpContext) { httpContext.Response.StatusCode = 401; - httpContext.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"SeriLog Ui\""); + httpContext.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"Serilog UI\""); httpContext.Response.WriteAsync("Authentication is required."); } } \ No newline at end of file diff --git a/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs b/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs index 3ae3c8fc..7ba6f6c3 100644 --- a/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs +++ b/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; @@ -23,9 +24,11 @@ public async Task Authorize_WithValidCredentials_ShouldReturnTrue() // Act var result = filter.Authorize(httpContext); + var authCookie = httpContext.Response.GetTypedHeaders().SetCookie.FirstOrDefault(sc => sc.Name == BasicAuthenticationFilter.AuthenticationCookieName); // Assert result.Should().BeTrue(); + authCookie.Should().NotBeNull(); } [Fact] @@ -66,6 +69,6 @@ public async Task Authorize_WithMissingAuthorizationHeader_ShouldSetChallengeRes // Assert result.Should().BeFalse(); httpContext.Response.StatusCode.Should().Be(401); - httpContext.Response.Headers[HeaderNames.WWWAuthenticate].Should().Contain("Basic realm=\"Hangfire Dashboard\""); + httpContext.Response.Headers[HeaderNames.WWWAuthenticate].Should().Contain("Basic realm=\"Serilog UI\""); } } \ No newline at end of file