From 73e54e252d91c7a6d4417e3eedf122b19f3878ce Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 3 Jun 2026 03:56:51 -0400 Subject: [PATCH] feat(dashboard): Blazor LoginCard page reusing the hardened /login endpoint --- .../Components/Layout/LoginLayout.razor | 6 ++ .../Dashboard/Components/Pages/Login.razor | 27 +++++++ ...DashboardEndpointRouteBuilderExtensions.cs | 70 ++++--------------- .../Gateway/GatewayApplicationTests.cs | 20 +++++- 4 files changed, 63 insertions(+), 60 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/LoginLayout.razor create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/LoginLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/LoginLayout.razor new file mode 100644 index 0000000..67f4765 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/LoginLayout.razor @@ -0,0 +1,6 @@ +@inherits LayoutComponentBase + +@* Minimal layout for the login page: no side rail, no brand block. The page + renders its own centred card via the shared kit's . Mirrors + OtOpcUa AdminUI's LoginLayout. *@ +@Body diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor new file mode 100644 index 0000000..c27c9c9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor @@ -0,0 +1,27 @@ +@page "/login" +@layout LoginLayout +@using Microsoft.AspNetCore.Authorization +@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the + RequireAuthorization(ViewerPolicy) that MapRazorComponents() applies, so the + cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users. + + The card is the shared kit's : it renders a NATIVE static +
(username/password + hidden returnUrl). A native + form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint + regardless of this app's InteractiveServer render mode. supplies the + token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@ +@attribute [AllowAnonymous] + + + + + +@code { + /// Original protected URL the operator was bounced from; round-tripped to POST /login. + [SupplyParameterFromQuery(Name = "returnUrl")] + private string? ReturnUrl { get; set; } + + /// Failure message surfaced by POST /login after a failed authentication. + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 173af90..69661b8 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -1,7 +1,6 @@ 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; @@ -25,12 +24,11 @@ public static class DashboardEndpointRouteBuilderExtensions return endpoints; } - endpoints.MapGet( - "/login", - (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery)) - .AllowAnonymous() - .WithName("DashboardLogin"); - + // 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. endpoints.MapPost( "/login", (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => @@ -92,17 +90,6 @@ public static class DashboardEndpointRouteBuilderExtensions 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, @@ -124,10 +111,13 @@ public static class DashboardEndpointRouteBuilderExtensions if (!result.Succeeded || result.Principal is null) { - return TypedResults.Content( - RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage), - "text/html", - statusCode: StatusCodes.Status401Unauthorized); + // 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 @@ -158,42 +148,6 @@ public static class DashboardEndpointRouteBuilderExtensions 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); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 887bffe..98ee67e 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -86,8 +86,14 @@ public sealed class GatewayApplicationTests Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings"); + + // GET /login is now served by the [AllowAnonymous] Blazor component + // (Components/Pages/Login.razor → @page "/login"), not a named minimal-API + // endpoint. The form still POSTs to the minimal-API DashboardLoginPost endpoint. + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login" + && endpoint.Metadata.GetMetadata() is not null); Assert.Contains(endpoints, endpoint => - endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogin"); + endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLoginPost"); Assert.Contains(endpoints, endpoint => endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); } @@ -100,7 +106,7 @@ public sealed class GatewayApplicationTests IReadOnlyList endpoints = GetRouteEndpoints(app); string[] anonymousEndpointNames = - ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"]; + ["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"]; foreach (string endpointName in anonymousEndpointNames) { RouteEndpoint endpoint = Assert.Single( @@ -109,6 +115,16 @@ public sealed class GatewayApplicationTests Assert.NotNull(endpoint.Metadata.GetMetadata()); } + + // GET /login is the [AllowAnonymous] Blazor component route. Its + // [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy) + // that MapRazorComponents() applies, so the LoginPath="/login" redirect + // resolves for unauthenticated users instead of looping the cookie challenge. + RouteEndpoint loginComponent = Assert.Single( + endpoints, + candidate => candidate.RoutePattern.RawText == "/login" + && candidate.Metadata.GetMetadata() is not null); + Assert.NotNull(loginComponent.Metadata.GetMetadata()); } /// Verifies that dashboard Razor component routes require the dashboard viewer policy.