From ff86b3f0b09fe8262cdde51ff28b6efa3f0aa4fa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 18:15:22 -0400 Subject: [PATCH] Implement dashboard authentication --- docs/gateway-dashboard-design.md | 20 +- docs/gateway-process-design.md | 10 + gateway.md | 7 + .../DashboardAuthenticationDefaults.cs | 10 + .../DashboardAuthenticationResult.cs | 19 ++ .../Dashboard/DashboardAuthenticator.cs | 81 +++++++ .../DashboardAuthorizationHandler.cs | 59 +++++ .../DashboardAuthorizationRequirement.cs | 5 + ...DashboardEndpointRouteBuilderExtensions.cs | 217 ++++++++++++++++++ .../DashboardServiceCollectionExtensions.cs | 42 ++++ .../Dashboard/IDashboardAuthenticator.cs | 8 + src/MxGateway.Server/GatewayApplication.cs | 3 + .../Dashboard/DashboardAuthenticatorTests.cs | 113 +++++++++ .../DashboardAuthorizationHandlerTests.cs | 91 ++++++++ .../Dashboard/DashboardCookieOptionsTests.cs | 32 +++ 15 files changed, 710 insertions(+), 7 deletions(-) create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs create mode 100644 src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs diff --git a/docs/gateway-dashboard-design.md b/docs/gateway-dashboard-design.md index 70e68cc..d691c9f 100644 --- a/docs/gateway-dashboard-design.md +++ b/docs/gateway-dashboard-design.md @@ -257,19 +257,18 @@ Do not show API key secrets or pepper values. ## Authentication And Authorization -Dashboard access should use the same API-key authentication model as gRPC where +Dashboard access uses the same API-key authentication model as gRPC where practical. -Recommended v1 behavior: +Implemented v1 behavior: -- dashboard disabled by default unless configured, - when enabled, require API key auth, - require `admin` scope for dashboard access, -- accept API key through a secure cookie established by a simple login form, or - through reverse-proxy/header configuration for local deployments, -- do not put API keys in query strings. +- accept API key through a secure cookie established by a simple login form, +- do not put API keys in query strings, +- validate anti-forgery tokens for login and logout posts. -Simplest implementation path: +The implementation path is: 1. Add `/dashboard/login`. 2. User submits API key over HTTPS. @@ -281,6 +280,13 @@ Simplest implementation path: For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost` option. It must default to false. +`DashboardAuthenticator` keeps API-key validation outside UI components. It +formats the submitted key as a bearer authorization header for +`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is +enabled, and creates the dashboard cookie principal without storing raw API key +material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and +explicit loopback bypass decisions for all protected dashboard routes. + ## Configuration Suggested configuration: diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 33b2b7e..408b687 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -650,6 +650,16 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in `Authentication:Mode` set to `Disabled` bypasses API-key verification for local development only. +Dashboard authentication reuses the API-key verifier and scope model. The +dashboard login endpoint accepts the key in a form post, checks `admin` scope +when `Dashboard:RequireAdminScope` is enabled, and signs in with the +`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict +SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears +that cookie. Login and logout posts use anti-forgery validation, and dashboard +API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost` +allows only loopback requests to bypass the dashboard cookie requirement and +defaults to `false`. + Recommended scopes: - `session:open` diff --git a/gateway.md b/gateway.md index 2dfcbc2..ca82673 100644 --- a/gateway.md +++ b/gateway.md @@ -109,6 +109,13 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can project without binding to a metrics exporter. `DashboardSnapshotService` projects sessions, workers, metrics, faults, and effective configuration into immutable DTOs for read-only dashboard rendering. +Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login` +accepts the API key in a form body, validates the configured `admin` scope, +and issues an HTTP-only secure cookie for subsequent dashboard requests. +`/dashboard/logout` clears that cookie. Login and logout posts validate +anti-forgery tokens, and API keys are never accepted through query strings. +`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for +loopback requests only when explicitly enabled. ### Worker Process diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs new file mode 100644 index 0000000..84cea7a --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -0,0 +1,10 @@ +namespace MxGateway.Server.Dashboard; + +public static class DashboardAuthenticationDefaults +{ + public const string AuthenticationScheme = "MxGateway.Dashboard"; + public const string AuthorizationPolicy = "MxGateway.Dashboard"; + public const string ScopeClaimType = "scope"; + public const string KeyPrefixClaimType = "mxgateway:key_prefix"; + public const string CookieName = "__Host-MxGatewayDashboard"; +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs new file mode 100644 index 0000000..36fbadf --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardAuthenticationResult( + bool Succeeded, + ClaimsPrincipal? Principal, + string? FailureMessage) +{ + public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) + { + return new DashboardAuthenticationResult(true, principal, null); + } + + public static DashboardAuthenticationResult Fail(string failureMessage) + { + return new DashboardAuthenticationResult(false, null, failureMessage); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs new file mode 100644 index 0000000..b0d269b --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthenticator( + IApiKeyVerifier apiKeyVerifier, + IOptions options) : IDashboardAuthenticator +{ + private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access."; + + public async Task AuthenticateAsync( + string? apiKey, + CancellationToken cancellationToken) + { + if (options.Value.Authentication.Mode == AuthenticationMode.Disabled) + { + return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity( + KeyId: "authentication-disabled", + KeyPrefix: "authentication-disabled", + DisplayName: "Authentication Disabled", + Scopes: new HashSet([GatewayScopes.Admin], StringComparer.Ordinal)))); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + ApiKeyVerificationResult verificationResult = await apiKeyVerifier + .VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken) + .ConfigureAwait(false); + + if (!verificationResult.Succeeded || verificationResult.Identity is null) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + if (options.Value.Dashboard.RequireAdminScope + && !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin)) + { + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } + + return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity)); + } + + private static string FormatAuthorizationHeader(string apiKey) + { + string trimmedApiKey = apiKey.Trim(); + + return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? trimmedApiKey + : $"Bearer {trimmedApiKey}"; + } + + private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity) + { + List claims = + [ + new Claim(ClaimTypes.NameIdentifier, identity.KeyId), + new Claim(ClaimTypes.Name, identity.DisplayName), + new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix) + ]; + + claims.AddRange(identity.Scopes.Select(scope => new Claim( + DashboardAuthenticationDefaults.ScopeClaimType, + scope))); + + ClaimsIdentity claimsIdentity = new( + claims, + DashboardAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + DashboardAuthenticationDefaults.ScopeClaimType); + + return new ClaimsPrincipal(claimsIdentity); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs new file mode 100644 index 0000000..6158138 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs @@ -0,0 +1,59 @@ +using System.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthorizationHandler( + IHttpContextAccessor httpContextAccessor, + IOptions options) : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + DashboardAuthorizationRequirement requirement) + { + GatewayOptions gatewayOptions = options.Value; + + if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest()) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private bool IsLoopbackRequest() + { + IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + + return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress); + } + + private static bool HasAdminScope(AuthorizationHandlerContext context) + { + return context.User.HasClaim( + DashboardAuthenticationDefaults.ScopeClaimType, + GatewayScopes.Admin); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs b/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs new file mode 100644 index 0000000..45862e2 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Authorization; + +namespace MxGateway.Server.Dashboard; + +public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement; diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..57b04a1 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -0,0 +1,217 @@ +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.HttpResults; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Dashboard; + +public static class DashboardEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints) + { + IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService(); + IConfigurationSection dashboardSection = configuration + .GetSection($"{GatewayOptions.SectionName}:Dashboard"); + + if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled) + { + return endpoints; + } + + string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase); + RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase); + + dashboard.MapGet( + "/", + (HttpContext httpContext, IAntiforgery antiforgery, IDashboardSnapshotService snapshotService) => + GetDashboardHomeAsync(httpContext, antiforgery, snapshotService, pathBase)) + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy) + .WithName("DashboardHome"); + + dashboard.MapGet( + "/login", + (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase)) + .AllowAnonymous() + .WithName("DashboardLogin"); + + dashboard.MapPost( + "/login", + (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => + PostLoginAsync(httpContext, antiforgery, authenticator, pathBase)) + .AllowAnonymous() + .WithName("DashboardLoginPost"); + + dashboard.MapPost( + "/logout", + (HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase)) + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy) + .WithName("DashboardLogout"); + + dashboard.MapGet("/denied", () => Results.Content( + RenderPage("Access denied", "

The signed-in API key is not authorized for dashboard access.

"), + "text/html")) + .AllowAnonymous() + .WithName("DashboardAccessDenied"); + + return endpoints; + } + + private static ContentHttpResult GetDashboardHomeAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + IDashboardSnapshotService snapshotService, + string pathBase) + { + AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); + DashboardSnapshot snapshot = snapshotService.GetSnapshot(); + string requestToken = tokens.RequestToken ?? string.Empty; + string body = $""" +
+ + +
+
+
Open sessions
+
{snapshot.Sessions.Count}
+
Workers
+
{snapshot.Workers.Count}
+
Faults
+
{snapshot.Faults.Count}
+
+ """; + + return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html"); + } + + private static Task GetLoginAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + string pathBase) + { + string returnUrl = SanitizeReturnUrl( + httpContext.Request.Query["returnUrl"].ToString(), + pathBase); + + return Task.FromResult(TypedResults.Content( + RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null), + "text/html")); + } + + private static async Task PostLoginAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + IDashboardAuthenticator authenticator, + string pathBase) + { + await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); + + IFormCollection form = await httpContext.Request + .ReadFormAsync(httpContext.RequestAborted) + .ConfigureAwait(false); + string returnUrl = SanitizeReturnUrl( + form["returnUrl"].ToString(), + pathBase); + + DashboardAuthenticationResult result = await authenticator + .AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted) + .ConfigureAwait(false); + + if (!result.Succeeded || result.Principal is null) + { + return TypedResults.Content( + RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage), + "text/html", + statusCode: StatusCodes.Status401Unauthorized); + } + + await httpContext + .SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal) + .ConfigureAwait(false); + + return Results.LocalRedirect(returnUrl); + } + + private static async Task PostLogoutAsync( + HttpContext httpContext, + IAntiforgery antiforgery, + string pathBase) + { + await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); + await httpContext + .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme) + .ConfigureAwait(false); + + return Results.LocalRedirect($"{pathBase}/login"); + } + + private static string RenderLoginPage( + HttpContext httpContext, + IAntiforgery antiforgery, + string returnUrl, + string pathBase, + string? failureMessage) + { + AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); + string requestToken = tokens.RequestToken ?? string.Empty; + string alert = string.IsNullOrWhiteSpace(failureMessage) + ? string.Empty + : $"

{HtmlEncoder.Default.Encode(failureMessage)}

"; + + string body = $""" + {alert} +
+ + + + + +
+ """; + + return RenderPage("Dashboard Sign In", body); + } + + private static string RenderPage(string title, string body) + { + return $""" + + + + + + {HtmlEncoder.Default.Encode(title)} + + +
+

{HtmlEncoder.Default.Encode(title)}

+ {body} +
+ + + """; + } + + private static string NormalizePathBase(string pathBase) + { + string normalized = pathBase.TrimEnd('/'); + + return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal) + ? "/dashboard" + : normalized; + } + + private static string SanitizeReturnUrl(string? returnUrl, string pathBase) + { + if (string.IsNullOrWhiteSpace(returnUrl) + || !returnUrl.StartsWith("/", StringComparison.Ordinal) + || returnUrl.StartsWith("//", StringComparison.Ordinal) + || !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase) + || Uri.TryCreate(returnUrl, UriKind.Absolute, out _)) + { + return pathBase; + } + + return returnUrl; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index cfbaa90..da0dddc 100644 --- a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,3 +1,8 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + namespace MxGateway.Server.Dashboard; public static class DashboardServiceCollectionExtensions @@ -5,7 +10,44 @@ public static class DashboardServiceCollectionExtensions public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); + services.AddHttpContextAccessor(); + services.AddAntiforgery(); + services + .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme) + .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme); + services.AddOptions(DashboardAuthenticationDefaults.AuthenticationScheme) + .Configure>(ConfigureCookieOptions); + services.AddAuthorization(options => + { + options.AddPolicy( + DashboardAuthenticationDefaults.AuthorizationPolicy, + policy => policy.AddRequirements(new DashboardAuthorizationRequirement())); + }); + services.AddSingleton(); return services; } + + private static void ConfigureCookieOptions( + CookieAuthenticationOptions cookieOptions, + IOptions gatewayOptions) + { + string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); + if (string.IsNullOrWhiteSpace(pathBase)) + { + pathBase = "/dashboard"; + } + + cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; + cookieOptions.Cookie.HttpOnly = true; + cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always; + cookieOptions.Cookie.SameSite = SameSiteMode.Strict; + cookieOptions.Cookie.Path = "/"; + cookieOptions.LoginPath = $"{pathBase}/login"; + cookieOptions.LogoutPath = $"{pathBase}/logout"; + cookieOptions.AccessDeniedPath = $"{pathBase}/denied"; + cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8); + cookieOptions.SlidingExpiration = true; + } } diff --git a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs new file mode 100644 index 0000000..5c8c330 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs @@ -0,0 +1,8 @@ +namespace MxGateway.Server.Dashboard; + +public interface IDashboardAuthenticator +{ + Task AuthenticateAsync( + string? apiKey, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 887cdcf..0a3f92d 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -19,6 +19,8 @@ public static class GatewayApplication WebApplication app = builder.Build(); app.UseGatewayRequestLoggingScope(); + app.UseAuthentication(); + app.UseAuthorization(); app.MapGatewayEndpoints(); return app; @@ -55,6 +57,7 @@ public static class GatewayApplication .WithName("LiveHealth"); endpoints.MapGrpcService(); + endpoints.MapGatewayDashboard(); return endpoints; } diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs new file mode 100644 index 0000000..aa7dfa5 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardAuthenticatorTests +{ + [Fact] + public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal() + { + FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin)); + DashboardAuthenticator authenticator = CreateAuthenticator(verifier); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value); + Assert.Contains(result.Principal.Claims, claim => + claim.Type == DashboardAuthenticationDefaults.ScopeClaimType + && claim.Value == GatewayScopes.Admin); + Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader); + } + + [Fact] + public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey() + { + DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier( + SuccessWithScopes(GatewayScopes.EventsRead))); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey() + { + DashboardAuthenticator authenticator = CreateAuthenticator( + new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), + requireAdminScope: false); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_secret", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Principal); + } + + [Fact] + public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure() + { + DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier( + ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch))); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "mxgw_operator01_super-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal); + } + + private static DashboardAuthenticator CreateAuthenticator( + IApiKeyVerifier verifier, + bool requireAdminScope = true) + { + return new DashboardAuthenticator( + verifier, + Options.Create(new GatewayOptions + { + Dashboard = new DashboardOptions + { + RequireAdminScope = requireAdminScope + } + })); + } + + private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) + { + return ApiKeyVerificationResult.Success(new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator Key", + Scopes: new HashSet(scopes, StringComparer.Ordinal))); + } + + private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier + { + public string? LastAuthorizationHeader { get; private set; } + + public Task VerifyAsync( + string? authorizationHeader, + CancellationToken cancellationToken) + { + LastAuthorizationHeader = authorizationHeader; + + return Task.FromResult(result); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs new file mode 100644 index 0000000..7683b4f --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardAuthorizationHandlerTests +{ + [Fact] + public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Parse("10.0.0.5"), + allowAnonymousLocalhost: false); + + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + IPAddress.Loopback, + allowAnonymousLocalhost: true); + + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + CreatePrincipal(GatewayScopes.EventsRead), + IPAddress.Loopback, + allowAnonymousLocalhost: false); + + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds() + { + AuthorizationHandlerContext context = await AuthorizeAsync( + CreatePrincipal(GatewayScopes.Admin), + IPAddress.Parse("10.0.0.5"), + allowAnonymousLocalhost: false); + + Assert.True(context.HasSucceeded); + } + + private static async Task AuthorizeAsync( + ClaimsPrincipal principal, + IPAddress remoteAddress, + bool allowAnonymousLocalhost) + { + DashboardAuthorizationRequirement requirement = new(); + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = remoteAddress; + DashboardAuthorizationHandler handler = new( + new HttpContextAccessor { HttpContext = httpContext }, + Options.Create(new GatewayOptions + { + Dashboard = new DashboardOptions + { + AllowAnonymousLocalhost = allowAnonymousLocalhost, + RequireAdminScope = true + } + })); + AuthorizationHandlerContext context = new([requirement], principal, httpContext); + + await handler.HandleAsync(context); + + return context; + } + + private static ClaimsPrincipal CreatePrincipal(string scope) + { + ClaimsIdentity identity = new( + [new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)], + DashboardAuthenticationDefaults.AuthenticationScheme); + + return new ClaimsPrincipal(identity); + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs new file mode 100644 index 0000000..f643986 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MxGateway.Server; +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardCookieOptionsTests +{ + [Fact] + public void Build_ConfiguresSecureDashboardCookie() + { + WebApplication app = GatewayApplication.Build([]); + IOptionsMonitor optionsMonitor = app.Services + .GetRequiredService>(); + + CookieAuthenticationOptions options = optionsMonitor.Get( + DashboardAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name); + Assert.True(options.Cookie.HttpOnly); + Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy); + Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite); + Assert.Equal("/", options.Cookie.Path); + Assert.Equal("/dashboard/login", options.LoginPath); + Assert.Equal("/dashboard/logout", options.LogoutPath); + Assert.Equal("/dashboard/denied", options.AccessDeniedPath); + } +} -- 2.52.0