fix(admin): authenticate SignalR hub clients with a bearer-token scheme
The Admin-003 fix gated every SignalR hub with [Authorize], but the server-side
Blazor HubConnection clients had no way to authenticate: the browser's HttpOnly
auth cookie is not reachable from the interactive circuit, so every hub negotiate
returned 401 and the Admin live-update feature was non-functional app-wide
(silently degraded on Hosts/ScriptLog, fatal on the cluster pages).
Introduce a token-based hub auth path:
- HubTokenService mints/validates short-lived tokens using ASP.NET Core Data
Protection (the same primitive that protects the auth cookie — no signing-key
management, no new packages). Tokens carry the user's name + roles.
- HubTokenAuthenticationHandler is a custom "HubToken" auth scheme that reads the
token from the Authorization: Bearer header (negotiate) or the access_token
query parameter (WebSocket upgrade).
- The "HubClients" authorization policy runs both the cookie and HubToken
schemes; the hub endpoints use RequireAuthorization("HubClients").
- AdminHubConnectionFactory builds hub connections with an AccessTokenProvider
that mints a fresh token for the circuit's authenticated user on every
(re)connect. All six hub-consuming pages now resolve connections through it.
Hub negotiate now returns 200 and the WebSocket upgrades (101); live updates
work. The best-effort try/catch guards added previously are kept as defence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
@inject NodeAclService AclSvc
|
||||
@inject PermissionProbeService ProbeSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
@@ -222,10 +223,7 @@ else
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
_hub.On<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
@@ -179,10 +180,7 @@ else
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
|
||||
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterNodeService NodeSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4 class="panel-head">Redundancy topology</h4>
|
||||
@@ -130,10 +131,7 @@ else
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
|
||||
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="page-title">Driver host status</h1>
|
||||
@@ -155,8 +156,7 @@ else
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet");
|
||||
_hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build();
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject AclChangeNotifier Notifier
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="page-title">LDAP group → Admin role grants</h1>
|
||||
@@ -171,10 +172,7 @@ else
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
_hub.On<RoleGrantsChangedMessage>("RoleGrantsChanged", async _ =>
|
||||
{
|
||||
await ReloadAsync();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="page-title">Script log viewer</h1>
|
||||
@@ -120,10 +121,7 @@ else
|
||||
|
||||
try
|
||||
{
|
||||
_hub ??= new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/script-log"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub ??= HubFactory.Create("/hubs/script-log");
|
||||
|
||||
if (_hub.State == HubConnectionState.Disconnected)
|
||||
await _hub.StartAsync();
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@@ -25,7 +25,15 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
o.Cookie.Name = "OtOpcUa.Admin";
|
||||
o.LoginPath = "/login";
|
||||
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
});
|
||||
})
|
||||
// Bearer-token scheme for the SignalR hub clients. A server-side Blazor circuit cannot
|
||||
// forward the browser's HttpOnly auth cookie to the loopback hub connection, so pages
|
||||
// authenticate to the hubs with a HubTokenService token instead (see HubTokenService).
|
||||
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||
HubTokenDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
builder.Services.AddSingleton<HubTokenService>();
|
||||
builder.Services.AddScoped<AdminHubConnectionFactory>();
|
||||
|
||||
// Secure-by-default: the fallback policy requires an authenticated user for any
|
||||
// endpoint (and any routable page) that carries no explicit authorization metadata,
|
||||
@@ -35,6 +43,13 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin))
|
||||
// SignalR hubs accept either the browser auth cookie or a HubToken bearer token, so the
|
||||
// hub endpoint authorization must run both schemes (the default fallback policy is
|
||||
// cookie-only and would 401 the token-authenticated hub clients).
|
||||
.AddPolicy("HubClients", p => p
|
||||
.AddAuthenticationSchemes(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme, HubTokenDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser())
|
||||
.SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build());
|
||||
@@ -152,11 +167,13 @@ app.UseAntiforgery();
|
||||
app.MapAuthEndpoints();
|
||||
|
||||
// Admin-003: every SignalR hub requires an authenticated caller. The [Authorize] attribute
|
||||
// on each Hub class is the primary gate; .RequireAuthorization() on the endpoint is the
|
||||
// belt-and-braces backstop so a hub added without the attribute still cannot ship anonymous.
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet").RequireAuthorization();
|
||||
app.MapHub<AlertHub>("/hubs/alerts").RequireAuthorization();
|
||||
app.MapHub<ScriptLogHub>("/hubs/script-log").RequireAuthorization();
|
||||
// on each Hub class is the primary gate; .RequireAuthorization("HubClients") on the endpoint
|
||||
// is the belt-and-braces backstop AND the scheme gate — the "HubClients" policy runs both the
|
||||
// cookie and the HubToken scheme so server-side Blazor hub clients (which cannot present the
|
||||
// browser cookie) authenticate with a bearer token instead.
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet").RequireAuthorization("HubClients");
|
||||
app.MapHub<AlertHub>("/hubs/alerts").RequireAuthorization("HubClients");
|
||||
app.MapHub<ScriptLogHub>("/hubs/script-log").RequireAuthorization("HubClients");
|
||||
|
||||
if (metricsEnabled)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>Scheme name for the SignalR hub bearer-token authentication.</summary>
|
||||
public static class HubTokenDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "HubToken";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates SignalR hub requests that carry a <see cref="HubTokenService"/>-minted
|
||||
/// token. SignalR supplies the token as an <c>Authorization: Bearer</c> header on the
|
||||
/// negotiate / long-poll requests and as an <c>access_token</c> query-string parameter on
|
||||
/// the WebSocket upgrade (custom headers cannot be set on a browser WebSocket handshake) —
|
||||
/// this handler accepts either.
|
||||
/// </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()
|
||||
{
|
||||
var token = ExtractToken();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var 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()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..].Trim();
|
||||
|
||||
return Request.Query.TryGetValue("access_token", out var queryToken)
|
||||
? queryToken.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Mints and validates short-lived bearer tokens that let the Admin UI's server-side
|
||||
/// Blazor <see cref="Microsoft.AspNetCore.SignalR.Client.HubConnection"/> clients
|
||||
/// authenticate to the <c>[Authorize]</c>-gated SignalR hubs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Admin-003 fix gated every hub with authorization, but a server-side Blazor circuit
|
||||
/// cannot forward the browser's HttpOnly auth cookie to the loopback hub connection — so the
|
||||
/// hub negotiate would 401. Instead, a component mints a token here for its already-
|
||||
/// authenticated user and supplies it through <c>HttpConnectionOptions.AccessTokenProvider</c>;
|
||||
/// <see cref="HubTokenAuthenticationHandler"/> validates it on the hub endpoint.
|
||||
/// <para>
|
||||
/// The token is an ASP.NET Core Data Protection time-limited payload — the same
|
||||
/// primitive that already protects the auth cookie — so there is no signing-key
|
||||
/// management and no extra package. It is only ever presented loopback (the Admin
|
||||
/// server connecting to its own hub).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HubTokenService
|
||||
{
|
||||
private const string ProtectorPurpose = "ZB.MOM.WW.OtOpcUa.Admin.HubToken.v1";
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
|
||||
public HubTokenService(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
|
||||
}
|
||||
|
||||
/// <summary>Mints a token carrying the user's name and roles, valid for 30 minutes.</summary>
|
||||
public string Issue(ClaimsPrincipal user)
|
||||
{
|
||||
var payload = new HubTokenPayload(
|
||||
user.Identity?.Name,
|
||||
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray());
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token and rebuilds the <see cref="ClaimsPrincipal"/>. Returns
|
||||
/// <c>null</c> when the token is missing, tampered with, or expired.
|
||||
/// </summary>
|
||||
public ClaimsPrincipal? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<HubTokenPayload>(_protector.Unprotect(token));
|
||||
if (payload is null) return null;
|
||||
|
||||
var claims = new List<Claim>();
|
||||
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)));
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
claims, HubTokenDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role);
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Crypto failure (tampered / expired / wrong key) or malformed JSON — unauthenticated.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="HubConnection"/>s to the Admin UI's own SignalR hubs with bearer-token
|
||||
/// authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hubs are <c>[Authorize]</c>-gated (Admin-003). A server-side Blazor circuit cannot
|
||||
/// forward the browser's HttpOnly auth cookie to the loopback hub connection, so every page
|
||||
/// that needs live updates resolves this factory and lets it wire an
|
||||
/// <c>AccessTokenProvider</c> that mints a <see cref="HubTokenService"/> token for the
|
||||
/// circuit's authenticated user. The provider re-mints on every (re)connect so a long-lived
|
||||
/// page outlives the token lifetime.
|
||||
/// </remarks>
|
||||
public sealed class AdminHubConnectionFactory
|
||||
{
|
||||
private readonly NavigationManager _nav;
|
||||
private readonly HubTokenService _tokens;
|
||||
private readonly AuthenticationStateProvider _authState;
|
||||
|
||||
public AdminHubConnectionFactory(
|
||||
NavigationManager nav,
|
||||
HubTokenService tokens,
|
||||
AuthenticationStateProvider authState)
|
||||
{
|
||||
_nav = nav;
|
||||
_tokens = tokens;
|
||||
_authState = authState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an auto-reconnecting connection to <paramref name="hubPath"/>
|
||||
/// (e.g. <c>/hubs/fleet</c>), authenticated as the current circuit's user.
|
||||
/// </summary>
|
||||
public HubConnection Create(string hubPath)
|
||||
{
|
||||
var hubUrl = _nav.ToAbsoluteUri(hubPath);
|
||||
return new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.AccessTokenProvider = async () =>
|
||||
{
|
||||
var state = await _authState.GetAuthenticationStateAsync();
|
||||
return _tokens.Issue(state.User);
|
||||
};
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user