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..1f4606ba --- /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"; + internal 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 = authCookie.Equals(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; + } + + private string EncryptCredentials(string user, string pass) + { + using 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..7ba6f6c3 --- /dev/null +++ b/tests/Serilog.Ui.Web.Tests/Authorization/BasicAuthenticationFilterTests.cs @@ -0,0 +1,74 @@ +using System.Linq; +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); + var authCookie = httpContext.Response.GetTypedHeaders().SetCookie.FirstOrDefault(sc => sc.Name == BasicAuthenticationFilter.AuthenticationCookieName); + + // Assert + result.Should().BeTrue(); + authCookie.Should().NotBeNull(); + } + + [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=\"Serilog UI\""); + } +} \ No newline at end of file