fix(security): bearer token hardening follow-ups — nbf skew, TTL-0 path, cache docs, extra rejection tests (review 2026-10-21 05/U1, 05/P1 L1/L5/L6)
- JwtTokenService back-dates nbf by the 30 s validation skew so a peer central whose clock lags the issuer accepts a fresh token after failover. - RoleMappingCacheSeconds = 0 routes RoleMapper to the matched-rows loader (old cost) instead of loading every Deployer mapping's scope rules per call. - RoleMappingCache / SecurityOptions / Component-Security.md describe Invalidate() by its contract plus the TTL, not by a call site. - Tests: HS512 with the same key, issuer/audience mismatch, lagging-peer nbf, TTL-0 matched-only reads, refresh-vs-Invalidate generation race.
This commit is contained in:
@@ -146,7 +146,10 @@ public class JwtTokenService
|
||||
issuer: TokenIssuer,
|
||||
audience: TokenAudience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
// nbf is back-dated by the validation skew: a token minted on one central node and
|
||||
// presented to its peer straight after a failover must not be refused because the
|
||||
// peer's clock lags the issuer's.
|
||||
notBefore: now - ValidationClockSkew,
|
||||
expires: now.Add(TokenLifetime),
|
||||
signingCredentials: credentials);
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ public class RoleMapper
|
||||
IReadOnlyList<string> ldapGroups,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is not null
|
||||
// A disabled cache (TTL 0) takes the matched-rows path: the snapshot loader would read
|
||||
// the scope rules of EVERY Deployer mapping on each call.
|
||||
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is { Enabled: true }
|
||||
? await _cache.GetAsync(_securityRepository, ct)
|
||||
: await LoadMatchedRowsAsync(ldapGroups, ct);
|
||||
|
||||
@@ -98,7 +100,7 @@ public class RoleMapper
|
||||
isSystemWide);
|
||||
}
|
||||
|
||||
// Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the
|
||||
// Uncached path (no RoleMappingCache supplied, or its TTL is 0): 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)
|
||||
|
||||
@@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// <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"/>.
|
||||
/// an acceptable price: without an explicit <see cref="Invalidate"/>, a mapping or scope-rule
|
||||
/// change is visible on every node within that window. <see cref="Invalidate"/> discards this
|
||||
/// node's snapshot so the next mapping re-reads the database; calling it after a mutation makes
|
||||
/// the change immediate on the node that performed it (other nodes still rely on the TTL).
|
||||
/// A TTL of <c>0</c> disables the cache: <see cref="Enabled"/> is false and
|
||||
/// <see cref="RoleMapper"/> reads the repository directly on every call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -54,6 +58,13 @@ public sealed class RoleMappingCache
|
||||
|
||||
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
|
||||
|
||||
/// <summary>
|
||||
/// False when <see cref="SecurityOptions.RoleMappingCacheSeconds"/> is <c>0</c>; callers then
|
||||
/// bypass the snapshot (which would otherwise load every Deployer mapping's scope rules on
|
||||
/// each call) and read only what they match.
|
||||
/// </summary>
|
||||
public bool Enabled => Ttl > TimeSpan.Zero;
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -67,9 +67,9 @@ public class SecurityOptions
|
||||
/// 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.
|
||||
/// database, matched scope rules only). Without an explicit invalidation a mapping change
|
||||
/// is visible on every node within this window; <see cref="RoleMappingCache.Invalidate"/>
|
||||
/// discards the calling node's snapshot so its next mapping re-reads the database.
|
||||
/// </summary>
|
||||
public int RoleMappingCacheSeconds { get; set; } = 30;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user