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
: $"