Implement dashboard authentication
This commit is contained in:
@@ -257,19 +257,18 @@ Do not show API key secrets or pepper values.
|
|||||||
|
|
||||||
## Authentication And Authorization
|
## 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.
|
practical.
|
||||||
|
|
||||||
Recommended v1 behavior:
|
Implemented v1 behavior:
|
||||||
|
|
||||||
- dashboard disabled by default unless configured,
|
|
||||||
- when enabled, require API key auth,
|
- when enabled, require API key auth,
|
||||||
- require `admin` scope for dashboard access,
|
- require `admin` scope for dashboard access,
|
||||||
- accept API key through a secure cookie established by a simple login form, or
|
- accept API key through a secure cookie established by a simple login form,
|
||||||
through reverse-proxy/header configuration for local deployments,
|
- do not put API keys in query strings,
|
||||||
- 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`.
|
1. Add `/dashboard/login`.
|
||||||
2. User submits API key over HTTPS.
|
2. User submits API key over HTTPS.
|
||||||
@@ -281,6 +280,13 @@ Simplest implementation path:
|
|||||||
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
||||||
option. It must default to false.
|
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
|
## Configuration
|
||||||
|
|
||||||
Suggested configuration:
|
Suggested configuration:
|
||||||
|
|||||||
@@ -650,6 +650,16 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in
|
|||||||
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
|
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
|
||||||
development only.
|
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:
|
Recommended scopes:
|
||||||
|
|
||||||
- `session:open`
|
- `session:open`
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can
|
|||||||
project without binding to a metrics exporter.
|
project without binding to a metrics exporter.
|
||||||
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
||||||
effective configuration into immutable DTOs for read-only dashboard rendering.
|
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
|
### Worker Process
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GatewayOptions> options) : IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||||
|
|
||||||
|
public async Task<DashboardAuthenticationResult> 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<string>([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<Claim> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||||
@@ -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<IConfiguration>();
|
||||||
|
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", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||||
|
"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 = $"""
|
||||||
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/logout")}" class="mb-3">
|
||||||
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
|
<button type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
<dl>
|
||||||
|
<dt>Open sessions</dt>
|
||||||
|
<dd>{snapshot.Sessions.Count}</dd>
|
||||||
|
<dt>Workers</dt>
|
||||||
|
<dd>{snapshot.Workers.Count}</dd>
|
||||||
|
<dt>Faults</dt>
|
||||||
|
<dd>{snapshot.Faults.Count}</dd>
|
||||||
|
</dl>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<ContentHttpResult> 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<IResult> 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<IResult> 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
|
||||||
|
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||||
|
|
||||||
|
string body = $"""
|
||||||
|
{alert}
|
||||||
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
|
||||||
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
|
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||||
|
<label for="apiKey">API key</label>
|
||||||
|
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return RenderPage("Dashboard Sign In", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderPage(string title, string body)
|
||||||
|
{
|
||||||
|
return $"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||||
|
{body}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
public static class DashboardServiceCollectionExtensions
|
public static class DashboardServiceCollectionExtensions
|
||||||
@@ -5,7 +10,44 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddAntiforgery();
|
||||||
|
services
|
||||||
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(
|
||||||
|
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||||
|
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||||
|
});
|
||||||
|
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureCookieOptions(
|
||||||
|
CookieAuthenticationOptions cookieOptions,
|
||||||
|
IOptions<GatewayOptions> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public interface IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
|
string? apiKey,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ public static class GatewayApplication
|
|||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseGatewayRequestLoggingScope();
|
app.UseGatewayRequestLoggingScope();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.MapGatewayEndpoints();
|
app.MapGatewayEndpoints();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -55,6 +57,7 @@ public static class GatewayApplication
|
|||||||
.WithName("LiveHealth");
|
.WithName("LiveHealth");
|
||||||
|
|
||||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
|
endpoints.MapGatewayDashboard();
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string>(scopes, StringComparer.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||||
|
{
|
||||||
|
public string? LastAuthorizationHeader { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||||
|
string? authorizationHeader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastAuthorizationHeader = authorizationHeader;
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuthorizationHandlerContext> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||||
|
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user