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; /// Endpoint extensions for registering the gateway dashboard routes. public static class DashboardEndpointRouteBuilderExtensions { /// /// The named rate-limiter policy applied to the POST /auth/login route. Registered /// in GatewayApplication via . /// internal const string LoginRateLimiterPolicy = "dashboard-login"; /// /// 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. /// /// The inbound request. /// The bound security options carrying the login-limit knobs. /// The per-IP fixed-window partition. internal static RateLimitPartition 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, }); } /// /// 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. /// /// The bound security options carrying the login-limit knobs. /// A partitioned rate limiter keyed the same way as the production login policy. internal static PartitionedRateLimiter CreateLoginRateLimiter(SecurityOptions security) => PartitionedRateLimiter.Create( ctx => GetLoginRateLimitPartition(ctx, security)); /// Maps all gateway dashboard routes including login, logout, and Razor components. /// The endpoint route builder. /// The route builder for chaining. 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; } // GET /login is served by the [AllowAnonymous] Blazor component // (Components/Pages/Login.razor → @page "/login"), which renders the shared // kit's . Its [AllowAnonymous] attribute overrides the // RequireAuthorization(ViewerPolicy) that MapRazorComponents() 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 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", "

The signed-in user is not authorized for dashboard access.

"), "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("/hubs/snapshot"); endpoints.MapHub("/hubs/alarms"); endpoints.MapHub("/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() .AddInteractiveServerRenderMode() .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly) .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy); return endpoints; } private static async Task 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 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 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 : $"""

{HtmlEncoder.Default.Encode(heading)}

"""; return $""" {HtmlEncoder.Default.Encode(title)}
▮ MXAccess Gateway
{headingHtml} {body}
"""; } 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; } }