fix(archreview): security authz+hub cluster (SEC-07/05/08/11) + validation hardening
SEC-07: add QueryActiveAlarmsRequest -> events:read scope arm; fix two tests that
constructed StreamAlarmsRequest instead of QueryActiveAlarmsRequest.
SEC-05: shorten hub-token lifetime 30m -> 5m; document that the ?access_token= query
carriage must never be request-logged.
SEC-08: gateway-side CachingApiKeyVerifier (short TTL, keyed on a hash of the presented
secret) skips the per-call store read+last_used write; CoalescingMarkApiKeyStore bounds
last_used writes to <=1/key/min; identity constraints are cached. Invalidation is wired
at the gateway admin sites (revoke/rotate/delete); short TTL backstops out-of-process CLI.
SEC-11: fixed-window rate limit on POST /auth/login + a per-peer (key-id) failure limiter
checked before VerifyAsync; new MxGateway:Security options bound + validated.
Also fixes regressions from the SEC-01/04/06 commit (c185f62) that a narrow test filter
missed (all now covered by a full-suite checkpoint):
- Rooting check is cross-platform: accepts Windows C:\/UNC forms on Unix so the shipped
appsettings path validates on the macOS dev box, still rejecting bare filenames.
- AddGatewayConfiguration TryAdds a non-production IHostEnvironment fallback so the validator
resolves in minimal test/tooling containers; the real host + apikey CLI register the actual
environment first (TryAdd no-op there).
- Test assembly defaults ASPNETCORE_ENVIRONMENT=Development (ModuleInitializer) so full-host
tests exercise wiring instead of tripping the SEC-04/06 production guards.
- GatewayOptionsTests asserts the SEC-01 CommonApplicationData-derived default (platform-correct).
archreview: SEC-07/05/08/11 (P1). Verified: NonWindows build clean; full gateway suite
747 passed / 42 failed, where all 42 are the pre-existing macOS named-pipe-harness failures
(Unix-socket path limit) and 0 are validation/regression failures.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Lets gateway-side admin call sites drop a cached API-key verification when they revoke, rotate
|
||||
/// or delete a key, so a mutation applied through the running gateway process takes effect
|
||||
/// immediately rather than after the cache TTL elapses.
|
||||
/// </summary>
|
||||
public interface IApiKeyCacheInvalidator
|
||||
{
|
||||
/// <summary>Removes every cached verification for the given key id.</summary>
|
||||
/// <param name="keyId">The key id whose cached verifications should be dropped.</param>
|
||||
void Invalidate(string keyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IApiKeyVerifier"/> decorator that caches successful verifications for a short,
|
||||
/// configurable TTL (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>), keyed on a
|
||||
/// SHA-256 hash of the presented token. A cache hit returns the cached result without calling the
|
||||
/// inner (library) verifier, so it avoids both the per-call SQLite read and the <c>last_used</c>
|
||||
/// write the library verifier couples into <see cref="IApiKeyVerifier.VerifyAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Only successful verifications are cached. Failures and unparseable headers always fall through
|
||||
/// to the inner verifier — caching a failure risks pinning a transiently-wrong negative, and the
|
||||
/// SEC-11 per-peer failure counter (not this cache) is what bounds brute-force cost.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cache key is the hex SHA-256 of the presented token (which embeds both the key id and the
|
||||
/// secret). Hashing means the plaintext secret is never stored in memory. The hash is a cache index
|
||||
/// only; the authentic constant-time secret comparison remains inside the inner verifier, reached
|
||||
/// on every cache miss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete
|
||||
/// call <see cref="Invalidate"/> directly (see <c>DashboardApiKeyManagementService</c>), and the
|
||||
/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the
|
||||
/// separate <c>apikey</c> CLI process, whose in-memory cache is not this process's cache).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
|
||||
{
|
||||
private const string CacheKeyPrefix = "mxgw:apikeyverif:";
|
||||
|
||||
private readonly IApiKeyVerifier _inner;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly TimeSpan _ttl;
|
||||
|
||||
// keyId -> set of cache keys currently held for that id, so a revoke/rotate can evict every
|
||||
// secret variant (e.g. an old secret still within TTL after a rotate).
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CachingApiKeyVerifier"/> class.</summary>
|
||||
/// <param name="inner">The wrapped verifier (the library verifier) reached on a cache miss.</param>
|
||||
/// <param name="cache">The shared memory cache.</param>
|
||||
/// <param name="security">Security options carrying the verification-cache TTL.</param>
|
||||
public CachingApiKeyVerifier(IApiKeyVerifier inner, IMemoryCache cache, SecurityOptions security)
|
||||
: this(
|
||||
inner,
|
||||
cache,
|
||||
TimeSpan.FromSeconds((security ?? throw new ArgumentNullException(nameof(security)))
|
||||
.ApiKeyVerificationCacheSeconds))
|
||||
{
|
||||
}
|
||||
|
||||
// Test/explicit-TTL seam.
|
||||
internal CachingApiKeyVerifier(IApiKeyVerifier inner, IMemoryCache cache, TimeSpan ttl)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
_inner = inner;
|
||||
_cache = cache;
|
||||
_ttl = ttl;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
if (_ttl <= TimeSpan.Zero || !TryComputeCacheKey(authorizationHeader, out string cacheKey))
|
||||
{
|
||||
return await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out ApiKeyVerification? cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Succeeded && result.Identity is not null)
|
||||
{
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _ttl,
|
||||
});
|
||||
IndexCacheKey(result.Identity.KeyId, cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string keyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
|
||||
{
|
||||
foreach (string cacheKey in cacheKeys.Keys)
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void IndexCacheKey(string keyId, string cacheKey)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
|
||||
keyId,
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
set.TryAdd(cacheKey, 0);
|
||||
}
|
||||
|
||||
private static bool TryComputeCacheKey(string? authorizationHeader, out string cacheKey)
|
||||
{
|
||||
cacheKey = string.Empty;
|
||||
if (string.IsNullOrEmpty(authorizationHeader))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||
const string bearer = "Bearer ";
|
||||
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: header;
|
||||
|
||||
if (token.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(token.ToString()));
|
||||
cacheKey = CacheKeyPrefix + Convert.ToHexString(hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user