using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.HttpResults; 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; } endpoints.MapGet( "/login", (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery)) .AllowAnonymous() .WithName("DashboardLogin"); endpoints.MapPost( "/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 Task GetLoginAsync( HttpContext httpContext, IAntiforgery antiforgery) { string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString()); return Task.FromResult(TypedResults.Content( RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null), "text/html")); } 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) { return TypedResults.Content( RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage), "text/html", statusCode: StatusCodes.Status401Unauthorized); } 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 RenderLoginPage( HttpContext httpContext, IAntiforgery antiforgery, string returnUrl, string? failureMessage) { AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); string requestToken = tokens.RequestToken ?? string.Empty; string alert = string.IsNullOrWhiteSpace(failureMessage) ? string.Empty : $"

{HtmlEncoder.Default.Encode(failureMessage)}

"; string body = $""" """; return RenderPage("Dashboard Sign In", heading: null, body); } 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; } }