Implement dashboard authentication
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user