using System.Text.Encodings.Web; 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 { /// 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() .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(Policy = HubClientsPolicy)] so the policy accepts either // the dashboard cookie or a HubToken bearer. 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() .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; } }