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:
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory, process-lifetime store of the bearer tokens the CLI obtains from
|
||||
/// <c>POST /auth/token</c> (review 2026-10-21 05/U1, 05/P1). One entry per
|
||||
/// <c>(management URL, username, password)</c> so every <see cref="ManagementHttpClient"/> a
|
||||
/// single CLI invocation creates shares one credential exchange — one LDAP bind per invocation
|
||||
/// instead of one per management call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tokens are never written to disk, logged or echoed; the store dies with the process. The key
|
||||
/// includes a SHA-256 digest of the password rather than the password itself, so a different
|
||||
/// password for the same user never reuses a token minted for the old one.
|
||||
/// </remarks>
|
||||
internal sealed class CliTokenStore
|
||||
{
|
||||
/// <summary>The process-wide store used by the public <see cref="ManagementHttpClient"/> constructor.</summary>
|
||||
public static CliTokenStore Shared { get; } = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns the (shared) session for a management URL and credential pair.</summary>
|
||||
/// <param name="baseUrl">The management base URL.</param>
|
||||
/// <param name="username">The CLI username.</param>
|
||||
/// <param name="password">The CLI password.</param>
|
||||
/// <returns>The session carrying the cached token, if any.</returns>
|
||||
public Session For(string baseUrl, string username, string password)
|
||||
{
|
||||
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
var key = $"{baseUrl.TrimEnd('/').ToLowerInvariant()}\n{username}\n{digest}";
|
||||
return _sessions.GetOrAdd(key, _ => new Session());
|
||||
}
|
||||
|
||||
/// <summary>Token state for one credential pair against one server.</summary>
|
||||
internal sealed class Session
|
||||
{
|
||||
/// <summary>Serialises token exchanges so concurrent calls share one bind.</summary>
|
||||
public SemaphoreSlim Gate { get; } = new(1, 1);
|
||||
|
||||
/// <summary>The current bearer token, or null when none is held.</summary>
|
||||
public string? Token { get; set; }
|
||||
|
||||
/// <summary>When <see cref="Token"/> should be treated as stale (expiry minus a safety margin).</summary>
|
||||
public DateTimeOffset RefreshAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the server has shown it does not take bearer tokens on the management
|
||||
/// surfaces (no <c>/auth/token</c>, an older server whose tokens nothing accepts, or a
|
||||
/// login-disabled deployment): every later call goes straight to HTTP Basic.
|
||||
/// </summary>
|
||||
public bool BasicOnly { get; set; }
|
||||
|
||||
/// <summary>True while a non-stale token is held.</summary>
|
||||
/// <param name="now">The current instant.</param>
|
||||
/// <returns>Whether <see cref="Token"/> can be sent.</returns>
|
||||
public bool HasUsableToken(DateTimeOffset now) => Token is not null && now < RefreshAfter;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the central management surfaces (<c>POST /management</c>, the audit REST
|
||||
/// endpoints). Authenticates with a bearer token exchanged once per process at
|
||||
/// <c>POST /auth/token</c>, falling back to HTTP Basic when the server does not offer bearer
|
||||
/// tokens (review 2026-10-21 05/U1, 05/P1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bearer flow.</b> The first call a process makes for a given URL + credential pair posts
|
||||
/// the credentials to <c>/auth/token</c> (one LDAP bind) and keeps the returned JWT in memory
|
||||
/// (<see cref="CliTokenStore"/>; never persisted or logged). Every later call — from this or any
|
||||
/// other client instance in the same process — sends <c>Authorization: Bearer</c>, which the
|
||||
/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call
|
||||
/// answered <c>401</c> re-exchanges once (unless the token was minted for that very call) and,
|
||||
/// if still refused, retries with Basic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Basic fallback.</b> A server whose <c>/auth/token</c> is absent (404/405), disabled
|
||||
/// (login-disabled deployment, 404) or does not advertise <c>management_bearer: true</c> (an
|
||||
/// older server, whose tokens no management surface accepts) is remembered as Basic-only for
|
||||
/// the rest of the process. A transient exchange failure (5xx) uses Basic for that call only.
|
||||
/// Bad credentials (<c>401</c>) and a throttle lockout (<c>429</c>) from the exchange are
|
||||
/// returned as-is — retrying them with Basic would only add a second failed bind against the
|
||||
/// login throttle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>SCADABRIDGE_AUTH_MODE=basic</c> disables the exchange (Basic on every call).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
/// <summary>Environment variable selecting the auth mode: <c>token</c> (default) or <c>basic</c>.</summary>
|
||||
public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE";
|
||||
|
||||
/// <summary>Re-exchange a token this long before its stated expiry.</summary>
|
||||
internal static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthenticationHeaderValue _basic;
|
||||
private readonly string _username;
|
||||
private readonly string _password;
|
||||
private readonly CliTokenStore.Session? _session;
|
||||
|
||||
/// <summary>
|
||||
/// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS
|
||||
@@ -81,10 +121,19 @@ public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
},
|
||||
baseUrl, username, password)
|
||||
baseUrl, username, password,
|
||||
IsBasicAuthModeForced() ? null : CliTokenStore.Shared)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="AuthModeEnvVar"/> is <c>basic</c>, which disables the token
|
||||
/// exchange for the process.
|
||||
/// </summary>
|
||||
/// <returns>Whether HTTP Basic is forced on every call.</returns>
|
||||
internal static bool IsBasicAuthModeForced() =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
|
||||
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
|
||||
@@ -94,16 +143,217 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <param name="baseUrl">The base URL for the management API.</param>
|
||||
/// <param name="username">The username for HTTP Basic authentication.</param>
|
||||
/// <param name="password">The password for HTTP Basic authentication.</param>
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
|
||||
/// <param name="tokenStore">
|
||||
/// Bearer-token store to exchange credentials through; <see langword="null"/> (the default)
|
||||
/// sends HTTP Basic on every call, which keeps stub-handler tests to a single request.
|
||||
/// </param>
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password, CliTokenStore? tokenStore = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
// Test seam: exposes the constructed HttpClient's effective Timeout
|
||||
// without requiring reflection.
|
||||
EffectiveTimeout = httpClient.Timeout;
|
||||
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
_username = username;
|
||||
_password = password;
|
||||
_basic = new AuthenticationHeaderValue(
|
||||
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
|
||||
_session = tokenStore?.For(baseUrl, username, password);
|
||||
}
|
||||
|
||||
/// <summary>The HTTP Basic credential value (base64 <c>user:password</c>), for the debug-stream hub fallback.</summary>
|
||||
internal string BasicCredentials => _basic.Parameter!;
|
||||
|
||||
/// <summary>Outcome of <see cref="TryGetBearerTokenAsync"/>.</summary>
|
||||
/// <param name="Token">The bearer token, or <see langword="null"/> when the caller should use Basic.</param>
|
||||
/// <param name="Failure">A credential/throttle failure from the exchange that must be reported, not retried with Basic.</param>
|
||||
internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a usable bearer token for this client's credentials, exchanging at
|
||||
/// <c>/auth/token</c> if none is cached — used by the debug-stream hub, which cannot replay a
|
||||
/// rejected handshake. <see cref="BearerResult.Token"/> is null when the caller should fall
|
||||
/// back to Basic; <see cref="BearerResult.Failure"/> is set for bad credentials or a lockout.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancels the exchange.</param>
|
||||
/// <returns>The token, a fall-back-to-Basic signal, or a failure to report.</returns>
|
||||
internal async Task<BearerResult> TryGetBearerTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var auth = await AcquireAuthAsync(forceExchange: false, cancellationToken).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
var failure = await ToManagementResponseAsync(auth.Failure, cancellationToken).ConfigureAwait(false);
|
||||
auth.Failure.Dispose();
|
||||
return new BearerResult(null, failure);
|
||||
}
|
||||
|
||||
return new BearerResult(auth.Header.Scheme == "Bearer" ? auth.Header.Parameter : null, null);
|
||||
}
|
||||
|
||||
private readonly record struct AuthChoice(AuthenticationHeaderValue Header, bool FreshlyMinted, HttpResponseMessage? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Picks the Authorization header for the next request: the cached bearer token, a freshly
|
||||
/// exchanged one, or Basic. A non-null <see cref="AuthChoice.Failure"/> is the exchange's own
|
||||
/// 401/429 response, to be surfaced to the caller as-is.
|
||||
/// </summary>
|
||||
private async Task<AuthChoice> AcquireAuthAsync(bool forceExchange, CancellationToken ct)
|
||||
{
|
||||
var session = _session;
|
||||
if (session is null || session.BasicOnly)
|
||||
return new AuthChoice(_basic, false, null);
|
||||
|
||||
try
|
||||
{
|
||||
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
// Callers map TaskCanceledException to their TIMEOUT result, as for the send itself.
|
||||
throw new TaskCanceledException(ex.Message, ex, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (session.BasicOnly)
|
||||
return new AuthChoice(_basic, false, null);
|
||||
if (!forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow))
|
||||
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), false, null);
|
||||
|
||||
session.Token = null;
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("username", _username),
|
||||
new KeyValuePair<string, string>("password", _password),
|
||||
]);
|
||||
var response = await _httpClient.PostAsync("auth/token", form, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests)
|
||||
return new AuthChoice(_basic, false, response);
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
session.BasicOnly = true;
|
||||
return new AuthChoice(_basic, false, null);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new AuthChoice(_basic, false, null); // transient: Basic for this call only
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!TryParseToken(body, out var token, out var lifetime))
|
||||
{
|
||||
// An older server: /auth/token exists but no management surface takes its tokens.
|
||||
session.BasicOnly = true;
|
||||
return new AuthChoice(_basic, false, null);
|
||||
}
|
||||
|
||||
session.Token = token;
|
||||
var margin = lifetime > TokenRefreshMargin * 2 ? TokenRefreshMargin : TimeSpan.Zero;
|
||||
session.RefreshAfter = DateTimeOffset.UtcNow + lifetime - margin;
|
||||
return new AuthChoice(new AuthenticationHeaderValue("Bearer", token), true, null);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseToken(string body, out string token, out TimeSpan lifetime)
|
||||
{
|
||||
token = string.Empty;
|
||||
lifetime = TimeSpan.FromMinutes(15);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("management_bearer", out var bearer) || bearer.ValueKind != JsonValueKind.True
|
||||
|| !root.TryGetProperty("access_token", out var accessToken) || accessToken.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(accessToken.GetString()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = accessToken.GetString()!;
|
||||
if (root.TryGetProperty("expires_in", out var expiresIn) && expiresIn.TryGetInt32(out var seconds) && seconds > 0)
|
||||
lifetime = TimeSpan.FromSeconds(seconds);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request built by <paramref name="buildRequest"/> with the chosen credentials,
|
||||
/// applying the bearer refresh-once-then-Basic policy on a 401.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendAuthenticatedAsync(
|
||||
Func<HttpRequestMessage> buildRequest, HttpCompletionOption completion, CancellationToken ct)
|
||||
{
|
||||
var auth = await AcquireAuthAsync(forceExchange: false, ct).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
return auth.Failure;
|
||||
|
||||
var response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
||||
return response;
|
||||
|
||||
// The server refused our bearer token. A token cached from an earlier call may simply be
|
||||
// stale (expired, signing key rotated): re-exchange once and retry.
|
||||
if (!auth.FreshlyMinted)
|
||||
{
|
||||
response.Dispose();
|
||||
auth = await AcquireAuthAsync(forceExchange: true, ct).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
return auth.Failure;
|
||||
response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
||||
return response;
|
||||
}
|
||||
|
||||
// A token minted moments ago is still refused (e.g. the node that answered does not share
|
||||
// the signing key): fall back to Basic and stop exchanging for this process.
|
||||
response.Dispose();
|
||||
response = await SendWithAsync(buildRequest, _basic, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized && _session is not null)
|
||||
_session.BasicOnly = true;
|
||||
return response;
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendWithAsync(
|
||||
Func<HttpRequestMessage> buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct)
|
||||
{
|
||||
var request = buildRequest();
|
||||
request.Headers.Authorization = header;
|
||||
return _httpClient.SendAsync(request, completion, ct);
|
||||
}
|
||||
|
||||
private static async Task<ManagementResponse> ToManagementResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return new ManagementResponse((int)response.StatusCode, responseBody, null, null);
|
||||
|
||||
string? error;
|
||||
string? code = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
||||
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = responseBody;
|
||||
}
|
||||
|
||||
return new ManagementResponse((int)response.StatusCode, null, error, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,12 +370,15 @@ public class ManagementHttpClient : IDisposable
|
||||
var body = JsonSerializer.Serialize(new { command = commandName, payload },
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync("management", content, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Post, "management")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
},
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -165,7 +418,7 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <c>/api/audit/query</c> endpoint) and returns the
|
||||
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
|
||||
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
|
||||
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
|
||||
/// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
||||
/// <param name="timeout">The request timeout.</param>
|
||||
@@ -177,7 +430,9 @@ public class ManagementHttpClient : IDisposable
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -216,7 +471,7 @@ public class ManagementHttpClient : IDisposable
|
||||
/// maintenance endpoints) with a JSON body and returns the response. Unlike
|
||||
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
|
||||
/// <c>POST /management</c> command envelope — these are plain REST resources.
|
||||
/// Authentication (HTTP Basic) and the base address are shared.
|
||||
/// Authentication (bearer token or HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL.</param>
|
||||
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
|
||||
@@ -226,12 +481,15 @@ public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
|
||||
var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||
{
|
||||
Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"),
|
||||
},
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -276,7 +534,9 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The raw HTTP response message for streaming.</returns>
|
||||
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
|
||||
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
=> await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying HTTP client.
|
||||
|
||||
@@ -28,7 +28,7 @@ scadabridge --url http://central-host:5000 <command>
|
||||
scadabridge --url http://localhost:9000 <command>
|
||||
```
|
||||
|
||||
The management API is stateless (Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
|
||||
The management API is stateless (a self-contained bearer token, or Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
|
||||
|
||||
## Global Options
|
||||
|
||||
@@ -61,6 +61,7 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
|
||||
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
|
||||
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
|
||||
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
|
||||
| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). |
|
||||
|
||||
## Output
|
||||
|
||||
@@ -2672,13 +2673,23 @@ client deadline; explicit bundle/Flow Debugger timeouts retain their own policie
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It authenticates with a bearer token (see *Authentication*). Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
|
||||
There is no Akka dependency in the CLI at all: it does not join the cluster, does not use `ClusterClient`, and does not contact a `ClusterClientReceptionist`. (Earlier revisions of this document described a ClusterClient transport that was never built.)
|
||||
|
||||
An `HttpClient` is created per command invocation and disposed when the command completes.
|
||||
|
||||
Role enforcement is applied by the ManagementActor on the server side. The CLI sends `--username` / `--password` as HTTP Basic credentials. The server performs LDAP authentication, resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
Role enforcement is applied by the ManagementActor on the server side. The server performs LDAP authentication (once, at the token exchange — or per call on the Basic fallback), resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
|
||||
## Authentication
|
||||
|
||||
The CLI resolves `--username` / `--password` (or `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD`) and, on the first management call of the process, exchanges them at `POST /auth/token` for a short-lived bearer JWT (default 15 minutes) carrying the username, roles and permitted sites. That exchange is the invocation's only LDAP bind: every later call — `/management`, the audit REST endpoints, and `debug stream` (including its reconnects) — sends `Authorization: Bearer <token>`, which the server validates without contacting LDAP, so a short LDAP outage does not interrupt a run that already holds a token.
|
||||
|
||||
- The token is kept **in memory only** (never written to disk, logged or printed) and dies with the process.
|
||||
- A call refused with `401` re-exchanges once, then retries with HTTP Basic.
|
||||
- A server without bearer support — no `/auth/token`, a login-disabled deployment, or an older server whose token response lacks `management_bearer: true` — is used with HTTP Basic for the rest of the process (an LDAP bind per call, as before).
|
||||
- Bad credentials or a login-throttle lockout at the exchange are reported directly (exit 1) and not retried with Basic.
|
||||
- Set `SCADABRIDGE_AUTH_MODE=basic` to skip the exchange entirely.
|
||||
|
||||
## Issues & Missing Features
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
@@ -18,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
/// </summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
|
||||
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/token</c>, <c>/auth/logout</c>, <c>/auth/ping</c> and <c>/auth/activity</c> endpoints on the given route builder.</summary>
|
||||
/// <param name="endpoints">The route builder to add the endpoints to.</param>
|
||||
/// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
@@ -118,70 +120,9 @@ public static class AuthEndpoints
|
||||
context.Response.Redirect("/");
|
||||
});
|
||||
|
||||
endpoints.MapPost("/auth/token", async (HttpContext context) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
// /auth/login. Fall back to no site-scope rather than throwing if a future
|
||||
// mapper leaves Scope null or sets a different type.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName,
|
||||
resolvedUsername,
|
||||
roleMapping.Roles,
|
||||
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
username = resolvedUsername,
|
||||
display_name = displayName,
|
||||
roles = roleMapping.Roles,
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
// Credential → bearer-token exchange for the CLI (review 2026-10-21 05/U1, 05/P1).
|
||||
// Antiforgery is disabled: this is a non-cookie, credential-in-body endpoint.
|
||||
endpoints.MapPost("/auth/token", (Delegate)HandleTokenAsync).DisableAntiforgery();
|
||||
|
||||
// Logout is a state-changing authenticated action: it
|
||||
// keeps antiforgery validation enabled so it cannot be triggered
|
||||
@@ -212,6 +153,107 @@ public static class AuthEndpoints
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <c>POST /auth/token</c>: binds the form-posted <c>username</c>/<c>password</c>
|
||||
/// against LDAP (login-throttled), maps the groups to roles and site scope, and returns a
|
||||
/// short-lived HS256 bearer JWT (<see cref="SecurityOptions.JwtExpiryMinutes"/>) that
|
||||
/// <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
|
||||
/// endpoints and the debug-stream hub. The token carries the username, display name, roles
|
||||
/// and permitted site ids — everything those surfaces need, so a bearer request costs no
|
||||
/// LDAP bind. The response's <c>management_bearer: true</c> marks a server whose management
|
||||
/// surfaces accept the token; a client that does not see it (an older server, whose tokens
|
||||
/// nothing accepted) falls back to HTTP Basic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the dev/test <c>DisableLogin</c> flag the exchange answers <c>404</c>: the
|
||||
/// management surfaces already authenticate every request as the dev user, so the CLI falls
|
||||
/// back to Basic, and no credential-free token is ever minted that another deployment
|
||||
/// sharing the signing key might honour.
|
||||
/// </remarks>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <returns>The token JSON on success; 400/401/404/429 otherwise.</returns>
|
||||
public static async Task<IResult> HandleTokenAsync(HttpContext context)
|
||||
{
|
||||
var disableLogin = context.RequestServices.GetService<IOptions<AuthDisableLoginOptions>>()?.Value;
|
||||
if (disableLogin?.DisableLogin == true)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Token exchange is unavailable while login is disabled.", code = "TOKEN_EXCHANGE_DISABLED" },
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!context.Request.HasFormContentType)
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync(context.RequestAborted);
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
// /auth/login. Fall back to no site-scope rather than throwing if a future
|
||||
// mapper leaves Scope null or sets a different type.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
|
||||
// Site ids are stamped exactly as ManagementAuthenticator's Basic path projects them:
|
||||
// none for a system-wide grant, the permitted set otherwise.
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName,
|
||||
resolvedUsername,
|
||||
roleMapping.Roles,
|
||||
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
expires_in = (int)jwtService.TokenLifetime.TotalSeconds,
|
||||
management_bearer = true,
|
||||
username = resolvedUsername,
|
||||
display_name = displayName,
|
||||
roles = roleMapping.Roles,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
|
||||
/// cookie session is still valid and <c>401</c> once it has lapsed
|
||||
|
||||
@@ -85,6 +85,17 @@ public static class StartupValidator
|
||||
.Require("ScadaBridge:Security:JwtSigningKey",
|
||||
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
|
||||
"required for Central")
|
||||
// The key signs the CLI bearer JWT (/auth/token → ManagementAuthenticator).
|
||||
// JwtTokenService is resolved lazily (first token request), so its own
|
||||
// constructor check cannot fail the boot — a 1-byte key used to boot clean
|
||||
// and mint trivially forgeable tokens (review 2026-10-21 05/U1). HMAC-SHA256
|
||||
// needs >= 256 bits; measured in UTF-8 bytes, exactly as the service keys the
|
||||
// HMAC. A missing key is reported by the rule above, not twice.
|
||||
.Require("ScadaBridge:Security:JwtSigningKey",
|
||||
value => string.IsNullOrEmpty(value)
|
||||
|| System.Text.Encoding.UTF8.GetByteCount(value) >= ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes,
|
||||
$"must be at least {ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes} bytes "
|
||||
+ "(256 bits) for HMAC-SHA256 — it signs the CLI bearer tokens, and a short key makes them forgeable")
|
||||
// The inbound API-key pepper
|
||||
// backs the peppered-HMAC secret compare in the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the
|
||||
|
||||
@@ -10,13 +10,24 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces
|
||||
/// (<see cref="ManagementEndpoints"/>, <see cref="AuditEndpoints"/>, and the debug-stream hub).
|
||||
/// Centralising the flow here means the dev/test <c>DisableLogin</c> bypass is applied
|
||||
/// <em>identically</em> on every CLI-facing surface.
|
||||
/// Shared authentication for the management/CLI surfaces (<see cref="ManagementEndpoints"/>,
|
||||
/// <see cref="AuditEndpoints"/>, and the debug-stream hub). Accepts either
|
||||
/// <c>Authorization: Bearer <jwt></c> (a token minted by <c>POST /auth/token</c>) or
|
||||
/// HTTP Basic (an LDAP bind per request). Centralising the flow here means the dev/test
|
||||
/// <c>DisableLogin</c> bypass and both schemes are applied <em>identically</em> on every
|
||||
/// CLI-facing surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bearer</b> (review 2026-10-21 05/U1, 05/P1): the token is validated by
|
||||
/// <see cref="JwtTokenService.ValidateToken"/> — HS256 signature with the shared
|
||||
/// <c>JwtSigningKey</c>, issuer, audience and lifetime (small clock skew). The username, roles
|
||||
/// and permitted site ids inside a valid token are authoritative for its lifetime: a bearer
|
||||
/// request performs no LDAP bind and no role re-map, mirroring "LDAP failure: active sessions
|
||||
/// continue with current roles". An invalid token is a plain 401 — there is no LDAP bind to
|
||||
/// throttle, so it does not feed <see cref="LoginThrottle"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ScadaBridge:Security:Auth:DisableLogin</c> swaps the <em>cookie</em> authentication
|
||||
/// scheme for <c>AutoLoginAuthenticationHandler</c>, which lets the interactive browser UI
|
||||
/// in without credentials. The CLI does not use cookies — it authenticates the
|
||||
@@ -61,9 +72,10 @@ public static class ManagementAuthenticator
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a management/CLI request. Honours the dev/test <c>DisableLogin</c> bypass
|
||||
/// first (<see cref="TryDisableLoginUser"/>); otherwise decodes HTTP Basic Auth, binds
|
||||
/// against LDAP, and resolves roles. Returns a populated <see cref="AuthenticatedUser"/> on
|
||||
/// success, or an <see cref="IResult"/> carrying the 401 response on any failure.
|
||||
/// first (<see cref="TryDisableLoginUser"/>); otherwise accepts a bearer JWT (validated
|
||||
/// locally, no LDAP) or decodes HTTP Basic Auth, binds against LDAP, and resolves roles.
|
||||
/// Returns a populated <see cref="AuthenticatedUser"/> on success, or an
|
||||
/// <see cref="IResult"/> carrying the 401 (or 429 throttle) response on any failure.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP request to authenticate.</param>
|
||||
/// <returns>An <see cref="AuthOutcome"/> carrying either the user or the 401 failure result.</returns>
|
||||
@@ -75,12 +87,17 @@ public static class ManagementAuthenticator
|
||||
if (bypass is not null)
|
||||
return new AuthOutcome(bypass, null);
|
||||
|
||||
// 1. Decode Basic Auth
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
|
||||
// 0. Bearer token minted by POST /auth/token — validated locally, no LDAP bind.
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateBearer(context, authHeader["Bearer ".Length..].Trim());
|
||||
|
||||
// 1. Decode Basic Auth
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
new { error = "Authorization header required (Basic or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
string username, password;
|
||||
@@ -144,4 +161,39 @@ public static class ManagementAuthenticator
|
||||
|
||||
return new AuthOutcome(resolvedUser, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bearer JWT and projects its claims onto an <see cref="AuthenticatedUser"/>.
|
||||
/// The token's roles and site scope are taken as-is (authoritative until expiry); an
|
||||
/// empty site-id set is the same system-wide signal the Basic path produces.
|
||||
/// </summary>
|
||||
private static AuthOutcome AuthenticateBearer(HttpContext context, string token)
|
||||
{
|
||||
var jwt = context.RequestServices.GetService<JwtTokenService>();
|
||||
var principal = jwt?.ValidateToken(token);
|
||||
var username = principal?.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
||||
if (principal is null || string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Invalid or expired bearer token.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
var roles = principal.FindAll(JwtTokenService.RoleClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new AuthOutcome(
|
||||
new AuthenticatedUser(
|
||||
username,
|
||||
string.IsNullOrWhiteSpace(displayName) ? username : displayName,
|
||||
roles,
|
||||
permittedSiteIds),
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Mints and validates the short-lived HMAC-SHA256 bearer JWT that <c>POST /auth/token</c>
|
||||
/// issues and <c>ManagementAuthenticator</c> accepts on the management, audit REST and
|
||||
/// debug-stream surfaces. Also owns the claim-type constants shared with the cookie session.
|
||||
/// The Blazor cookie session does NOT embed a JWT.
|
||||
/// </summary>
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly SecurityOptions _options;
|
||||
@@ -56,16 +62,30 @@ public class JwtTokenService
|
||||
/// <summary>Fixed audience bound into every token and required on validation.</summary>
|
||||
public const string TokenAudience = "scadabridge-central";
|
||||
|
||||
/// <summary>
|
||||
/// Clock skew tolerated when validating <c>exp</c>/<c>nbf</c>. A bearer token minted on
|
||||
/// one central node is routinely presented to the other, so a few seconds of drift
|
||||
/// between the pair must not reject a valid token; the window is kept small so an
|
||||
/// expired token is not honoured for long.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan ValidationClockSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
|
||||
/// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
|
||||
/// <param name="options">Security options containing the JWT signing key and token lifetime.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||
/// <param name="timeProvider">Clock used to stamp <c>iat</c>/<c>nbf</c>/<c>exp</c>; defaults to the system clock.</param>
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
// Fail fast: a missing or short signing key produces trivially forgeable tokens.
|
||||
// HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
|
||||
// Defence in depth: a missing or short signing key produces trivially forgeable
|
||||
// tokens. HMAC-SHA256 requires a key of at least 256 bits (32 bytes). The boot-time
|
||||
// guard is StartupValidator (Central role), which fails the node before the host is
|
||||
// built; this check only catches a host that bypassed it (tests, tooling).
|
||||
var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
|
||||
? 0
|
||||
: Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
|
||||
@@ -78,25 +98,26 @@ public class JwtTokenService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lifetime of an issued bearer token (<see cref="SecurityOptions.JwtExpiryMinutes"/>).</summary>
|
||||
public TimeSpan TokenLifetime => TimeSpan.FromMinutes(_options.JwtExpiryMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
|
||||
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
|
||||
/// refresh the caller MUST pass the existing anchor forward so the idle window
|
||||
/// continues to be measured from the user's last genuine activity rather than
|
||||
/// from token issuance time.
|
||||
/// Issues a short-lived bearer JWT for the management / audit / debug-stream surfaces
|
||||
/// (<c>POST /auth/token</c>). The token carries everything <c>ManagementAuthenticator</c>
|
||||
/// needs — username, display name, roles and the permitted site ids — and those claims
|
||||
/// are authoritative until the token expires (<see cref="SecurityOptions.JwtExpiryMinutes"/>):
|
||||
/// a bearer request performs no LDAP bind and no role re-map.
|
||||
/// </summary>
|
||||
/// <param name="displayName">Human-readable display name embedded as a claim.</param>
|
||||
/// <param name="username">Authenticated username embedded as a claim.</param>
|
||||
/// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
|
||||
/// <returns>The signed JWT string, ready to embed in a cookie or bearer header.</returns>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null or empty for system-wide access.</param>
|
||||
/// <returns>The signed JWT string, ready to send as an <c>Authorization: Bearer</c> header.</returns>
|
||||
public string GenerateToken(
|
||||
string displayName,
|
||||
string username,
|
||||
IReadOnlyList<string> roles,
|
||||
IReadOnlyList<string>? permittedSiteIds,
|
||||
DateTimeOffset? lastActivity = null)
|
||||
IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
@@ -105,7 +126,6 @@ public class JwtTokenService
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
@@ -121,11 +141,13 @@ public class JwtTokenService
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: TokenIssuer,
|
||||
audience: TokenAudience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||
notBefore: now,
|
||||
expires: now.Add(TokenLifetime),
|
||||
signingCredentials: credentials);
|
||||
|
||||
// MapOutboundClaims=false: write the claim TYPE strings into the JWT
|
||||
@@ -135,19 +157,26 @@ public class JwtTokenService
|
||||
// MapInboundClaims=false on validation (see ValidateToken), this makes the
|
||||
// claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte
|
||||
// the same string both in the token and after it is read back, with no
|
||||
// mapping round-trip surprises. The "zb:" scope/display/username claims are
|
||||
// not in any map and were unaffected either way; pinning both directions
|
||||
// makes the role/site migration to canonical types deterministic.
|
||||
// mapping round-trip surprises.
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
handler.OutboundClaimTypeMap.Clear();
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
|
||||
/// <summary>
|
||||
/// Validates a bearer JWT and returns the decoded <see cref="ClaimsPrincipal"/>, or
|
||||
/// <c>null</c> if the token is malformed, unsigned (<c>alg=none</c>), signed with any
|
||||
/// algorithm other than HS256, signed with a different key, expired, not yet valid, or
|
||||
/// minted for a different issuer/audience. Never throws for a bad token and never logs
|
||||
/// the token itself.
|
||||
/// </summary>
|
||||
/// <param name="token">The JWT string to validate.</param>
|
||||
/// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
@@ -156,9 +185,14 @@ public class JwtTokenService
|
||||
ValidateAudience = true,
|
||||
ValidAudience = TokenAudience,
|
||||
ValidateLifetime = true,
|
||||
RequireExpirationTime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
// Signed tokens only, and only the one algorithm we mint: an alg=none or an
|
||||
// algorithm-confusion token is rejected before its claims are read.
|
||||
RequireSignedTokens = true,
|
||||
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
|
||||
ClockSkew = ValidationClockSkew,
|
||||
|
||||
// The token was minted carrying the canonical claim TYPE strings
|
||||
// verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType =
|
||||
@@ -181,114 +215,11 @@ public class JwtTokenService
|
||||
var principal = handler.ValidateToken(token, validationParameters, out _);
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Token validation failed");
|
||||
// Only the exception TYPE is logged — the message can echo token segments.
|
||||
_logger.LogDebug("Bearer token validation failed ({Reason})", ex.GetType().Name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
|
||||
/// <param name="principal">The current authenticated principal.</param>
|
||||
/// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
|
||||
public bool ShouldRefresh(ClaimsPrincipal principal)
|
||||
{
|
||||
var expClaim = principal.FindFirst("exp");
|
||||
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
|
||||
return false;
|
||||
|
||||
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
|
||||
var remaining = expiry - DateTimeOffset.UtcNow;
|
||||
|
||||
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
|
||||
/// <param name="principal">The current authenticated principal.</param>
|
||||
/// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
|
||||
public bool IsIdleTimedOut(ClaimsPrincipal principal)
|
||||
{
|
||||
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
|
||||
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
|
||||
return true;
|
||||
|
||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
|
||||
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
|
||||
/// triggered by a request, but it must not be treated as user activity — the
|
||||
/// idle window must keep being measured from the user's last genuine interaction,
|
||||
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
||||
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
||||
/// anchor when handling a genuine user request.
|
||||
/// <para>
|
||||
/// A principal that is already past the idle timeout cannot be refreshed: this
|
||||
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
|
||||
/// claims). Enforcing the idle check here — rather than relying on the caller to
|
||||
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
|
||||
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
|
||||
/// session could be kept alive indefinitely by background refreshes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
|
||||
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
|
||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
// An idle-expired session must not be renewed — the user must re-login.
|
||||
if (IsIdleTimedOut(currentPrincipal))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cannot refresh token for {Username}: session is past the idle timeout", username);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
ReadLastActivity(currentPrincipal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
|
||||
/// advanced to now. This is the explicit "user did something" path — distinct
|
||||
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
|
||||
/// handling a genuine user interaction.
|
||||
/// </summary>
|
||||
/// <param name="currentPrincipal">The current authenticated principal.</param>
|
||||
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
||||
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
||||
/// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
|
||||
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
|
||||
{
|
||||
var claim = principal.FindFirst(LastActivityClaimType);
|
||||
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,31 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a user's LDAP groups to ScadaBridge roles and the Deployer site scope, from the
|
||||
/// DB-backed LDAP-group-mapping table. Reads through the process-wide
|
||||
/// <see cref="RoleMappingCache"/> when one is supplied (production DI always does), so a
|
||||
/// mapping costs no database round-trip while the snapshot is fresh.
|
||||
/// </summary>
|
||||
public class RoleMapper
|
||||
{
|
||||
private readonly ISecurityRepository _securityRepository;
|
||||
private readonly RoleMappingCache? _cache;
|
||||
|
||||
/// <summary>Initializes the mapper with the security repository.</summary>
|
||||
/// <summary>Initializes an uncached mapper that reads the repository on every call.</summary>
|
||||
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
|
||||
public RoleMapper(ISecurityRepository securityRepository)
|
||||
: this(securityRepository, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes the mapper with the security repository and the process-wide mapping cache.</summary>
|
||||
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
|
||||
/// <param name="cache">Process-wide mapping snapshot; null reads the repository on every call.</param>
|
||||
public RoleMapper(ISecurityRepository securityRepository, RoleMappingCache? cache)
|
||||
{
|
||||
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
// virtual: a test seam so HTTP-pipeline tests (e.g. the audit
|
||||
@@ -23,7 +39,9 @@ public class RoleMapper
|
||||
IReadOnlyList<string> ldapGroups,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
|
||||
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is not null
|
||||
? await _cache.GetAsync(_securityRepository, ct)
|
||||
: await LoadMatchedRowsAsync(ldapGroups, ct);
|
||||
|
||||
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var permittedSiteIds = new HashSet<string>();
|
||||
@@ -31,7 +49,7 @@ public class RoleMapper
|
||||
var hasScopedDeploymentMapping = false;
|
||||
var hasUnscopedDeploymentMapping = false;
|
||||
|
||||
foreach (var mapping in allMappings)
|
||||
foreach (var mapping in rows)
|
||||
{
|
||||
// Match LDAP group names (case-insensitive)
|
||||
if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
|
||||
@@ -43,13 +61,12 @@ public class RoleMapper
|
||||
{
|
||||
hasDeploymentRole = true;
|
||||
|
||||
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
|
||||
if (scopeRules.Count > 0)
|
||||
if (mapping.ScopeSiteIds.Count > 0)
|
||||
{
|
||||
hasScopedDeploymentMapping = true;
|
||||
foreach (var rule in scopeRules)
|
||||
foreach (var siteId in mapping.ScopeSiteIds)
|
||||
{
|
||||
permittedSiteIds.Add(rule.SiteId.ToString());
|
||||
permittedSiteIds.Add(siteId);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -80,4 +97,27 @@ public class RoleMapper
|
||||
permittedSiteIds.ToList(),
|
||||
isSystemWide);
|
||||
}
|
||||
|
||||
// Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the
|
||||
// MATCHED Deployer mappings only, exactly as the mapper always did.
|
||||
private async Task<IReadOnlyList<RoleMappingCache.MappingRow>> LoadMatchedRowsAsync(
|
||||
IReadOnlyList<string> ldapGroups, CancellationToken ct)
|
||||
{
|
||||
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
|
||||
var rows = new List<RoleMappingCache.MappingRow>(allMappings.Count);
|
||||
foreach (var mapping in allMappings)
|
||||
{
|
||||
IReadOnlyList<string> scope = [];
|
||||
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase)
|
||||
&& ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var rules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
|
||||
scope = rules.Select(r => r.SiteId.ToString()).ToArray();
|
||||
}
|
||||
|
||||
rows.Add(new RoleMappingCache.MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide, short-TTL snapshot of the LDAP-group→role mapping table and the site-scope
|
||||
/// rules of every Deployer mapping (review 2026-10-21 05/P1, 07/P-3). Before it, every
|
||||
/// <see cref="RoleMapper.MapGroupsToRolesAsync"/> call — one per Basic-authenticated
|
||||
/// management request and one per Blazor auth-state read — issued
|
||||
/// <c>GetAllMappingsAsync</c> plus one <c>GetScopeRulesForMappingAsync</c> per matched Deployer
|
||||
/// mapping. The table changes only through Administrator commands, so a
|
||||
/// <see cref="SecurityOptions.RoleMappingCacheSeconds"/> window (default 30 s) of staleness is
|
||||
/// an acceptable price: a role revocation reaches every node within that window, and the node
|
||||
/// that served the change sees it at once when the mutation path calls <see cref="Invalidate"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The snapshot holds immutable copies (never the EF entities of whichever scope loaded it), and
|
||||
/// loads the scope rules of ALL Deployer mappings in one refresh, so a cache hit performs no
|
||||
/// database work at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A refresh that started before an <see cref="Invalidate"/> call is not published: a
|
||||
/// generation counter is captured before the load and compared on publish, so an in-flight
|
||||
/// read of the pre-change table can never overwrite the invalidation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RoleMappingCache
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<SecurityOptions>? _options;
|
||||
private readonly object _lock = new();
|
||||
private Entry? _entry;
|
||||
private long _generation;
|
||||
|
||||
/// <summary>One cached mapping row.</summary>
|
||||
/// <param name="Id">The mapping's primary key.</param>
|
||||
/// <param name="LdapGroupName">The LDAP group the mapping matches.</param>
|
||||
/// <param name="Role">The ScadaBridge role the group grants.</param>
|
||||
/// <param name="ScopeSiteIds">The permitted site ids (Deployer mappings only; empty = unscoped).</param>
|
||||
public sealed record MappingRow(int Id, string LdapGroupName, string Role, IReadOnlyList<string> ScopeSiteIds);
|
||||
|
||||
private sealed record Entry(IReadOnlyList<MappingRow> Rows, DateTimeOffset LoadedAt);
|
||||
|
||||
/// <summary>Initializes the cache.</summary>
|
||||
/// <param name="timeProvider">Clock used to age the snapshot.</param>
|
||||
/// <param name="options">Security options supplying <see cref="SecurityOptions.RoleMappingCacheSeconds"/>; null uses the 30 s default.</param>
|
||||
public RoleMappingCache(TimeProvider timeProvider, IOptionsMonitor<SecurityOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options;
|
||||
}
|
||||
|
||||
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
|
||||
|
||||
/// <summary>
|
||||
/// Discards the cached snapshot so the next mapping re-reads the database. Call after any
|
||||
/// LDAP-group-mapping or site-scope-rule create/update/delete.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_generation++;
|
||||
_entry = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current mapping rows, loading them through <paramref name="repository"/> when
|
||||
/// the snapshot is missing, older than the TTL, or the cache is disabled.
|
||||
/// </summary>
|
||||
/// <param name="repository">The caller's (scoped) security repository, used only on a miss.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The mapping rows with their scope rules resolved.</returns>
|
||||
public async Task<IReadOnlyList<MappingRow>> GetAsync(ISecurityRepository repository, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var ttl = Ttl;
|
||||
long generation;
|
||||
lock (_lock)
|
||||
{
|
||||
if (ttl > TimeSpan.Zero && _entry is { } cached
|
||||
&& _timeProvider.GetUtcNow() - cached.LoadedAt < ttl)
|
||||
{
|
||||
return cached.Rows;
|
||||
}
|
||||
|
||||
generation = _generation;
|
||||
}
|
||||
|
||||
var rows = await LoadAsync(repository, ct).ConfigureAwait(false);
|
||||
|
||||
if (ttl > TimeSpan.Zero)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Publish only if no Invalidate() raced the load.
|
||||
if (generation == _generation)
|
||||
{
|
||||
_entry = new Entry(rows, _timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<MappingRow>> LoadAsync(ISecurityRepository repository, CancellationToken ct)
|
||||
{
|
||||
var mappings = await repository.GetAllMappingsAsync(ct).ConfigureAwait(false);
|
||||
var rows = new List<MappingRow>(mappings.Count);
|
||||
foreach (var mapping in mappings)
|
||||
{
|
||||
IReadOnlyList<string> scope = [];
|
||||
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rules = await repository.GetScopeRulesForMappingAsync(mapping.Id, ct).ConfigureAwait(false);
|
||||
scope = rules.Select(r => r.SiteId.ToString()).ToArray();
|
||||
}
|
||||
|
||||
rows.Add(new MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>JWT Bearer path (<c>/auth/token</c>)</b>: <see cref="JwtSigningKey"/> and
|
||||
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to
|
||||
/// the CLI / Inbound API. They have no effect on the Blazor cookie session.
|
||||
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to the CLI,
|
||||
/// which <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
|
||||
/// endpoints and the debug-stream hub. They have no effect on the Blazor cookie session
|
||||
/// (and none on the Inbound API, which authenticates with API keys).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Blazor cookie session</b>: <see cref="IdleTimeoutMinutes"/> and
|
||||
@@ -28,8 +30,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
public class SecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
|
||||
/// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
|
||||
/// Symmetric HMAC-SHA256 signing key for the bearer JWTs minted by <c>/auth/token</c>.
|
||||
/// Must be at least <see cref="MinJwtSigningKeyBytes"/> UTF-8 bytes (256 bits) — enforced
|
||||
/// at boot on Central nodes by the Host's <c>StartupValidator</c>, and again (defence in
|
||||
/// depth) at <see cref="JwtTokenService"/> construction. Both central nodes must share
|
||||
/// the same key; rotating it invalidates every outstanding bearer token.
|
||||
/// </summary>
|
||||
public string JwtSigningKey { get; set; } = string.Empty;
|
||||
|
||||
@@ -37,7 +42,11 @@ public class SecurityOptions
|
||||
/// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
|
||||
/// </summary>
|
||||
public const int MinJwtSigningKeyBytes = 32;
|
||||
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
|
||||
/// <summary>
|
||||
/// Lifetime, in minutes, of a bearer JWT issued by <c>/auth/token</c>. The roles and site
|
||||
/// scope inside the token are authoritative for this long (a role revocation reaches a
|
||||
/// bearer caller only when its token expires). Default: <b>15</b>. Must be positive.
|
||||
/// </summary>
|
||||
public int JwtExpiryMinutes { get; set; } = 15;
|
||||
/// <summary>
|
||||
/// Session idle timeout in minutes for the Blazor cookie session; sessions inactive
|
||||
@@ -55,9 +64,14 @@ public class SecurityOptions
|
||||
public int IdleTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes before token expiry to trigger refresh.
|
||||
/// How long, in seconds, <see cref="RoleMapper"/> may serve the LDAP-group→role mapping
|
||||
/// table and its site-scope rules from memory before re-reading the configuration
|
||||
/// database. Default: <b>30</b>. <b>0</b> disables the cache (every mapping reads the
|
||||
/// database). A mapping change is visible on every node within this window; the node
|
||||
/// that served the change sees it immediately once <see cref="RoleMappingCache.Invalidate"/>
|
||||
/// is called.
|
||||
/// </summary>
|
||||
public int JwtRefreshThresholdMinutes { get; set; } = 5;
|
||||
public int RoleMappingCacheSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How long a cookie session's role-mapping projection may be stale
|
||||
@@ -84,7 +98,7 @@ public class SecurityOptions
|
||||
/// <summary>
|
||||
/// When true (default) the authentication cookie is always marked
|
||||
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
|
||||
/// since the cookie carries the embedded JWT bearer credential. Set false
|
||||
/// since the cookie is the session credential. Set false
|
||||
/// for an HTTP-only deployment such as the local Docker dev cluster: the
|
||||
/// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
|
||||
/// any HTTPS request but is usable over plain HTTP.
|
||||
@@ -172,6 +186,21 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
|
||||
$"enforcement is defeated.");
|
||||
}
|
||||
|
||||
// A non-positive bearer lifetime mints tokens that are already expired.
|
||||
if (options.JwtExpiryMinutes <= 0)
|
||||
{
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
||||
$"{nameof(SecurityOptions.JwtExpiryMinutes)} " +
|
||||
$"({options.JwtExpiryMinutes}) must be positive.");
|
||||
}
|
||||
|
||||
if (options.RoleMappingCacheSeconds < 0)
|
||||
{
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
||||
$"{nameof(SecurityOptions.RoleMappingCacheSeconds)} " +
|
||||
$"({options.RoleMappingCacheSeconds}) must be non-negative (0 disables the cache).");
|
||||
}
|
||||
|
||||
// SECURITY: login-throttle thresholds must be non-negative. 0 for
|
||||
// MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch;
|
||||
// a negative value (or a negative window/lockout) is a misconfiguration that would
|
||||
|
||||
@@ -61,6 +61,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<JwtTokenService>();
|
||||
services.AddScoped<RoleMapper>();
|
||||
|
||||
// Process-wide short-TTL cache of the LDAP-group→role mapping table + scope rules
|
||||
// (review 2026-10-21 05/P1). Singleton so every scoped RoleMapper shares it; the
|
||||
// repository it reads through is still the caller's scoped one.
|
||||
services.AddSingleton<RoleMappingCache>();
|
||||
|
||||
// The cookie OnValidatePrincipal core. Scoped to match the
|
||||
// IGroupRoleMapper<string> it depends on (which depends on the Scoped
|
||||
// ISecurityRepository). The clock is injected (TimeProvider) so the idle/refresh
|
||||
@@ -110,7 +115,10 @@ public static class ServiceCollectionExtensions
|
||||
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
|
||||
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
|
||||
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
|
||||
// JWT signing key continues to fail-fast at JwtTokenService construction.
|
||||
// JWT signing key (presence AND the >=32-byte HMAC-SHA256 floor) is enforced
|
||||
// pre-host by the Host's StartupValidator on Central nodes; JwtTokenService's
|
||||
// constructor repeats the length check only as defence in depth, since it is
|
||||
// resolved lazily (first /auth/token or bearer request), not at boot.
|
||||
|
||||
// Register ASP.NET Core authentication with cookie scheme. Only the static
|
||||
// SecurityOptions-independent settings (login/logout paths) are set here; the
|
||||
@@ -176,9 +184,9 @@ public static class ServiceCollectionExtensions
|
||||
// renews it on activity (the middleware re-issues the cookie once past
|
||||
// the halfway mark of the window). An active user is therefore kept
|
||||
// signed in; an idle user is signed out after the idle timeout. The
|
||||
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
|
||||
// itself (see JwtTokenService) — a separate layer from the cookie
|
||||
// session window. Bound here via PostConfigure so SecurityOptions
|
||||
// cookie carries a claims ticket, not a JWT — JwtExpiryMinutes governs
|
||||
// only the CLI bearer token minted by /auth/token (see JwtTokenService).
|
||||
// Bound here via PostConfigure so SecurityOptions
|
||||
// (configured by the Host after AddSecurity) is honoured.
|
||||
//
|
||||
// The cookie hardening (HttpOnly=true, SameSite=Strict,
|
||||
@@ -207,15 +215,15 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// When the operator opts out of HTTPS-only cookies,
|
||||
// log a Warning so an HTTP-only deployment is at least audible in
|
||||
// the startup log. The cookie carries the embedded JWT bearer
|
||||
// credential — over plain HTTP that travels in cleartext on every
|
||||
// the startup log. The cookie is the session credential — over
|
||||
// plain HTTP that travels in cleartext on every
|
||||
// request. The default is true; this branch fires only on an
|
||||
// explicit opt-out (typically the dev Docker cluster). Apply sets
|
||||
// SecurePolicy=SameAsRequest in that case.
|
||||
if (!securityOptions.Value.RequireHttpsCookie)
|
||||
{
|
||||
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
||||
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
|
||||
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The session cookie will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user