fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
@@ -90,12 +89,44 @@ public static class DebugCommands
|
||||
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
|
||||
{
|
||||
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
|
||||
// Exchange the credentials for a bearer token once (review 2026-10-21 05/U1, 05/P1) so
|
||||
// the hub handshake — and every automatic reconnect — costs no LDAP bind. The hub
|
||||
// authenticates through ManagementAuthenticator, which accepts the token in the
|
||||
// Authorization header; the SignalR .NET client sends the AccessTokenProvider value
|
||||
// there on negotiate and on the WebSocket upgrade, and re-invokes the provider on each
|
||||
// reconnect, so a token that expired mid-stream is re-exchanged. A server without
|
||||
// bearer support (or SCADABRIDGE_AUTH_MODE=basic) keeps the Basic header.
|
||||
using var authClient = new ManagementHttpClient(baseUrl, username, password);
|
||||
ManagementHttpClient.BearerResult bearer;
|
||||
try
|
||||
{
|
||||
bearer = await authClient.TryGetBearerTokenAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bearer.Failure is not null)
|
||||
{
|
||||
OutputFormatter.WriteError(bearer.Failure.Error ?? "Authentication failed.", bearer.Failure.ErrorCode ?? "AUTH_FAILED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.Headers.Add("Authorization", $"Basic {credentials}");
|
||||
if (bearer.Token is not null)
|
||||
{
|
||||
options.AccessTokenProvider = async () =>
|
||||
(await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token;
|
||||
}
|
||||
else
|
||||
{
|
||||
options.Headers.Add("Authorization", $"Basic {authClient.BasicCredentials}");
|
||||
}
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
Reference in New Issue
Block a user