e0a3fbf35b
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.
Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.
Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
209 lines
9.0 KiB
C#
209 lines
9.0 KiB
C#
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;
|
|
|
|
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
|
public static class DashboardEndpointRouteBuilderExtensions
|
|
{
|
|
/// <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()
|
|
.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(Policy = HubClientsPolicy)] so the policy accepts either
|
|
// the dashboard cookie or a HubToken bearer.
|
|
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()
|
|
.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;
|
|
}
|
|
}
|