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:
@@ -43,7 +43,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
|
||||
### CLI / API sessions — bearer JWT (`POST /auth/token`)
|
||||
- `POST /auth/token` (form `username`/`password`) performs the LDAP bind (login-throttled), maps the groups to roles and site scope, and returns a short-lived **HS256 JWT** signed with `SecurityOptions.JwtSigningKey`, shared by both central nodes: `{ access_token, token_type: "Bearer", expires_in, management_bearer: true, username, display_name, roles }`.
|
||||
- **JWT claims**: display name, username, roles, and for a site-scoped Deployer the permitted site IDs (no site claims = system-wide, the same normalisation the Basic path uses). Issuer and audience are both fixed to `scadabridge-central`. Lifetime is `JwtExpiryMinutes` (default **15**).
|
||||
- `ManagementAuthenticator` accepts `Authorization: Bearer <jwt>` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged.
|
||||
- `ManagementAuthenticator` accepts `Authorization: Bearer <jwt>` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift; `nbf` is back-dated by the same skew so a peer whose clock lags the issuer accepts a fresh token after failover); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged.
|
||||
- **Roles and scope inside a valid token are authoritative for its lifetime**: a bearer request performs no LDAP bind and no role re-map. A role revocation therefore reaches a bearer caller when its token expires (≤ `JwtExpiryMinutes`); rotating `JwtSigningKey` invalidates every outstanding token immediately.
|
||||
- The CLI exchanges credentials once per process, keeps the token in memory only, re-exchanges once on a `401`, and falls back to HTTP Basic when the server has no bearer support — see `Component-CLI.md`. HTTP Basic stays supported on every surface.
|
||||
- Under the dev/test `DisableLogin` flag `/auth/token` answers `404`: the management surfaces already authenticate every request as the dev user, and no credential-free token is minted that another deployment sharing the key might honour.
|
||||
@@ -53,7 +53,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
|
||||
|
||||
- **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other.
|
||||
- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch.
|
||||
- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. A mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` makes it immediate on the node that served the change; wiring it into the ManagementActor role-mapping / scope-rule handlers is a pending follow-up, so today the TTL alone bounds the staleness (a refresh started before an invalidation is never published over it).
|
||||
- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. Without an explicit invalidation a mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` discards the calling node's snapshot so its next mapping re-reads the database (a refresh started before an invalidation is never published over it); other nodes still rely on the TTL. `RoleMappingCacheSeconds = 0` bypasses the snapshot and reads only the matched mappings' scope rules per call.
|
||||
|
||||
#### Session Validation (`OnValidatePrincipal`)
|
||||
- The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+86
-2
@@ -30,14 +30,14 @@ public class ManagementAuthenticatorBearerTests
|
||||
new(Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 }),
|
||||
NullLogger<JwtTokenService>.Instance, clock);
|
||||
|
||||
private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization)
|
||||
private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization, string key = Key)
|
||||
{
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(
|
||||
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
|
||||
services.AddSingleton<IOptions<SecurityOptions>>(
|
||||
Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 }));
|
||||
Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 }));
|
||||
services.AddLogging();
|
||||
services.AddSingleton(ldap);
|
||||
services.AddScoped<JwtTokenService>();
|
||||
@@ -162,6 +162,90 @@ public class ManagementAuthenticatorBearerTests
|
||||
Assert.NotNull(outcome.Failure);
|
||||
}
|
||||
|
||||
private static string HandMinted(string key, string algorithm, string issuer, string audience)
|
||||
{
|
||||
var credentials = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
||||
new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), algorithm);
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: [new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)],
|
||||
expires: DateTime.UtcNow.AddMinutes(10),
|
||||
signingCredentials: credentials);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
handler.OutboundClaimTypeMap.Clear();
|
||||
return handler.WriteToken(jwt);
|
||||
}
|
||||
|
||||
/// <summary>A token signed with the right key but HS512 is rejected: only HS256 is accepted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Hs512WithSameKey_Is401()
|
||||
{
|
||||
// 64+ bytes so HS512 itself is satisfied; only the algorithm allow-list can reject it.
|
||||
var longKey = new string('k', 80);
|
||||
var token = HandMinted(longKey, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha512,
|
||||
JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience);
|
||||
var (context, _) = ContextWithAuthorization($"Bearer {token}", longKey);
|
||||
|
||||
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
||||
|
||||
Assert.Null(outcome.User);
|
||||
Assert.NotNull(outcome.Failure);
|
||||
}
|
||||
|
||||
/// <summary>The same key and algorithm with a hand-minted HS256 token is accepted (control for the cases below).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task HandMintedHs256_OwnIssuerAndAudience_IsAccepted()
|
||||
{
|
||||
var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256,
|
||||
JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience);
|
||||
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
||||
|
||||
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
||||
|
||||
Assert.NotNull(outcome.User);
|
||||
}
|
||||
|
||||
/// <summary>A correctly signed token for another issuer or audience is rejected.</summary>
|
||||
/// <param name="issuer">The token's issuer.</param>
|
||||
/// <param name="audience">The token's audience.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("some-other-system", JwtTokenService.TokenAudience)]
|
||||
[InlineData(JwtTokenService.TokenIssuer, "some-other-audience")]
|
||||
public async Task IssuerOrAudienceMismatch_Is401(string issuer, string audience)
|
||||
{
|
||||
var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256, issuer, audience);
|
||||
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
||||
|
||||
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
||||
|
||||
Assert.Null(outcome.User);
|
||||
Assert.NotNull(outcome.Failure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// nbf is back-dated by the validation skew, so a peer node whose clock lags the issuer by
|
||||
/// up to twice the skew accepts a fresh token (review L1).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FreshToken_FromIssuerAhead_IsAcceptedByLaggingPeer()
|
||||
{
|
||||
// Issuer clock 45 s ahead of this (validating) node — beyond the 30 s skew alone, within
|
||||
// skew + the nbf back-dating.
|
||||
var token = Jwt(clock: new FixedClock(DateTimeOffset.UtcNow.AddSeconds(45)))
|
||||
.GenerateToken("A", "admin", [Roles.Administrator], null);
|
||||
var (context, _) = ContextWithAuthorization($"Bearer {token}");
|
||||
|
||||
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
|
||||
|
||||
Assert.NotNull(outcome.User);
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string s)
|
||||
{
|
||||
var padded = s.Replace('-', '+').Replace('_', '/');
|
||||
|
||||
@@ -29,13 +29,17 @@ public class RoleMappingCacheTests
|
||||
public List<LdapGroupMapping> Mappings { get; } = [];
|
||||
public Dictionary<int, IReadOnlyList<SiteScopeRule>> ScopeRules { get; } = [];
|
||||
public int MappingReads { get; private set; }
|
||||
public Func<Task>? BeforeMappingRead { get; set; }
|
||||
public int ScopeReads { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
MappingReads++;
|
||||
return Task.FromResult<IReadOnlyList<LdapGroupMapping>>(Mappings.ToList());
|
||||
var snapshot = Mappings.ToList();
|
||||
if (BeforeMappingRead is not null)
|
||||
await BeforeMappingRead();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -140,10 +144,13 @@ public class RoleMappingCacheTests
|
||||
Assert.Empty(after.Roles);
|
||||
}
|
||||
|
||||
/// <summary>A zero TTL disables caching entirely.</summary>
|
||||
/// <summary>
|
||||
/// A zero TTL disables caching entirely and reads only the MATCHED Deployer mappings' scope
|
||||
/// rules, not every Deployer mapping's (review L6).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ZeroTtl_DisablesCache()
|
||||
public async Task ZeroTtl_DisablesCache_AndReadsOnlyMatchedScopeRules()
|
||||
{
|
||||
var (repo, _, _, mapper) = Create(ttlSeconds: 0);
|
||||
|
||||
@@ -151,6 +158,41 @@ public class RoleMappingCacheTests
|
||||
await mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
|
||||
Assert.Equal(2, repo.MappingReads);
|
||||
Assert.Equal(0, repo.ScopeReads); // "Admins" matches no Deployer mapping
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refresh that read the table before an <see cref="RoleMappingCache.Invalidate"/> is not
|
||||
/// published over it: the next mapping re-reads (review L5).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RefreshRacingInvalidate_IsNotPublished()
|
||||
{
|
||||
var (repo, _, cache, mapper) = Create();
|
||||
var readStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
repo.BeforeMappingRead = async () =>
|
||||
{
|
||||
readStarted.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
// Refresh #1 snapshots the table WITH the Admins mapping, then stalls.
|
||||
var inFlight = mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
await readStarted.Task;
|
||||
repo.BeforeMappingRead = null;
|
||||
|
||||
// The mapping is revoked and the cache invalidated while #1 is still in flight.
|
||||
repo.Mappings.RemoveAll(m => m.Id == 1);
|
||||
cache.Invalidate();
|
||||
release.SetResult();
|
||||
Assert.Contains(Roles.Administrator, (await inFlight).Roles); // #1 answers its own caller
|
||||
|
||||
var after = await mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
|
||||
Assert.Empty(after.Roles); // the stale snapshot was never published
|
||||
Assert.Equal(2, repo.MappingReads);
|
||||
}
|
||||
|
||||
/// <summary>The cache is shared by mappers built over different (scoped) repositories.</summary>
|
||||
|
||||
Reference in New Issue
Block a user