1b3928a0a2
- 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.
63 lines
3.0 KiB
C#
63 lines
3.0 KiB
C#
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;
|
|
}
|
|
}
|