f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
253 lines
11 KiB
C#
253 lines
11 KiB
C#
using System.Text.Encodings.Web;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Antiforgery;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
|
public static class DashboardEndpointRouteBuilderExtensions
|
|
{
|
|
/// <summary>
|
|
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route. Registered
|
|
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
|
|
/// </summary>
|
|
internal const string LoginRateLimiterPolicy = "dashboard-login";
|
|
|
|
/// <summary>
|
|
/// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
|
|
/// Shared by the production policy registration and the tests so both exercise identical limits.
|
|
/// </summary>
|
|
/// <param name="httpContext">The inbound request.</param>
|
|
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
|
|
/// <returns>The per-IP fixed-window partition.</returns>
|
|
internal static RateLimitPartition<string> GetLoginRateLimitPartition(
|
|
HttpContext httpContext,
|
|
SecurityOptions security)
|
|
{
|
|
string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
return RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey,
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = security.LoginRateLimitPermitLimit,
|
|
Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test seam: builds a standalone partitioned limiter over the production partition logic, so a
|
|
/// test can acquire leases and assert the (permit+1)th is rejected without an HTTP server.
|
|
/// </summary>
|
|
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
|
|
/// <returns>A partitioned rate limiter keyed the same way as the production login policy.</returns>
|
|
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
|
|
=> PartitionedRateLimiter.Create<HttpContext, string>(
|
|
ctx => GetLoginRateLimitPartition(ctx, security));
|
|
|
|
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
|
/// <param name="endpoints">The endpoint route builder.</param>
|
|
/// <returns>The route builder for chaining.</returns>
|
|
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;
|
|
}
|
|
|
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
|
// (Components/Pages/Login.razor → @page "/login"), which renders the shared
|
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
|
//
|
|
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
|
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
|
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
|
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
|
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
|
endpoints.MapPost(
|
|
"/auth/login",
|
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
|
.AllowAnonymous()
|
|
.RequireRateLimiting(LoginRateLimiterPolicy)
|
|
.WithName("DashboardLoginPost");
|
|
|
|
endpoints.MapPost(
|
|
"/logout",
|
|
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardLogout");
|
|
|
|
// Browsers that navigate directly to /logout issue a GET. Sign out and
|
|
// redirect to /login so the URL works as users expect. Logout is
|
|
// self-destructive, so the GET path intentionally skips antiforgery.
|
|
endpoints.MapGet(
|
|
"/logout",
|
|
(Delegate)(static (HttpContext httpContext) => GetLogoutAsync(httpContext)))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardLogoutGet");
|
|
|
|
endpoints.MapGet("/denied", () => Results.Content(
|
|
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
|
|
"text/html"))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardAccessDenied");
|
|
|
|
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
|
|
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
|
|
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
|
|
// real authenticated dashboard role — see AlarmsHub for why the alarm feed is
|
|
// gated harder than the session-scoped ones.
|
|
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
|
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
|
endpoints.MapHub<EventsHub>("/hubs/events");
|
|
|
|
// Bearer-token mint endpoint. The cookie-authenticated browser hits
|
|
// this from JS before opening a hub connection; the hub then accepts
|
|
// the returned token via the HubToken scheme. Restricting access to
|
|
// the cookie scheme keeps the bearer issuance path from being
|
|
// self-bootstrapped from a previous bearer.
|
|
endpoints.MapGet(
|
|
"/hubs/token",
|
|
(HttpContext httpContext, HubTokenService tokens) =>
|
|
{
|
|
string token = tokens.Issue(httpContext.User);
|
|
return Results.Json(new { token });
|
|
})
|
|
.RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy)
|
|
.WithName("DashboardHubToken");
|
|
|
|
// Every dashboard Razor component requires a viewer-or-admin role. The
|
|
// login/logout/denied endpoints above opt out via AllowAnonymous(); an
|
|
// unauthenticated request to a component route is challenged by the
|
|
// cookie scheme and redirected to /login.
|
|
endpoints.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode()
|
|
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
|
|
.RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static async Task<IResult> PostLoginAsync(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery,
|
|
IDashboardAuthenticator authenticator)
|
|
{
|
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
|
|
|
IFormCollection form = await httpContext.Request
|
|
.ReadFormAsync(httpContext.RequestAborted)
|
|
.ConfigureAwait(false);
|
|
string returnUrl = SanitizeReturnUrl(form["returnUrl"].ToString());
|
|
|
|
DashboardAuthenticationResult result = await authenticator
|
|
.AuthenticateAsync(
|
|
form["username"].ToString(),
|
|
form["password"].ToString(),
|
|
httpContext.RequestAborted)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!result.Succeeded || result.Principal is null)
|
|
{
|
|
// Round-trip the failure back to the anonymous Blazor /login page, carrying
|
|
// the (sanitized) returnUrl so a successful retry still lands on the target.
|
|
string failureMessage = result.FailureMessage
|
|
?? "The username or password is invalid, or the user is not authorized.";
|
|
return Results.Redirect(
|
|
$"/login?error={Uri.EscapeDataString(failureMessage)}"
|
|
+ $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
|
}
|
|
|
|
await httpContext
|
|
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.LocalRedirect(returnUrl);
|
|
}
|
|
|
|
private static async Task<IResult> PostLogoutAsync(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery)
|
|
{
|
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
|
await httpContext
|
|
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.LocalRedirect("/login");
|
|
}
|
|
|
|
private static async Task<IResult> GetLogoutAsync(HttpContext httpContext)
|
|
{
|
|
await httpContext
|
|
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.LocalRedirect("/login");
|
|
}
|
|
|
|
private static string RenderPage(string title, string body)
|
|
=> RenderPage(title, heading: title, body);
|
|
|
|
private static string RenderPage(string title, string? heading, string body)
|
|
{
|
|
string headingHtml = string.IsNullOrEmpty(heading)
|
|
? string.Empty
|
|
: $"""
|
|
<div class="dashboard-page-header">
|
|
<h1>{HtmlEncoder.Default.Encode(heading)}</h1>
|
|
</div>
|
|
""";
|
|
|
|
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>
|
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
|
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
|
|
<link rel="stylesheet" href="/css/site.css" />
|
|
</head>
|
|
<body class="dashboard-body">
|
|
<header class="app-bar">
|
|
<span class="brand"><span class="mark">▮</span> MXAccess Gateway</span>
|
|
</header>
|
|
<main class="page">
|
|
{headingHtml}
|
|
{body}
|
|
</main>
|
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
|
|
private static string SanitizeReturnUrl(string? returnUrl)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(returnUrl)
|
|
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
|
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|
|
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
|
|
{
|
|
return "/";
|
|
}
|
|
|
|
return returnUrl;
|
|
}
|
|
}
|