Implement dashboard authentication
This commit is contained in:
@@ -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;
|
||||
|
||||
public static class DashboardServiceCollectionExtensions
|
||||
@@ -5,7 +10,44 @@ public static class DashboardServiceCollectionExtensions
|
||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
app.UseGatewayRequestLoggingScope();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapGatewayEndpoints();
|
||||
|
||||
return app;
|
||||
@@ -55,6 +57,7 @@ public static class GatewayApplication
|
||||
.WithName("LiveHealth");
|
||||
|
||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||
endpoints.MapGatewayDashboard();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user