a8aafdf974
Fixes code-review findings Server-001 (Critical) and Server-003 (High). Server-001: the dashboard Razor components were mapped with no authorization policy, so every dashboard page — including the API Keys page — was reachable unauthenticated. MapRazorComponents<App>() now requires DashboardAuthenticationDefaults.AuthorizationPolicy; unauthenticated requests are challenged by the cookie scheme and redirected to the login page. Server-003: DashboardAuthenticator.CreatePrincipal never issued the 'scope' claim that DashboardAuthorizationHandler checks when Dashboard:RequireAdminScope is enabled, so enforcing the policy would have denied every LDAP login. CreatePrincipal (reached only after the required-group check passes) now emits the admin scope claim. Replaces the GatewayApplicationTests case that asserted dashboard routes allow anonymous access — it encoded the bug as expected behavior — with tests that verify component routes require the policy and the login/logout/denied endpoints allow anonymous. All 309 MxGateway.Tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
8.9 KiB
C#
219 lines
8.9 KiB
C#
using System.Text.Encodings.Web;
|
|
using Microsoft.AspNetCore.Antiforgery;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using MxGateway.Server.Configuration;
|
|
using MxGateway.Server.Dashboard.Components;
|
|
|
|
namespace 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;
|
|
}
|
|
|
|
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
|
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
|
|
|
|
dashboard.MapGet(
|
|
"/login",
|
|
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardLogin");
|
|
|
|
dashboard.MapPost(
|
|
"/login",
|
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
|
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardLoginPost");
|
|
|
|
dashboard.MapPost(
|
|
"/logout",
|
|
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardLogout");
|
|
|
|
dashboard.MapGet("/denied", () => Results.Content(
|
|
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
|
|
"text/html"))
|
|
.AllowAnonymous()
|
|
.WithName("DashboardAccessDenied");
|
|
|
|
// Every dashboard Razor component requires an authorized session. 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 the login page.
|
|
dashboard.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode()
|
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static Task<ContentHttpResult> GetLoginAsync(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery,
|
|
string pathBase)
|
|
{
|
|
string returnUrl = SanitizeReturnUrl(
|
|
httpContext.Request.Query["returnUrl"].ToString(),
|
|
pathBase);
|
|
|
|
return Task.FromResult(TypedResults.Content(
|
|
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
|
|
"text/html"));
|
|
}
|
|
|
|
private static async Task<IResult> PostLoginAsync(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery,
|
|
IDashboardAuthenticator authenticator,
|
|
string pathBase)
|
|
{
|
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
|
|
|
IFormCollection form = await httpContext.Request
|
|
.ReadFormAsync(httpContext.RequestAborted)
|
|
.ConfigureAwait(false);
|
|
string returnUrl = SanitizeReturnUrl(
|
|
form["returnUrl"].ToString(),
|
|
pathBase);
|
|
|
|
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, pathBase, result.FailureMessage),
|
|
"text/html",
|
|
statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
|
|
await httpContext
|
|
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.LocalRedirect(returnUrl);
|
|
}
|
|
|
|
private static async Task<IResult> PostLogoutAsync(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery,
|
|
string pathBase)
|
|
{
|
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
|
await httpContext
|
|
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
|
.ConfigureAwait(false);
|
|
|
|
return Results.LocalRedirect($"{pathBase}/login");
|
|
}
|
|
|
|
private static string RenderLoginPage(
|
|
HttpContext httpContext,
|
|
IAntiforgery antiforgery,
|
|
string returnUrl,
|
|
string pathBase,
|
|
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="{HtmlEncoder.Default.Encode(pathBase + "/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", body);
|
|
}
|
|
|
|
private static string RenderPage(string title, string body)
|
|
{
|
|
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="/css/theme.css" />
|
|
<link rel="stylesheet" href="/css/dashboard.css" />
|
|
</head>
|
|
<body class="dashboard-body">
|
|
<header class="app-bar">
|
|
<span class="brand"><span class="mark">▮</span> MXAccess Gateway</span>
|
|
</header>
|
|
<main class="page">
|
|
<div class="dashboard-page-header">
|
|
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
|
</div>
|
|
{body}
|
|
</main>
|
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
|
|
private static string NormalizePathBase(string pathBase)
|
|
{
|
|
string normalized = pathBase.TrimEnd('/');
|
|
|
|
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
|
|
? "/dashboard"
|
|
: normalized;
|
|
}
|
|
|
|
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(returnUrl)
|
|
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
|
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|
|
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|
|
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
|
|
{
|
|
return pathBase;
|
|
}
|
|
|
|
return returnUrl;
|
|
}
|
|
}
|