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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user