feat(dashboard): Blazor LoginCard page reusing the hardened /login endpoint
This commit is contained in:
@@ -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 <LoginCard>. Mirrors
|
||||||
|
OtOpcUa AdminUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the
|
||||||
|
RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, so the
|
||||||
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
|
<form method="post" action="/login"> (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. <AntiforgeryToken/> supplies the
|
||||||
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
|
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Original protected URL the operator was bounced from; round-tripped to POST /login.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||||
|
private string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Failure message surfaced by POST /login after a failed authentication.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
|
private string? Error { get; set; }
|
||||||
|
}
|
||||||
+12
-58
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
@@ -25,12 +24,11 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints.MapGet(
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
"/login",
|
// (Components/Pages/Login.razor → @page "/login"), which renders the shared
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
.AllowAnonymous()
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
.WithName("DashboardLogin");
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
@@ -92,17 +90,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<ContentHttpResult> 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<IResult> PostLoginAsync(
|
private static async Task<IResult> PostLoginAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
@@ -124,10 +111,13 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
if (!result.Succeeded || result.Principal is null)
|
if (!result.Succeeded || result.Principal is null)
|
||||||
{
|
{
|
||||||
return TypedResults.Content(
|
// Round-trip the failure back to the anonymous Blazor /login page, carrying
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
|
// the (sanitized) returnUrl so a successful retry still lands on the target.
|
||||||
"text/html",
|
string failureMessage = result.FailureMessage
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
?? "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
|
await httpContext
|
||||||
@@ -158,42 +148,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return Results.LocalRedirect("/login");
|
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
|
|
||||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
|
||||||
|
|
||||||
string body = $"""
|
|
||||||
<section class="dashboard-login">
|
|
||||||
{alert}
|
|
||||||
<form method="post" action="/login" class="card login-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
|
||||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Username</label>
|
|
||||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
""";
|
|
||||||
|
|
||||||
return RenderPage("Dashboard Sign In", heading: null, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RenderPage(string title, string body)
|
private static string RenderPage(string title, string body)
|
||||||
=> RenderPage(title, heading: title, body);
|
=> RenderPage(title, heading: title, body);
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,14 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||||
|
|
||||||
|
// GET /login is now served by the [AllowAnonymous] Blazor <Login> 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<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost");
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
||||||
}
|
}
|
||||||
@@ -100,7 +106,7 @@ public sealed class GatewayApplicationTests
|
|||||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||||
|
|
||||||
string[] anonymousEndpointNames =
|
string[] anonymousEndpointNames =
|
||||||
["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
|
["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
|
||||||
foreach (string endpointName in anonymousEndpointNames)
|
foreach (string endpointName in anonymousEndpointNames)
|
||||||
{
|
{
|
||||||
RouteEndpoint endpoint = Assert.Single(
|
RouteEndpoint endpoint = Assert.Single(
|
||||||
@@ -109,6 +115,16 @@ public sealed class GatewayApplicationTests
|
|||||||
|
|
||||||
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
|
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /login is the [AllowAnonymous] Blazor <Login> component route. Its
|
||||||
|
// [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy)
|
||||||
|
// that MapRazorComponents<App>() 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<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
|
Assert.NotNull(loginComponent.Metadata.GetMetadata<IAllowAnonymous>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user