dashboard: role-based LDAP auth + hub bearer scheme, drop PathBase
Restructure dashboard auth around LDAP-driven Admin/Viewer roles, add a
bearer scheme so SignalR hubs (next commit) can authenticate without
forwarding the HttpOnly browser cookie, and mount the dashboard at the
host root instead of a configurable `/dashboard` prefix.
Configuration changes (breaking):
- `MxGateway:Dashboard:PathBase` removed — the dashboard now serves at `/`.
- `MxGateway:Dashboard:RequireAdminScope` removed — role checks replace
the single admin-scope claim.
- `MxGateway:Ldap:RequiredGroup` removed — replaced by `MxGateway:Dashboard:GroupToRole`,
a map from LDAP group name to dashboard role. Legal role values:
`Admin` and `Viewer`. Users whose LDAP groups don't intersect this
map are rejected at login (the existing fail-closed contract).
- appsettings.json ships a default mapping `{ GwAdmin: Admin, GwReader: Viewer }`.
Auth model:
- DashboardRoles: new static class with `Admin` and `Viewer` constants.
- DashboardAuthenticator.AuthenticateAsync: after LDAP bind, maps the
user's groups through `DashboardOptions.GroupToRole` and emits one
`ClaimTypes.Role` claim per resolved role. Empty result → login fails.
- DashboardAuthorizationRequirement now carries `RequiredRoles`; static
presets `AnyDashboardRole` (Viewer ∨ Admin) and `AdminOnly`.
- DashboardAuthorizationHandler checks `IsInRole` against the
requirement's role list instead of the old scope claim. The
`AuthenticationMode.Disabled` and `AllowAnonymousLocalhost` bypasses
are preserved.
- DashboardApiKeyAuthorization.CanManage now requires the `Admin` role
(was: required LDAP group membership). The constructor's IOptions
parameter is gone.
Policies / schemes:
- DashboardAuthenticationDefaults gains `ViewerPolicy`, `AdminPolicy`,
`HubClientsPolicy`, and `HubAuthenticationScheme`. The legacy
`AuthorizationPolicy` and `ScopeClaimType` constants are removed.
- DashboardServiceCollectionExtensions registers all three policies,
adds the cookie scheme and the HubToken bearer scheme side by side,
calls `AddSignalR()`, and hard-codes the cookie's login/logout/denied
paths to root-relative `/login` etc.
Hub bearer infrastructure (no hubs wired yet — next commit):
- HubTokenService: mints time-limited data-protected JSON tokens
carrying the user's name, NameIdentifier, and roles. 30-minute
lifetime, purpose `ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`.
- HubTokenAuthenticationHandler: validates the token from
`Authorization: Bearer …` or `?access_token=…` (WebSocket upgrade
query string) and rebuilds the principal.
Endpoint mapping:
- DashboardEndpointRouteBuilderExtensions drops the `MapGroup(pathBase)`
wrapper. Login/logout/denied and Razor component routes are now
mounted at `/`. The login form posts to `/login`. Razor components
require the new `ViewerPolicy`.
- All page `@page "/dashboard/X"` dual-route directives are removed —
pages live at their canonical roots (`@page "/"`, `@page "/sessions"`, …).
- App.razor and DashboardLayout.razor drop their PathBase computations.
EffectiveLdapConfiguration drops `RequiredGroup`; EffectiveDashboardConfiguration
drops `PathBase`/`RequireAdminScope` and gains `GroupToRole`. SettingsPage
renders the role mapping in place of the retired fields.
Tests updated:
- DashboardAuthenticatorTests: covers the new GroupToRole mapping
(short name + DN + multi-role).
- DashboardAuthorizationHandlerTests: split into Viewer-policy and
Admin-policy cases.
- DashboardApiKeyAuthorizationTests, DashboardApiKeyManagementServiceTests:
authorized principal now carries the `Admin` role claim.
- DashboardCookieOptionsTests: expects root-relative login/logout paths.
- GatewayApplicationTests: dashboard component routes registered at `/`,
`/sessions`, … and gated by `ViewerPolicy`. Filter on
`ComponentTypeMetadata` to ignore minimal-API endpoints sharing `/`.
- GatewayOptionsTests + Validator: drop PathBase / RequireAdminScope /
RequiredGroup assertions; add a `GroupToRole` value-validation case.
- DashboardLdapLiveTests: provides the default `GwAdmin` → `Admin`
mapping so the live LDAP bind resolves to a role.
Verification: 473 server tests, 275 worker tests (+9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass.
This commit is intentionally UI-neutral. The sidebar layout and the
SignalR hubs that consume the new HubToken scheme land in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,7 +106,16 @@ public sealed class DashboardLdapLiveTests
|
||||
private static DashboardAuthenticator CreateAuthenticator()
|
||||
{
|
||||
return new DashboardAuthenticator(
|
||||
Options.Create(new GatewayOptions()),
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
},
|
||||
},
|
||||
}),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ public sealed class DashboardOptions
|
||||
/// <summary>Gets whether the dashboard is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Gets the dashboard URL path base.</summary>
|
||||
public string PathBase { get; init; } = "/dashboard";
|
||||
|
||||
/// <summary>Gets whether dashboard access requires admin scope.</summary>
|
||||
public bool RequireAdminScope { get; init; } = true;
|
||||
|
||||
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
|
||||
public bool AllowAnonymousLocalhost { get; init; } = true;
|
||||
|
||||
@@ -25,4 +19,11 @@ public sealed class DashboardOptions
|
||||
|
||||
/// <summary>Gets whether to show full tag values in the dashboard.</summary>
|
||||
public bool ShowTagValues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// LDAP group → dashboard role mapping. Values must be one of
|
||||
/// <see cref="DashboardRoles.Admin"/> or <see cref="DashboardRoles.Viewer"/>.
|
||||
/// Users with no matching group are rejected at login.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveDashboardConfiguration(
|
||||
bool Enabled,
|
||||
string PathBase,
|
||||
bool RequireAdminScope,
|
||||
bool AllowAnonymousLocalhost,
|
||||
int SnapshotIntervalMilliseconds,
|
||||
int RecentFaultLimit,
|
||||
int RecentSessionLimit,
|
||||
bool ShowTagValues);
|
||||
bool ShowTagValues,
|
||||
IReadOnlyDictionary<string, string> GroupToRole);
|
||||
|
||||
@@ -11,5 +11,4 @@ public sealed record EffectiveLdapConfiguration(
|
||||
string ServiceAccountPassword,
|
||||
string UserNameAttribute,
|
||||
string DisplayNameAttribute,
|
||||
string GroupAttribute,
|
||||
string RequiredGroup);
|
||||
string GroupAttribute);
|
||||
|
||||
@@ -30,8 +30,7 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
ServiceAccountPassword: RedactedValue,
|
||||
UserNameAttribute: value.Ldap.UserNameAttribute,
|
||||
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
|
||||
GroupAttribute: value.Ldap.GroupAttribute,
|
||||
RequiredGroup: value.Ldap.RequiredGroup),
|
||||
GroupAttribute: value.Ldap.GroupAttribute),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
@@ -53,13 +52,12 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
|
||||
Dashboard: new EffectiveDashboardConfiguration(
|
||||
Enabled: value.Dashboard.Enabled,
|
||||
PathBase: value.Dashboard.PathBase,
|
||||
RequireAdminScope: value.Dashboard.RequireAdminScope,
|
||||
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
|
||||
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
ShowTagValues: value.Dashboard.ShowTagValues,
|
||||
GroupToRole: value.Dashboard.GroupToRole),
|
||||
Protocol: new EffectiveProtocolConfiguration(
|
||||
value.Protocol.WorkerProtocolVersion,
|
||||
value.Protocol.MaxGrpcMessageBytes));
|
||||
|
||||
@@ -86,10 +86,6 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
options.GroupAttribute,
|
||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.RequiredGroup,
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
@@ -206,12 +202,23 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
||||
{
|
||||
if (options.Enabled)
|
||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||
// consequence that no LDAP user can sign in (login returns "no roles
|
||||
// mapped"). Operators who disable the dashboard or want a closed
|
||||
// deployment can ship without a mapping.
|
||||
foreach (KeyValuePair<string, string> entry in options.GroupToRole)
|
||||
{
|
||||
AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures);
|
||||
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
|
||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||
{
|
||||
failures.Add("MxGateway:Dashboard:PathBase must start with '/'.");
|
||||
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
||||
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,4 @@ public sealed class LdapOptions
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
|
||||
public string RequiredGroup { get; init; } = "GwAdmin";
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<base href="@DashboardBaseHref" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
@@ -17,19 +15,3 @@
|
||||
<script src="/_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private string DashboardBaseHref
|
||||
{
|
||||
get
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<header class="app-bar">
|
||||
<a class="brand" href=""><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<nav class="app-nav">
|
||||
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="sessions">Sessions</NavLink>
|
||||
<NavLink href="workers">Workers</NavLink>
|
||||
<NavLink href="events">Events</NavLink>
|
||||
<NavLink href="galaxy">Galaxy</NavLink>
|
||||
<NavLink href="browse">Browse</NavLink>
|
||||
<NavLink href="alarms">Alarms</NavLink>
|
||||
<NavLink href="apikeys">API Keys</NavLink>
|
||||
<NavLink href="settings">Settings</NavLink>
|
||||
<NavLink href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="/sessions">Sessions</NavLink>
|
||||
<NavLink href="/workers">Workers</NavLink>
|
||||
<NavLink href="/events">Events</NavLink>
|
||||
<NavLink href="/galaxy">Galaxy</NavLink>
|
||||
<NavLink href="/browse">Browse</NavLink>
|
||||
<NavLink href="/alarms">Alarms</NavLink>
|
||||
<NavLink href="/apikeys">API Keys</NavLink>
|
||||
<NavLink href="/settings">Settings</NavLink>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="app-user">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<form method="post" action="/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
@@ -35,16 +34,3 @@
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string DashboardPath(string relativePath)
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}{relativePath}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/alarms"
|
||||
@page "/dashboard/alarms"
|
||||
@implements IAsyncDisposable
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/apikeys"
|
||||
@page "/dashboard/apikeys"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardApiKeyManagementService ApiKeyManagementService
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/browse"
|
||||
@page "/dashboard/browse"
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/"
|
||||
@page "/dashboard/"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/events"
|
||||
@page "/dashboard/events"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Events</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/galaxy"
|
||||
@page "/dashboard/galaxy"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Galaxy</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/sessions/{SessionId}"
|
||||
@page "/dashboard/sessions/{SessionId}"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Session</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/sessions"
|
||||
@page "/dashboard/sessions"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Sessions</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/settings"
|
||||
@page "/dashboard/settings"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Settings</PageTitle>
|
||||
@@ -33,7 +32,24 @@ else
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
|
||||
<tr>
|
||||
<th scope="row">Dashboard role mapping</th>
|
||||
<td>
|
||||
@if (Snapshot.Configuration.Dashboard.GroupToRole.Count == 0)
|
||||
{
|
||||
<span class="text-muted">(none configured)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="mb-0">
|
||||
@foreach (KeyValuePair<string, string> pair in Snapshot.Configuration.Dashboard.GroupToRole)
|
||||
{
|
||||
<li><code>@pair.Key</code> → @pair.Value</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
||||
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
||||
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
||||
@@ -44,8 +60,6 @@ else
|
||||
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
|
||||
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
|
||||
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
|
||||
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
|
||||
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
|
||||
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
|
||||
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
|
||||
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/workers"
|
||||
@page "/dashboard/workers"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Workers</PageTitle>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
|
||||
public sealed class DashboardApiKeyAuthorization
|
||||
{
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
@@ -13,10 +11,6 @@ public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> option
|
||||
return false;
|
||||
}
|
||||
|
||||
string requiredGroup = options.Value.Ldap.RequiredGroup;
|
||||
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||
.Select(claim => claim.Value);
|
||||
|
||||
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
return user.IsInRole(DashboardRoles.Admin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,37 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public static class DashboardAuthenticationDefaults
|
||||
{
|
||||
/// <summary>Cookie scheme used to authenticate interactive dashboard requests.</summary>
|
||||
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||
public const string ScopeClaimType = "scope";
|
||||
|
||||
/// <summary>
|
||||
/// Bearer scheme used to authenticate SignalR hub connections. Hubs cannot
|
||||
/// reuse the browser's HttpOnly cookie when the client SignalR JS resolves
|
||||
/// the cookie scope to loopback, so the dashboard mints a short-lived
|
||||
/// data-protected bearer token on demand.
|
||||
/// </summary>
|
||||
public const string HubAuthenticationScheme = "MxGateway.Dashboard.HubToken";
|
||||
|
||||
/// <summary>
|
||||
/// Policy guarding read-only dashboard pages. Allowed roles:
|
||||
/// <see cref="DashboardRoles.Admin"/> or <see cref="DashboardRoles.Viewer"/>.
|
||||
/// </summary>
|
||||
public const string ViewerPolicy = "MxGateway.Dashboard.Viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Policy guarding write-capable dashboard pages and admin endpoints
|
||||
/// (API-key CRUD, settings edits, hub publishes). Allowed role:
|
||||
/// <see cref="DashboardRoles.Admin"/>.
|
||||
/// </summary>
|
||||
public const string AdminPolicy = "MxGateway.Dashboard.Admin";
|
||||
|
||||
/// <summary>
|
||||
/// Policy attached to SignalR hubs — accepts either the cookie scheme
|
||||
/// (in-page connect) or the hub-token bearer scheme (cross-origin or
|
||||
/// WebSocket connect where the cookie can't be forwarded).
|
||||
/// </summary>
|
||||
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
|
||||
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class DashboardAuthenticator(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LdapOptions ldapOptions = options.Value.Ldap;
|
||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
||||
if (!ldapOptions.Enabled
|
||||
|| string.IsNullOrWhiteSpace(username)
|
||||
|| string.IsNullOrWhiteSpace(password))
|
||||
@@ -79,12 +80,12 @@ public sealed class DashboardAuthenticator(
|
||||
?? normalizedUsername;
|
||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
||||
|
||||
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
|
||||
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
|
||||
normalizedUsername,
|
||||
ldapOptions.RequiredGroup);
|
||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||
normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
@@ -92,7 +93,8 @@ public sealed class DashboardAuthenticator(
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
groups));
|
||||
groups,
|
||||
roles));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -134,28 +136,32 @@ public sealed class DashboardAuthenticator(
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
|
||||
/// <summary>
|
||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||
/// an empty list when no group matches (caller rejects the login).
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||
IEnumerable<string> groups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
string normalizedRequiredGroup = requiredGroup.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
|
||||
if (groupToRole.Count == 0)
|
||||
{
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
ExtractFirstRdnValue(normalizedGroup),
|
||||
normalizedRequiredGroup,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||
{
|
||||
return true;
|
||||
roles.Add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
@@ -237,20 +243,16 @@ public sealed class DashboardAuthenticator(
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string username,
|
||||
string displayName,
|
||||
IEnumerable<string> groups)
|
||||
IEnumerable<string> groups,
|
||||
IEnumerable<string> roles)
|
||||
{
|
||||
// CreatePrincipal is reached only after IsMemberOfRequiredGroup passed,
|
||||
// so the authenticated user is authorized for the dashboard. Emit the
|
||||
// admin scope claim that DashboardAuthorizationHandler checks when
|
||||
// Dashboard:RequireAdminScope is enabled — without it, every LDAP login
|
||||
// would be denied once route-level authorization is enforced.
|
||||
List<Claim> claims =
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, username),
|
||||
new Claim(ClaimTypes.Name, displayName),
|
||||
new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin)
|
||||
];
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
claims.AddRange(groups.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
@@ -259,7 +261,7 @@ public sealed class DashboardAuthenticator(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType);
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(claimsIdentity);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ using System.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes a dashboard request by checking either: (a) the LDAP-issued
|
||||
/// role claim satisfies <see cref="DashboardAuthorizationRequirement.RequiredRoles"/>,
|
||||
/// (b) authentication is fully disabled, or (c) the request is from loopback
|
||||
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
|
||||
/// </summary>
|
||||
public sealed class DashboardAuthorizationHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||
@@ -36,9 +41,13 @@ public sealed class DashboardAuthorizationHandler(
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
|
||||
foreach (string role in requirement.RequiredRoles)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
if (context.User.IsInRole(role))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -50,11 +59,4 @@ public sealed class DashboardAuthorizationHandler(
|
||||
|
||||
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
|
||||
}
|
||||
|
||||
private static bool HasAdminScope(AuthorizationHandlerContext context)
|
||||
{
|
||||
return context.User.HasClaim(
|
||||
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||
GatewayScopes.Admin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,17 @@ using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||
/// <summary>
|
||||
/// Requirement satisfied when the user has any one of the listed roles, or
|
||||
/// when an environment-level bypass (auth disabled / loopback) is in effect.
|
||||
/// </summary>
|
||||
public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement
|
||||
{
|
||||
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
|
||||
|
||||
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
|
||||
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
|
||||
|
||||
public static DashboardAuthorizationRequirement AdminOnly { get; } =
|
||||
new([DashboardRoles.Admin]);
|
||||
}
|
||||
|
||||
+22
-43
@@ -24,73 +24,64 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
||||
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
|
||||
|
||||
dashboard.MapGet(
|
||||
endpoints.MapGet(
|
||||
"/login",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogin");
|
||||
|
||||
dashboard.MapPost(
|
||||
endpoints.MapPost(
|
||||
"/login",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
|
||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLoginPost");
|
||||
|
||||
dashboard.MapPost(
|
||||
endpoints.MapPost(
|
||||
"/logout",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogout");
|
||||
|
||||
dashboard.MapGet("/denied", () => Results.Content(
|
||||
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");
|
||||
|
||||
// Every dashboard Razor component requires an authorized session. The
|
||||
// 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 the login page.
|
||||
dashboard.MapRazorComponents<App>()
|
||||
// cookie scheme and redirected to /login.
|
||||
endpoints.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static Task<ContentHttpResult> GetLoginAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string pathBase)
|
||||
IAntiforgery antiforgery)
|
||||
{
|
||||
string returnUrl = SanitizeReturnUrl(
|
||||
httpContext.Request.Query["returnUrl"].ToString(),
|
||||
pathBase);
|
||||
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
|
||||
|
||||
return Task.FromResult(TypedResults.Content(
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
|
||||
"text/html"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> PostLoginAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
IDashboardAuthenticator authenticator,
|
||||
string pathBase)
|
||||
IDashboardAuthenticator authenticator)
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||
|
||||
IFormCollection form = await httpContext.Request
|
||||
.ReadFormAsync(httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
string returnUrl = SanitizeReturnUrl(
|
||||
form["returnUrl"].ToString(),
|
||||
pathBase);
|
||||
string returnUrl = SanitizeReturnUrl(form["returnUrl"].ToString());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator
|
||||
.AuthenticateAsync(
|
||||
@@ -102,7 +93,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
if (!result.Succeeded || result.Principal is null)
|
||||
{
|
||||
return TypedResults.Content(
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
|
||||
"text/html",
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
@@ -116,22 +107,20 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
|
||||
private static async Task<IResult> PostLogoutAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string pathBase)
|
||||
IAntiforgery antiforgery)
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||
await httpContext
|
||||
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.LocalRedirect($"{pathBase}/login");
|
||||
return Results.LocalRedirect("/login");
|
||||
}
|
||||
|
||||
private static string RenderLoginPage(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string returnUrl,
|
||||
string pathBase,
|
||||
string? failureMessage)
|
||||
{
|
||||
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||
@@ -143,7 +132,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
string body = $"""
|
||||
<section class="dashboard-login">
|
||||
{alert}
|
||||
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
|
||||
<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)}" />
|
||||
@@ -193,24 +182,14 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
""";
|
||||
}
|
||||
|
||||
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)
|
||||
private static string SanitizeReturnUrl(string? returnUrl)
|
||||
{
|
||||
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 "/";
|
||||
}
|
||||
|
||||
return returnUrl;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Role names assigned to authenticated dashboard users. The role is derived
|
||||
/// from the user's LDAP groups via <c>MxGateway:Dashboard:GroupToRole</c>.
|
||||
/// </summary>
|
||||
public static class DashboardRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-write access: API-key CRUD, settings, any state-changing UI.
|
||||
/// </summary>
|
||||
public const string Admin = "Admin";
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access: all pages render but write affordances are hidden.
|
||||
/// </summary>
|
||||
public const string Viewer = "Viewer";
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
@@ -21,46 +20,51 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddSingleton<HubTokenService>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services.AddCascadingAuthenticationState();
|
||||
services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
services.AddSignalR();
|
||||
|
||||
services
|
||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||
services.AddAuthorization(options =>
|
||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
||||
{
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||
cookieOptions.Cookie.HttpOnly = true;
|
||||
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||
cookieOptions.Cookie.Path = "/";
|
||||
cookieOptions.LoginPath = "/login";
|
||||
cookieOptions.LogoutPath = "/logout";
|
||||
cookieOptions.AccessDeniedPath = "/denied";
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||
_ => { });
|
||||
|
||||
services.AddAuthorization(authorization =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||
authorization.AddPolicy(
|
||||
DashboardAuthenticationDefaults.ViewerPolicy,
|
||||
policy => policy.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
||||
authorization.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AdminPolicy,
|
||||
policy => policy.AddRequirements(DashboardAuthorizationRequirement.AdminOnly));
|
||||
authorization.AddPolicy(
|
||||
DashboardAuthenticationDefaults.HubClientsPolicy,
|
||||
policy => policy
|
||||
.AddAuthenticationSchemes(
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureCookieOptions(
|
||||
CookieAuthenticationOptions cookieOptions,
|
||||
IOptions<GatewayOptions> gatewayOptions)
|
||||
{
|
||||
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||
cookieOptions.Cookie.HttpOnly = true;
|
||||
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||
cookieOptions.Cookie.Path = "/";
|
||||
cookieOptions.LoginPath = $"{pathBase}/login";
|
||||
cookieOptions.LogoutPath = $"{pathBase}/logout";
|
||||
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates SignalR hub connections by extracting a bearer token from
|
||||
/// either the <c>Authorization</c> header or the <c>access_token</c> query
|
||||
/// string (used by WebSocket upgrade requests that can't carry custom headers)
|
||||
/// and validating it via <see cref="HubTokenService"/>.
|
||||
/// </summary>
|
||||
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly HubTokenService _tokens;
|
||||
|
||||
public HubTokenAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
HubTokenService tokens)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
string? token = ExtractToken();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
System.Security.Claims.ClaimsPrincipal? principal = _tokens.Validate(token);
|
||||
if (principal is null)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid or expired hub token."));
|
||||
}
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(
|
||||
new AuthenticationTicket(principal, Scheme.Name)));
|
||||
}
|
||||
|
||||
private string? ExtractToken()
|
||||
{
|
||||
string header = Request.Headers.Authorization.ToString();
|
||||
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return header["Bearer ".Length..].Trim();
|
||||
}
|
||||
|
||||
return Request.Query.TryGetValue("access_token", out Microsoft.Extensions.Primitives.StringValues queryToken)
|
||||
? queryToken.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Mints and validates short-lived bearer tokens for SignalR hub connections.
|
||||
/// The token is a data-protected JSON payload containing the user's name and
|
||||
/// role claims. Validity is enforced by the data-protection time-limited
|
||||
/// protector; no separate signing keys are configured.
|
||||
/// </summary>
|
||||
public sealed class HubTokenService
|
||||
{
|
||||
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
|
||||
public HubTokenService(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataProtection);
|
||||
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
|
||||
}
|
||||
|
||||
/// <summary>Issue a bearer token carrying the user's identity and roles.</summary>
|
||||
public string Issue(ClaimsPrincipal user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
HubTokenPayload payload = new(
|
||||
user.Identity?.Name,
|
||||
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
||||
}
|
||||
|
||||
/// <summary>Validate a token and return the equivalent <see cref="ClaimsPrincipal"/>; null when invalid or expired.</summary>
|
||||
public ClaimsPrincipal? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HubTokenPayload? payload = JsonSerializer.Deserialize<HubTokenPayload>(_protector.Unprotect(token));
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Claim> claims = [];
|
||||
if (!string.IsNullOrEmpty(payload.Name))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Name, payload.Name));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(payload.NameIdentifier))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.NameIdentifier, payload.NameIdentifier));
|
||||
}
|
||||
|
||||
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"UserNameAttribute": "cn",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"RequiredGroup": "GwAdmin"
|
||||
"GroupAttribute": "memberOf"
|
||||
},
|
||||
"Worker": {
|
||||
"ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe",
|
||||
@@ -50,13 +49,15 @@
|
||||
},
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"PathBase": "/dashboard",
|
||||
"RequireAdminScope": true,
|
||||
"AllowAnonymousLocalhost": true,
|
||||
"SnapshotIntervalMilliseconds": 1000,
|
||||
"RecentFaultLimit": 100,
|
||||
"RecentSessionLimit": 200,
|
||||
"ShowTagValues": false
|
||||
"ShowTagValues": false,
|
||||
"GroupToRole": {
|
||||
"GwAdmin": "Admin",
|
||||
"GwReader": "Viewer"
|
||||
}
|
||||
},
|
||||
"Protocol": {
|
||||
"WorkerProtocolVersion": 1,
|
||||
|
||||
@@ -39,8 +39,6 @@ public sealed class GatewayOptionsTests
|
||||
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
|
||||
|
||||
Assert.True(options.Dashboard.Enabled);
|
||||
Assert.Equal("/dashboard", options.Dashboard.PathBase);
|
||||
Assert.True(options.Dashboard.RequireAdminScope);
|
||||
Assert.True(options.Dashboard.AllowAnonymousLocalhost);
|
||||
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
|
||||
Assert.Equal(100, options.Dashboard.RecentFaultLimit);
|
||||
@@ -89,7 +87,7 @@ public sealed class GatewayOptionsTests
|
||||
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
|
||||
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
|
||||
{
|
||||
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
|
||||
|
||||
+12
-32
@@ -1,6 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
@@ -8,19 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardApiKeyAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue()
|
||||
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("GwAdmin");
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local");
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Admin);
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
@@ -28,37 +17,28 @@ public sealed class DashboardApiKeyAuthorizationTests
|
||||
[Fact]
|
||||
public void CanManage_AnonymousUser_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = new(new ClaimsIdentity());
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse()
|
||||
public void CanManage_AuthenticatedViewer_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ReadOnly");
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Viewer);
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
private static DashboardApiKeyAuthorization CreateAuthorization()
|
||||
{
|
||||
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string group)
|
||||
private static ClaimsPrincipal CreatePrincipal(string role)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
+5
-11
@@ -144,19 +144,11 @@ public sealed class DashboardApiKeyManagementServiceTests
|
||||
FakeApiKeyAuditStore? auditStore = null,
|
||||
FakeApiKeySecretHasher? hasher = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
};
|
||||
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||
|
||||
return new DashboardApiKeyManagementService(
|
||||
new DashboardApiKeyAuthorization(Options.Create(options)),
|
||||
new DashboardApiKeyAuthorization(),
|
||||
adminStore ?? new FakeApiKeyAdminStore(),
|
||||
auditStore ?? new FakeApiKeyAuditStore(),
|
||||
hasher ?? new FakeApiKeySecretHasher(),
|
||||
@@ -178,8 +170,10 @@ public sealed class DashboardApiKeyManagementServiceTests
|
||||
private static ClaimsPrincipal CreateAuthorizedUser()
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
@@ -16,23 +16,47 @@ public sealed class DashboardAuthenticatorTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", true)]
|
||||
[InlineData("gwadmin", true)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
|
||||
[InlineData("OtherGroup", false)]
|
||||
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
|
||||
string requiredGroup,
|
||||
bool expected)
|
||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
||||
[InlineData("OtherGroup", null)]
|
||||
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
|
||||
string ldapGroup,
|
||||
string? expectedRole)
|
||||
{
|
||||
string[] groups =
|
||||
[
|
||||
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
|
||||
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
|
||||
];
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
if (expectedRole is null)
|
||||
{
|
||||
Assert.Empty(roles);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expectedRole, Assert.Single(roles));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
|
||||
{
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
|
||||
["GwAdmin", "GwReader"],
|
||||
mapping);
|
||||
|
||||
Assert.Contains(DashboardRoles.Admin, roles);
|
||||
Assert.Contains(DashboardRoles.Viewer, roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+51
-19
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
@@ -18,7 +17,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: true);
|
||||
allowAnonymousLocalhost: true,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
@@ -45,7 +46,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
@@ -60,41 +62,70 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: true);
|
||||
allowAnonymousLocalhost: true,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
|
||||
/// <summary>Verifies that an authenticated user without any dashboard role fails the viewer requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.EventsRead),
|
||||
CreatePrincipal("SomeOtherRole"),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
|
||||
/// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
|
||||
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.Admin),
|
||||
CreatePrincipal(DashboardRoles.Viewer),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(DashboardRoles.Admin),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AdminOnly);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(DashboardRoles.Viewer),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AdminOnly);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||
ClaimsPrincipal principal,
|
||||
IPAddress remoteAddress,
|
||||
bool allowAnonymousLocalhost)
|
||||
bool allowAnonymousLocalhost,
|
||||
DashboardAuthorizationRequirement requirement)
|
||||
{
|
||||
DashboardAuthorizationRequirement requirement = new();
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||
DashboardAuthorizationHandler handler = new(
|
||||
@@ -104,7 +135,6 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||
RequireAdminScope = true
|
||||
}
|
||||
}));
|
||||
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||
@@ -114,11 +144,13 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||
private static ClaimsPrincipal CreatePrincipal(string role)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public sealed class DashboardCookieOptionsTests
|
||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||
Assert.Equal("/", options.Cookie.Path);
|
||||
Assert.Equal("/dashboard/login", options.LoginPath);
|
||||
Assert.Equal("/dashboard/logout", options.LogoutPath);
|
||||
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
|
||||
Assert.Equal("/login", options.LoginPath);
|
||||
Assert.Equal("/logout", options.LogoutPath);
|
||||
Assert.Equal("/denied", options.AccessDeniedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ public sealed class GatewayApplicationTests
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||
Assert.Contains(endpoints, endpoint =>
|
||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
|
||||
Assert.Contains(endpoints, endpoint =>
|
||||
@@ -74,19 +74,22 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] componentRoutes =
|
||||
["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"];
|
||||
// Razor-component endpoints are distinguished from minimal-API
|
||||
// endpoints registered at the same path by the presence of
|
||||
// ComponentTypeMetadata. Filter to those before checking auth.
|
||||
string[] componentRoutes = ["/", "/sessions", "/workers", "/events", "/settings"];
|
||||
foreach (string route in componentRoutes)
|
||||
{
|
||||
RouteEndpoint[] matches = endpoints
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText == route)
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText == route
|
||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(matches);
|
||||
@@ -94,51 +97,32 @@ public sealed class GatewayApplicationTests
|
||||
{
|
||||
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-020 reversal regression guard. The original Server-020 finding
|
||||
/// incorrectly concluded that the duplicate <c>@page "/dashboard/X"</c>
|
||||
/// directives were redundant because <c>MapGroup("/dashboard")</c>
|
||||
/// would prepend the prefix to all dashboard Razor pages. In practice
|
||||
/// Blazor SSR's <c>RouteTableFactory</c> matches against the raw
|
||||
/// <c>@page</c> template values (not against the endpoint-route
|
||||
/// prefix), so removing <c>@page "/dashboard/X"</c> left the dashboard
|
||||
/// unreachable at runtime (every page returned HTTP 500 with "Unable
|
||||
/// to find the provided template '/dashboard/'"). The duplicate
|
||||
/// <c>@page</c> directives are restored, and as a side effect the
|
||||
/// endpoint route table DOES carry the doubled <c>/dashboard/dashboard/X</c>
|
||||
/// shape (because <c>MapGroup("/dashboard")</c> prefixes the already-prefixed
|
||||
/// <c>@page "/dashboard/X"</c>). Those doubled endpoints are harmless —
|
||||
/// no client requests <c>/dashboard/dashboard/X</c> — and removing them
|
||||
/// requires either dropping <c>MapGroup</c> or the <c>@page</c>
|
||||
/// prefix. This test asserts only the positive contract: every
|
||||
/// dashboard page IS reachable under the canonical <c>/dashboard/X</c>
|
||||
/// route, which is what the Blazor router actually serves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_RegistersCanonicalDashboardRoutes()
|
||||
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] canonicalRoutes =
|
||||
[
|
||||
"/dashboard/",
|
||||
"/dashboard/sessions",
|
||||
"/dashboard/workers",
|
||||
"/dashboard/events",
|
||||
"/dashboard/settings",
|
||||
"/dashboard/galaxy",
|
||||
"/dashboard/apikeys",
|
||||
"/dashboard/sessions/{SessionId}",
|
||||
"/",
|
||||
"/sessions",
|
||||
"/workers",
|
||||
"/events",
|
||||
"/settings",
|
||||
"/galaxy",
|
||||
"/apikeys",
|
||||
"/sessions/{SessionId}",
|
||||
];
|
||||
foreach (string canonical in canonicalRoutes)
|
||||
{
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical);
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical
|
||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +132,6 @@ public sealed class GatewayApplicationTests
|
||||
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.DoesNotContain(endpoints, endpoint =>
|
||||
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(endpoints, endpoint =>
|
||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
|
||||
"Dashboard",
|
||||
@@ -174,13 +156,9 @@ public sealed class GatewayApplicationTests
|
||||
"",
|
||||
"MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData(
|
||||
"MxGateway:Dashboard:PathBase",
|
||||
"dashboard",
|
||||
"MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:RequiredGroup",
|
||||
"",
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
|
||||
"MxGateway:Dashboard:GroupToRole:GwAdmin",
|
||||
"BogusRole",
|
||||
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:AllowInsecureLdap",
|
||||
"false",
|
||||
|
||||
Reference in New Issue
Block a user