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:
@@ -101,6 +101,38 @@ return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
|||||||
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
|
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
|
||||||
authorization code consumes.
|
authorization code consumes.
|
||||||
|
|
||||||
|
### Hot-path caching and last-used coalescing
|
||||||
|
|
||||||
|
Left unmediated, every authenticated gRPC call costs a SQLite read plus a
|
||||||
|
`last_used_utc` **write** (the library verifier couples `MarkKeyUsed` into
|
||||||
|
`VerifyAsync`), which makes the auth store the throughput ceiling on the
|
||||||
|
bulk-read workload. The gateway layers two decorators over the shared library's
|
||||||
|
registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the
|
||||||
|
library:
|
||||||
|
|
||||||
|
- **`CachingApiKeyVerifier`** wraps `IApiKeyVerifier` with an `IMemoryCache`
|
||||||
|
entry per successful verification, keyed on a SHA-256 hash of the presented
|
||||||
|
token (never the plaintext secret). A cache hit within
|
||||||
|
`MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the
|
||||||
|
cached result without touching the store, so both the read and the coupled
|
||||||
|
write are skipped. Only successes are cached; failures always reach the inner
|
||||||
|
verifier. On a gateway-initiated revoke/rotate/delete the dashboard admin
|
||||||
|
service calls `IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached
|
||||||
|
entry immediately. The short TTL is the backstop for out-of-band mutations
|
||||||
|
(a direct DB edit, or a revoke run by the separate `apikey` CLI process, whose
|
||||||
|
in-memory cache is not the running gateway's cache).
|
||||||
|
- **`CoalescingMarkApiKeyStore`** wraps `IApiKeyStore` and forwards at most one
|
||||||
|
`MarkUsed` write per key per `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds`
|
||||||
|
(default 60 s), so even under a cache miss the `last_used_utc` write is bounded
|
||||||
|
to roughly one per key per minute rather than one per RPC. `last_used_utc` is a
|
||||||
|
coarse staleness hint, not an audit record (audit rows are written separately),
|
||||||
|
so bounded staleness of up to one window is acceptable.
|
||||||
|
|
||||||
|
`GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON
|
||||||
|
deserialization by blob, so the per-call parse on the mapped identity collapses to
|
||||||
|
a dictionary lookup. Both windows are configurable and may be set to `0` to
|
||||||
|
disable the respective mechanism; see [GatewayConfiguration](./GatewayConfiguration.md).
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
|
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ The flow is:
|
|||||||
|
|
||||||
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
|
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
|
||||||
|
|
||||||
|
### Rate limiting the auth surface (SEC-11)
|
||||||
|
|
||||||
|
Before the verification store read, the helper checks a cheap in-process per-peer failure counter (`ApiKeyFailureLimiter`). A peer that has accumulated more than `MxGateway:Security:ApiKeyFailureLimit` failed attempts inside the sliding `ApiKeyFailureWindowSeconds` window is short-circuited with `StatusCode.ResourceExhausted` — so online guessing of API-key secrets cannot spend a SQLite read (and, in a naive design, a cache miss) per attempt. The peer is keyed on the presented key id where the token parses, falling back to the transport peer address; keying on key id throttles a single abusive credential without penalizing co-located clients behind a shared NAT. A successful verification resets the peer's counter. The counter is a bounded LRU (`ApiKeyFailureTrackedPeers`) so it cannot grow without limit. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property.
|
||||||
|
|
||||||
|
The dashboard login surface is throttled independently: `POST /auth/login` carries a fixed-window ASP.NET Core rate-limiter policy keyed per remote IP (`MxGateway:Security:LoginRateLimit*`), rejecting a burst with HTTP 429 before the LDAP bind is relayed to the directory. See [GatewayConfiguration](./GatewayConfiguration.md#security-options).
|
||||||
|
|
||||||
## Scope Resolution
|
## Scope Resolution
|
||||||
|
|
||||||
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
|
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
|
||||||
@@ -104,6 +110,7 @@ public string ResolveRequiredScope(object request)
|
|||||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||||
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
|
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
TestConnectionRequest or
|
TestConnectionRequest or
|
||||||
GetLastDeployTimeRequest or
|
GetLastDeployTimeRequest or
|
||||||
DiscoverHierarchyRequest or
|
DiscoverHierarchyRequest or
|
||||||
@@ -113,7 +120,7 @@ public string ResolveRequiredScope(object request)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||||
|
|
||||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||||
|
|
||||||
@@ -205,7 +212,7 @@ blocking constraint; secured values and raw credentials are never logged.
|
|||||||
|----------|-------|--------------|
|
|----------|-------|--------------|
|
||||||
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
||||||
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
||||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `QueryActiveAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||||
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
||||||
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
||||||
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
||||||
|
|||||||
@@ -218,9 +218,13 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
|
|||||||
side-effect; the dashboard only sees events while a gRPC client is also
|
side-effect; the dashboard only sees events while a gRPC client is also
|
||||||
subscribed to that session.
|
subscribed to that session.
|
||||||
|
|
||||||
`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer
|
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
||||||
token for the calling user; the Blazor pages use it via
|
token for the calling user; the Blazor pages use it via
|
||||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
||||||
|
The factory refreshes the token on every (re)connect, so the short lifetime
|
||||||
|
(SEC-05) is transparent to clients. The token is not server-side revocable;
|
||||||
|
its short lifetime bounds exposure of a captured token (see
|
||||||
|
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
||||||
|
|
||||||
## LDAP Options (Dashboard Login)
|
## LDAP Options (Dashboard Login)
|
||||||
|
|
||||||
@@ -258,6 +262,23 @@ The protocol option is exposed for diagnostics and explicit deployment
|
|||||||
configuration, not for compatibility negotiation. A mismatch fails validation
|
configuration, not for compatibility negotiation. A mismatch fails validation
|
||||||
at startup.
|
at startup.
|
||||||
|
|
||||||
|
## Security Options
|
||||||
|
|
||||||
|
Bound from `MxGateway:Security`. These knobs tune the authentication hot path
|
||||||
|
(SEC-08 verification cache / last-used coalescing) and the auth-surface rate
|
||||||
|
limiting (SEC-11). Defaults are safe; leave them unless profiling or a threat
|
||||||
|
model requires otherwise.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `MxGateway:Security:ApiKeyVerificationCacheSeconds` | `15` | TTL, in seconds, of a cached successful API-key verification. A cache hit within this window skips the per-call SQLite read (and the coupled `last_used` write). Gateway-initiated revoke/rotate/delete invalidate the entry immediately; this TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke run by the separate `apikey` CLI process). `0` disables the cache. Must be zero or greater. |
|
||||||
|
| `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` | `60` | Coalescing window, in seconds, for the `last_used_utc` write. The library verifier writes `last_used` on every successful verification; this bounds the write to at most one per key per window, so a hammered key does not churn the WAL. `0` forwards every write. Must be zero or greater. |
|
||||||
|
| `MxGateway:Security:LoginRateLimitPermitLimit` | `10` | Maximum `POST /auth/login` attempts permitted per remote IP within `LoginRateLimitWindowSeconds` before requests are rejected with HTTP 429. Throttles LDAP credential stuffing before the bind is relayed to the directory. Must be greater than zero. |
|
||||||
|
| `MxGateway:Security:LoginRateLimitWindowSeconds` | `60` | Fixed-window length, in seconds, for the per-IP login rate limit. Must be greater than zero. |
|
||||||
|
| `MxGateway:Security:ApiKeyFailureLimit` | `10` | Failed API-key verifications, per peer, within `ApiKeyFailureWindowSeconds` that trip the in-process short-circuit. Once tripped, the gRPC auth path rejects further attempts with `ResourceExhausted` **before** the store read; a successful verification resets the peer's counter. The peer is keyed on the presented key id (falling back to the transport address) so a single abusive credential behind a shared NAT is throttled without locking out co-located clients. Must be greater than zero. |
|
||||||
|
| `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted per peer. Must be greater than zero. |
|
||||||
|
| `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct peers tracked by the failure counter (a bounded LRU) so a spray of unique peer keys cannot grow memory without limit. Must be greater than zero. |
|
||||||
|
|
||||||
## Galaxy Options
|
## Galaxy Options
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|
|||||||
@@ -486,9 +486,13 @@ dashboard mints short-lived bearer tokens for the connection:
|
|||||||
and role claims to JSON, encrypts with the ASP.NET Core data-protection
|
and role claims to JSON, encrypts with the ASP.NET Core data-protection
|
||||||
time-limited protector under purpose
|
time-limited protector under purpose
|
||||||
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
|
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
|
||||||
string. Lifetime is 30 minutes.
|
string. Lifetime is **5 minutes**.
|
||||||
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
||||||
`?access_token=…` (WebSocket upgrade query string).
|
`?access_token=…` (WebSocket upgrade query string). The query-string form is
|
||||||
|
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
|
||||||
|
custom header; because it is easy to capture (proxy logs, browser history),
|
||||||
|
it must never be request-logged (see the remarks on
|
||||||
|
`HubTokenAuthenticationHandler`).
|
||||||
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
||||||
rebuilds the `ClaimsPrincipal` with the carried roles.
|
rebuilds the `ClaimsPrincipal` with the carried roles.
|
||||||
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
||||||
@@ -496,7 +500,17 @@ dashboard mints short-lived bearer tokens for the connection:
|
|||||||
|
|
||||||
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
||||||
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
||||||
every (re)connect.
|
every (re)connect, so the short 5-minute lifetime is transparent to clients.
|
||||||
|
|
||||||
|
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
||||||
|
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
||||||
|
carry no server-side revocation state (no jti denylist). A token captured before
|
||||||
|
logout remains valid until it expires, and a role change or key revocation does
|
||||||
|
not take effect on an already-issued token until then. The 5-minute lifetime is
|
||||||
|
the deliberate mitigation: it bounds that exposure window without the cost of a
|
||||||
|
revocation store. Server-side revocation is deferred until per-session hub ACLs
|
||||||
|
land (see the per-session-ACL note), at which point tokens gain session/role
|
||||||
|
binding and a denylist becomes worthwhile.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,18 @@ metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
|
|||||||
|
|
||||||
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
|
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
|
||||||
|
|
||||||
|
## Auth Store Write Churn
|
||||||
|
|
||||||
|
The per-RPC authentication path is a hidden write source: the shared verifier
|
||||||
|
refreshes `last_used_utc` on every authenticated call. To keep that off the
|
||||||
|
auth-store throughput ceiling, the gateway caches successful verifications for a
|
||||||
|
short TTL and coalesces the `last_used_utc` write to at most one per key per
|
||||||
|
window (`MxGateway:Security:ApiKeyVerificationCacheSeconds` /
|
||||||
|
`ApiKeyLastUsedCoalesceSeconds`, defaults 15 s / 60 s). This bounds WAL churn on
|
||||||
|
the bulk-read workload without dropping any metric — auth activity is still
|
||||||
|
observable via command-throughput counters and audit rows. See
|
||||||
|
[Authentication](./Authentication.md#hot-path-caching-and-last-used-coalescing).
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
|
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
|
||||||
|
|||||||
+26
@@ -1,4 +1,7 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -12,6 +15,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddGatewayConfiguration(
|
public static IServiceCollection AddGatewayConfiguration(
|
||||||
this IServiceCollection services, IConfiguration configuration)
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
// GatewayOptionsValidator depends on IHostEnvironment to gate the production-only rules
|
||||||
|
// (SEC-04/06). The real host and the apikey CLI both build through WebApplicationBuilder,
|
||||||
|
// which registers IHostEnvironment before this call — so TryAdd is a no-op there. Minimal
|
||||||
|
// containers (unit tests, tooling) have none; fall back to a non-production environment so
|
||||||
|
// the validator still activates and the production guards stay off outside a real
|
||||||
|
// deployment (the fail-fast guards only make sense against a real host environment).
|
||||||
|
services.TryAddSingleton<IHostEnvironment>(new FallbackHostEnvironment());
|
||||||
|
|
||||||
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
configuration, GatewayOptions.SectionName);
|
configuration, GatewayOptions.SectionName);
|
||||||
|
|
||||||
@@ -19,4 +30,19 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-production <see cref="IHostEnvironment"/> used only when no real host registered one
|
||||||
|
/// (minimal test/tooling containers). The real host's registration always wins via TryAdd.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FallbackHostEnvironment : IHostEnvironment
|
||||||
|
{
|
||||||
|
public string EnvironmentName { get; set; } = Environments.Development;
|
||||||
|
|
||||||
|
public string ApplicationName { get; set; } = typeof(FallbackHostEnvironment).Assembly.GetName().Name ?? "MxGateway";
|
||||||
|
|
||||||
|
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,10 @@ public sealed class GatewayOptions
|
|||||||
|
|
||||||
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
|
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
|
||||||
public TlsOptions Tls { get; init; } = new();
|
public TlsOptions Tls { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets security hot-path options (API-key verification cache / last-used coalescing and
|
||||||
|
/// login / API-key rate limiting).
|
||||||
|
/// </summary>
|
||||||
|
public SecurityOptions Security { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,43 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
ValidateProtocol(options.Protocol, builder);
|
ValidateProtocol(options.Protocol, builder);
|
||||||
ValidateAlarms(options.Alarms, builder);
|
ValidateAlarms(options.Alarms, builder);
|
||||||
ValidateTls(options.Tls, builder);
|
ValidateTls(options.Tls, builder);
|
||||||
|
ValidateSecurity(options.Security, builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateSecurity(SecurityOptions options, ValidationBuilder builder)
|
||||||
|
{
|
||||||
|
// Cache/coalesce windows may be 0 (disables that mechanism); negatives are invalid.
|
||||||
|
AddIfNegative(
|
||||||
|
options.ApiKeyVerificationCacheSeconds,
|
||||||
|
"MxGateway:Security:ApiKeyVerificationCacheSeconds must be greater than or equal to zero (0 disables the verification cache).",
|
||||||
|
builder);
|
||||||
|
AddIfNegative(
|
||||||
|
options.ApiKeyLastUsedCoalesceSeconds,
|
||||||
|
"MxGateway:Security:ApiKeyLastUsedCoalesceSeconds must be greater than or equal to zero (0 forwards every last_used write).",
|
||||||
|
builder);
|
||||||
|
|
||||||
|
// Rate-limit knobs must be positive: a zero permit/window/limit is a misconfiguration that
|
||||||
|
// would either reject every request or divide by zero rather than express an intent.
|
||||||
|
AddIfNotPositive(
|
||||||
|
options.LoginRateLimitPermitLimit,
|
||||||
|
"MxGateway:Security:LoginRateLimitPermitLimit must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
options.LoginRateLimitWindowSeconds,
|
||||||
|
"MxGateway:Security:LoginRateLimitWindowSeconds must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
options.ApiKeyFailureLimit,
|
||||||
|
"MxGateway:Security:ApiKeyFailureLimit must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
options.ApiKeyFailureWindowSeconds,
|
||||||
|
"MxGateway:Security:ApiKeyFailureWindowSeconds must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
options.ApiKeyFailureTrackedPeers,
|
||||||
|
"MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
|
||||||
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||||
@@ -456,12 +493,41 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Path.IsPathRooted(value))
|
if (!IsRootedForAnyPlatform(value))
|
||||||
{
|
{
|
||||||
builder.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
|
||||||
|
/// not just the host running the validator. This matters on the macOS dev box, where the
|
||||||
|
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
|
||||||
|
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
|
||||||
|
/// rooting check is to reject bare filenames that resolve against the launch working directory,
|
||||||
|
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRootedForAnyPlatform(string value)
|
||||||
|
{
|
||||||
|
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
|
||||||
|
if (Path.IsPathRooted(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
|
||||||
|
if (value.Length >= 3
|
||||||
|
&& char.IsLetter(value[0])
|
||||||
|
&& value[1] == ':'
|
||||||
|
&& (value[2] == '\\' || value[2] == '/'))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows UNC path ("\\server\share") checked on a non-Windows host.
|
||||||
|
return value.StartsWith(@"\\", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Security hot-path options bound from <c>MxGateway:Security</c>. Groups the API-key verification
|
||||||
|
/// cache and <c>last_used</c> write-coalescing knobs (SEC-08) with the login and API-key
|
||||||
|
/// rate-limiting knobs (SEC-11). Defaults preserve safe, low-overhead behaviour without requiring
|
||||||
|
/// operators to configure anything.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecurityOptions
|
||||||
|
{
|
||||||
|
/// <summary>The configuration sub-section this binds from.</summary>
|
||||||
|
public const string SectionName = "MxGateway:Security";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the time-to-live, in seconds, of a cached successful API-key verification. A cache hit
|
||||||
|
/// within this window skips the per-call SQLite read (and the library verifier's coupled
|
||||||
|
/// <c>last_used</c> write). Explicit invalidation on gateway-initiated revoke/rotate is the
|
||||||
|
/// primary correctness mechanism; this short TTL is the backstop for out-of-band mutations.
|
||||||
|
/// Set to <c>0</c> to disable caching. Default is 15 seconds.
|
||||||
|
/// </summary>
|
||||||
|
public int ApiKeyVerificationCacheSeconds { get; init; } = 15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the coalescing window, in seconds, for the <c>last_used_utc</c> write. The library
|
||||||
|
/// verifier couples the write into every verification; this decorator forwards at most one
|
||||||
|
/// <c>MarkUsed</c> per key per window, so a hammered key produces at most one write per window
|
||||||
|
/// rather than one per authenticated RPC. Set to <c>0</c> to forward every write. Default is
|
||||||
|
/// 60 seconds (at most one write per key per minute).
|
||||||
|
/// </summary>
|
||||||
|
public int ApiKeyLastUsedCoalesceSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of <c>POST /auth/login</c> attempts permitted per remote IP within
|
||||||
|
/// <see cref="LoginRateLimitWindowSeconds"/> before requests are rejected with HTTP 429. Default
|
||||||
|
/// is 10.
|
||||||
|
/// </summary>
|
||||||
|
public int LoginRateLimitPermitLimit { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the fixed-window length, in seconds, for the <c>POST /auth/login</c> per-IP rate limit.
|
||||||
|
/// Default is 60 seconds.
|
||||||
|
/// </summary>
|
||||||
|
public int LoginRateLimitWindowSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of consecutive failed API-key verifications, per peer, within
|
||||||
|
/// <see cref="ApiKeyFailureWindowSeconds"/> that trips the in-process short-circuit. Once tripped,
|
||||||
|
/// the gRPC auth path rejects further attempts before the store read; a successful verification
|
||||||
|
/// resets the peer's counter. Default is 10.
|
||||||
|
/// </summary>
|
||||||
|
public int ApiKeyFailureLimit { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the sliding-window length, in seconds, over which API-key verification failures are
|
||||||
|
/// counted per peer. Default is 60 seconds.
|
||||||
|
/// </summary>
|
||||||
|
public int ApiKeyFailureWindowSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of distinct peers tracked by the API-key failure counter. The counter
|
||||||
|
/// is a bounded LRU so a spray of unique peer keys cannot grow memory without limit. Default is
|
||||||
|
/// 4096.
|
||||||
|
/// </summary>
|
||||||
|
public int ApiKeyFailureTrackedPeers { get; init; } = 4096;
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
ApiKeyAdminCommands adminCommands,
|
ApiKeyAdminCommands adminCommands,
|
||||||
IApiKeyAdminStore adminStore,
|
IApiKeyAdminStore adminStore,
|
||||||
IAuditWriter auditWriter,
|
IAuditWriter auditWriter,
|
||||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IApiKeyCacheInvalidator? cacheInvalidator = null) : IDashboardApiKeyManagementService
|
||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
private const string PepperUnavailableMarker = "pepper unavailable";
|
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||||
@@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// SEC-08: drop any cached verification for this key in THIS gateway process so the revoke
|
||||||
|
// takes effect immediately rather than after the verification-cache TTL elapses.
|
||||||
|
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||||
|
|
||||||
await WriteDashboardAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
user,
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
@@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// SEC-08: the old secret's cached verification must not outlive the rotate — evict it so
|
||||||
|
// the superseded secret stops authenticating within this process immediately.
|
||||||
|
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||||
|
|
||||||
bool succeeded = rotated.Token is not null;
|
bool succeeded = rotated.Token is not null;
|
||||||
|
|
||||||
await WriteDashboardAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
@@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// SEC-08: a deleted key is only reachable after a prior revoke, but evict defensively so no
|
||||||
|
// cached verification survives the delete.
|
||||||
|
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||||
|
|
||||||
await WriteDashboardAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
user,
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -10,6 +11,41 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
||||||
public static class DashboardEndpointRouteBuilderExtensions
|
public static class DashboardEndpointRouteBuilderExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route (SEC-11). Registered
|
||||||
|
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal const string LoginRateLimiterPolicy = "dashboard-login";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
|
||||||
|
/// Shared by the production policy registration and the tests so both exercise identical limits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpContext">The inbound request.</param>
|
||||||
|
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
|
||||||
|
/// <returns>The per-IP fixed-window partition.</returns>
|
||||||
|
internal static RateLimitPartition<string> GetLoginRateLimitPartition(
|
||||||
|
HttpContext httpContext,
|
||||||
|
SecurityOptions security)
|
||||||
|
{
|
||||||
|
string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
return RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey,
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = security.LoginRateLimitPermitLimit,
|
||||||
|
Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam: a standalone partitioned limiter over the production partition logic, so a test can
|
||||||
|
// acquire leases and assert the (permit+1)th is rejected without an HTTP server.
|
||||||
|
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
|
||||||
|
=> PartitionedRateLimiter.Create<HttpContext, string>(
|
||||||
|
ctx => GetLoginRateLimitPartition(ctx, security));
|
||||||
|
|
||||||
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
||||||
/// <param name="endpoints">The endpoint route builder.</param>
|
/// <param name="endpoints">The endpoint route builder.</param>
|
||||||
/// <returns>The route builder for chaining.</returns>
|
/// <returns>The route builder for chaining.</returns>
|
||||||
@@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
|
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
|
||||||
|
.RequireRateLimiting(LoginRateLimiterPolicy)
|
||||||
.WithName("DashboardLoginPost");
|
.WithName("DashboardLoginPost");
|
||||||
|
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// string (used by WebSocket upgrade requests that can't carry custom headers)
|
/// string (used by WebSocket upgrade requests that can't carry custom headers)
|
||||||
/// and validating it via <see cref="HubTokenService"/>.
|
/// and validating it via <see cref="HubTokenService"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carrying the token in the <c>?access_token=</c> query string is the standard SignalR pattern:
|
||||||
|
/// the WebSocket upgrade handshake cannot attach a custom <c>Authorization</c> header, so the JS
|
||||||
|
/// client appends the token to the URL. Because a query string is far easier to capture than a
|
||||||
|
/// header (proxy access logs, browser history, <c>Referer</c>), this value must NEVER be
|
||||||
|
/// request-logged. Serilog HTTP request logging is intentionally not enabled; if it is ever added,
|
||||||
|
/// scrub the <c>access_token</c> query parameter before the URL reaches a sink. The short token
|
||||||
|
/// lifetime (<see cref="HubTokenService.TokenLifetime"/>) is the backstop that bounds exposure of a
|
||||||
|
/// leaked token.
|
||||||
|
/// </remarks>
|
||||||
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
private readonly HubTokenService _tokens;
|
private readonly HubTokenService _tokens;
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
public sealed class HubTokenService
|
public sealed class HubTokenService
|
||||||
{
|
{
|
||||||
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
||||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
|
|
||||||
|
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
|
||||||
|
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||||
|
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
||||||
|
// bounds how long a stale role set survives a role change. Five minutes is transparent to
|
||||||
|
// clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect;
|
||||||
|
// see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately
|
||||||
|
// deferred until per-session hub ACLs land (SEC-25), when tokens gain session binding.
|
||||||
|
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
private readonly ITimeLimitedDataProtector _protector;
|
private readonly ITimeLimitedDataProtector _protector;
|
||||||
|
|
||||||
@@ -41,14 +49,24 @@ public sealed class HubTokenService
|
|||||||
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
||||||
/// <param name="user">The claims principal representing the user.</param>
|
/// <param name="user">The claims principal representing the user.</param>
|
||||||
/// <returns>The data-protected bearer token string.</returns>
|
/// <returns>The data-protected bearer token string.</returns>
|
||||||
public string Issue(ClaimsPrincipal user)
|
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a bearer token that expires after the supplied lifetime. Test seam so a caller can
|
||||||
|
/// mint an already-expired token deterministically without wall-clock delay; production callers
|
||||||
|
/// use <see cref="Issue(ClaimsPrincipal)"/> and get <see cref="TokenLifetime"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The claims principal representing the user.</param>
|
||||||
|
/// <param name="lifetime">The lifetime applied to the token; a non-positive value yields an already-expired token.</param>
|
||||||
|
/// <returns>The data-protected bearer token string.</returns>
|
||||||
|
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
HubTokenPayload payload = new(
|
HubTokenPayload payload = new(
|
||||||
user.Identity?.Name,
|
user.Identity?.Name,
|
||||||
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||||
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
|
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
|
||||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public static class GatewayApplication
|
|||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
// SEC-11: the rate limiter must run after routing has selected the endpoint (so its
|
||||||
|
// RequireRateLimiting policy metadata is visible) and before the endpoint executes.
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
app.MapGatewayEndpoints();
|
app.MapGatewayEndpoints();
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ public static class GatewayApplication
|
|||||||
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
||||||
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
|
AddLoginRateLimiter(builder);
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||||
"auth-store",
|
"auth-store",
|
||||||
@@ -101,6 +105,26 @@ public static class GatewayApplication
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEC-11: register the named fixed-window rate-limiter policy applied to POST /auth/login. The
|
||||||
|
// limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on
|
||||||
|
// the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition).
|
||||||
|
private static void AddLoginRateLimiter(WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
SecurityOptions security =
|
||||||
|
builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
|
||||||
|
?? new SecurityOptions();
|
||||||
|
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy(
|
||||||
|
DashboardEndpointRouteBuilderExtensions.LoginRateLimiterPolicy,
|
||||||
|
httpContext => DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition(
|
||||||
|
httpContext,
|
||||||
|
security));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
|
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
|
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
|
||||||
|
|||||||
+85
@@ -1,4 +1,6 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
@@ -6,6 +8,7 @@ using ZB.MOM.WW.Auth.ApiKeys;
|
|||||||
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
@@ -66,6 +69,34 @@ public static class AuthStoreServiceCollectionExtensions
|
|||||||
// migrator and the migration hosted service.
|
// migrator and the migration hosted service.
|
||||||
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
|
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
|
||||||
|
|
||||||
|
// SEC-08 hot-path decorators. Every gRPC call previously did a SQLite read plus a
|
||||||
|
// last_used_utc WRITE via IApiKeyVerifier.VerifyAsync (the library verifier couples the
|
||||||
|
// mark into verification). Two gateway-side decorators cut that cost without editing the
|
||||||
|
// external library:
|
||||||
|
// * CoalescingMarkApiKeyStore wraps the library's IApiKeyStore and coalesces the
|
||||||
|
// last_used write to at most one per key per MxGateway:Security window (default 60s),
|
||||||
|
// so the library verifier's per-call mark no longer churns the WAL.
|
||||||
|
// * CachingApiKeyVerifier wraps the library's IApiKeyVerifier with a short-TTL memory
|
||||||
|
// cache (MxGateway:Security:ApiKeyVerificationCacheSeconds, default 15s) keyed on a
|
||||||
|
// SHA-256 hash of the presented token, so a cache hit skips the store read entirely.
|
||||||
|
// Invalidation of a cached verification on a gateway-initiated revoke/rotate/delete is
|
||||||
|
// wired at the dashboard admin call site (DashboardApiKeyManagementService) via
|
||||||
|
// IApiKeyCacheInvalidator; the short TTL is the backstop for out-of-band mutations.
|
||||||
|
// Read the security knobs straight off IConfiguration rather than IOptions<GatewayOptions>:
|
||||||
|
// touching IOptions<GatewayOptions>.Value would force the whole-options validation pipeline
|
||||||
|
// (which needs IHostEnvironment) at auth-store resolution, breaking the bare-DI auth tests.
|
||||||
|
// GatewayOptions is still validated at startup, which covers these same values.
|
||||||
|
SecurityOptions security = configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
|
||||||
|
?? new SecurityOptions();
|
||||||
|
services.AddMemoryCache();
|
||||||
|
DecorateSingleton<IApiKeyStore>(
|
||||||
|
services,
|
||||||
|
(sp, inner) => new CoalescingMarkApiKeyStore(
|
||||||
|
inner,
|
||||||
|
security,
|
||||||
|
sp.GetService<TimeProvider>() ?? TimeProvider.System));
|
||||||
|
DecorateVerifierWithCache(services, security);
|
||||||
|
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||||
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
|
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
|
||||||
@@ -103,4 +134,58 @@ public static class AuthStoreServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the last registration of <typeparamref name="TService"/> with a singleton that wraps
|
||||||
|
/// it. The wrapped (inner) service is created once, preserving singleton semantics. Used to layer
|
||||||
|
/// the SEC-08 store decorator over the external library's registration without editing it.
|
||||||
|
/// </summary>
|
||||||
|
private static void DecorateSingleton<TService>(
|
||||||
|
IServiceCollection services,
|
||||||
|
Func<IServiceProvider, TService, TService> decorator)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
Func<IServiceProvider, TService> innerFactory = CaptureInnerFactory<TService>(services);
|
||||||
|
services.AddSingleton(sp => decorator(sp, innerFactory(sp)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps the library's IApiKeyVerifier with CachingApiKeyVerifier and exposes the same instance
|
||||||
|
// as IApiKeyCacheInvalidator so admin call sites can drop cached verifications on revoke/rotate.
|
||||||
|
private static void DecorateVerifierWithCache(IServiceCollection services, SecurityOptions security)
|
||||||
|
{
|
||||||
|
Func<IServiceProvider, IApiKeyVerifier> innerFactory = CaptureInnerFactory<IApiKeyVerifier>(services);
|
||||||
|
services.AddSingleton(sp => new CachingApiKeyVerifier(
|
||||||
|
innerFactory(sp),
|
||||||
|
sp.GetRequiredService<IMemoryCache>(),
|
||||||
|
security));
|
||||||
|
services.AddSingleton<IApiKeyVerifier>(sp => sp.GetRequiredService<CachingApiKeyVerifier>());
|
||||||
|
services.AddSingleton<IApiKeyCacheInvalidator>(sp => sp.GetRequiredService<CachingApiKeyVerifier>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes the current last registration of TService and returns a factory that materialises that
|
||||||
|
// original implementation exactly once when first resolved. The removed descriptor may be a
|
||||||
|
// type, factory or instance registration.
|
||||||
|
private static Func<IServiceProvider, TService> CaptureInnerFactory<TService>(IServiceCollection services)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
ServiceDescriptor descriptor = services.LastOrDefault(d => d.ServiceType == typeof(TService))
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No existing registration for {typeof(TService)} to decorate; register the library provider first.");
|
||||||
|
services.Remove(descriptor);
|
||||||
|
|
||||||
|
if (descriptor.ImplementationInstance is TService instance)
|
||||||
|
{
|
||||||
|
return _ => instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.ImplementationFactory is not null)
|
||||||
|
{
|
||||||
|
return sp => (TService)descriptor.ImplementationFactory(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
Type implementationType = descriptor.ImplementationType
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Registration for {typeof(TService)} has no implementation type, factory or instance to decorate.");
|
||||||
|
return sp => (TService)ActivatorUtilities.CreateInstance(sp, implementationType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="IApiKeyStore"/> decorator that coalesces <see cref="MarkUsedAsync"/> writes to at
|
||||||
|
/// most one per key per <see cref="SecurityOptions.ApiKeyLastUsedCoalesceSeconds"/> window. Lookups
|
||||||
|
/// pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The library verifier couples the <c>last_used_utc</c> write into every successful verification
|
||||||
|
/// by calling <see cref="IApiKeyStore.MarkUsedAsync"/>. On the bulk-read hot path that turns a
|
||||||
|
/// per-RPC database write into the throughput ceiling. This decorator sits under the library
|
||||||
|
/// verifier: it forwards the first mark for a key, then drops subsequent marks until the window
|
||||||
|
/// elapses, so <c>last_used_utc</c> is refreshed at most once per key per window while the read
|
||||||
|
/// path stays correct. <c>last_used_utc</c> is a coarse staleness indicator, not an audit record
|
||||||
|
/// (audit rows are written separately), so bounded staleness of up to one window is acceptable.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CoalescingMarkApiKeyStore : IApiKeyStore
|
||||||
|
{
|
||||||
|
private readonly IApiKeyStore _inner;
|
||||||
|
private readonly TimeSpan _window;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
// keyId -> UtcTicks of the last forwarded mark. Bounded by the number of API keys, which is
|
||||||
|
// small; no eviction needed.
|
||||||
|
private readonly ConcurrentDictionary<string, long> _lastMarkedTicks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="CoalescingMarkApiKeyStore"/> class.</summary>
|
||||||
|
/// <param name="inner">The wrapped store.</param>
|
||||||
|
/// <param name="security">Security options carrying the coalescing window.</param>
|
||||||
|
/// <param name="clock">The time provider.</param>
|
||||||
|
public CoalescingMarkApiKeyStore(IApiKeyStore inner, SecurityOptions security, TimeProvider clock)
|
||||||
|
: this(
|
||||||
|
inner,
|
||||||
|
TimeSpan.FromSeconds((security ?? throw new ArgumentNullException(nameof(security)))
|
||||||
|
.ApiKeyLastUsedCoalesceSeconds),
|
||||||
|
clock)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/explicit-window seam.
|
||||||
|
internal CoalescingMarkApiKeyStore(IApiKeyStore inner, TimeSpan window, TimeProvider clock)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inner);
|
||||||
|
ArgumentNullException.ThrowIfNull(clock);
|
||||||
|
_inner = inner;
|
||||||
|
_window = window;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
|
=> _inner.FindByKeyIdAsync(keyId, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
|
=> _inner.FindActiveByKeyIdAsync(keyId, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(keyId);
|
||||||
|
|
||||||
|
if (_window <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = _clock.GetUtcNow().UtcTicks;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (_lastMarkedTicks.TryGetValue(keyId, out long last))
|
||||||
|
{
|
||||||
|
if (now - last < _window.Ticks)
|
||||||
|
{
|
||||||
|
// Within the coalescing window — drop the write.
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastMarkedTicks.TryUpdate(keyId, now, last))
|
||||||
|
{
|
||||||
|
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lost the race to another writer; re-evaluate.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastMarkedTicks.TryAdd(keyId, now))
|
||||||
|
{
|
||||||
|
return _inner.MarkUsedAsync(keyId, whenUtc, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lost the race to another writer; re-evaluate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
@@ -17,6 +19,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class GatewayApiKeyIdentityMapper
|
public static class GatewayApiKeyIdentityMapper
|
||||||
{
|
{
|
||||||
|
// SEC-08: memoize the constraints deserialization keyed by the raw JSON blob so the per-call
|
||||||
|
// JSON parse on the auth hot path collapses to a dictionary lookup. Distinct blobs are bounded
|
||||||
|
// by the number of API keys (small); ApiKeyConstraints is an immutable record, so a parsed
|
||||||
|
// instance is safe to share across callers. The cap is a defensive backstop against a pathological
|
||||||
|
// spread of distinct blobs — past it, we simply parse without caching rather than grow unbounded.
|
||||||
|
private const int MaxCachedConstraintBlobs = 1024;
|
||||||
|
private static readonly ConcurrentDictionary<string, ApiKeyConstraints> ConstraintCache =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static ApiKeyConstraints DeserializeConstraints(string? constraintsJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(constraintsJson))
|
||||||
|
{
|
||||||
|
return ApiKeyConstraints.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConstraintCache.TryGetValue(constraintsJson, out ApiKeyConstraints? cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiKeyConstraints parsed = ApiKeyConstraintSerializer.Deserialize(constraintsJson);
|
||||||
|
if (ConstraintCache.Count < MaxCachedConstraintBlobs)
|
||||||
|
{
|
||||||
|
ConstraintCache.TryAdd(constraintsJson, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque
|
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque
|
||||||
/// constraints JSON into <see cref="ApiKeyConstraints"/>.
|
/// constraints JSON into <see cref="ApiKeyConstraints"/>.
|
||||||
@@ -38,6 +70,6 @@ public static class GatewayApiKeyIdentityMapper
|
|||||||
KeyPrefix: "mxgw",
|
KeyPrefix: "mxgw",
|
||||||
DisplayName: identity.DisplayName,
|
DisplayName: identity.DisplayName,
|
||||||
Scopes: identity.Scopes,
|
Scopes: identity.Scopes,
|
||||||
Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson));
|
Constraints: DeserializeConstraints(constraintsJson));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap, in-process per-peer sliding-window failure counter for the gRPC auth path (SEC-11). It is
|
||||||
|
/// checked BEFORE the API-key verification store read and short-circuits a peer that has exceeded
|
||||||
|
/// <see cref="SecurityOptions.ApiKeyFailureLimit"/> failed attempts within
|
||||||
|
/// <see cref="SecurityOptions.ApiKeyFailureWindowSeconds"/>; a successful verification resets the
|
||||||
|
/// peer's counter.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The peer key is the API key id when the presented token parses, falling back to the transport
|
||||||
|
/// peer address otherwise. Keying on key id (per the NAT caveat in <c>glauth.md</c>) means a single
|
||||||
|
/// abusive credential behind a shared NAT is throttled without locking out unrelated clients on the
|
||||||
|
/// same address.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The tracked-peer set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>) so
|
||||||
|
/// a spray of unique peer keys cannot grow memory without limit. Successful peers are removed on
|
||||||
|
/// reset, so in steady state the map holds only peers with recent failures — the common success path
|
||||||
|
/// is a lock-free dictionary miss.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ApiKeyFailureLimiter
|
||||||
|
{
|
||||||
|
private readonly int _limit;
|
||||||
|
private readonly long _windowTicks;
|
||||||
|
private readonly int _maxPeers;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, PeerState> _peers = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ApiKeyFailureLimiter"/> class.</summary>
|
||||||
|
/// <param name="security">Security options carrying the failure-limit knobs.</param>
|
||||||
|
/// <param name="clock">The time provider.</param>
|
||||||
|
public ApiKeyFailureLimiter(SecurityOptions security, TimeProvider clock)
|
||||||
|
: this(
|
||||||
|
(security ?? throw new ArgumentNullException(nameof(security))).ApiKeyFailureLimit,
|
||||||
|
TimeSpan.FromSeconds(security.ApiKeyFailureWindowSeconds),
|
||||||
|
security.ApiKeyFailureTrackedPeers,
|
||||||
|
clock)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/explicit seam.
|
||||||
|
internal ApiKeyFailureLimiter(int limit, TimeSpan window, int maxPeers, TimeProvider clock)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(clock);
|
||||||
|
_limit = limit;
|
||||||
|
_windowTicks = window.Ticks;
|
||||||
|
_maxPeers = maxPeers;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns whether the peer has reached the failure limit within the current window.</summary>
|
||||||
|
/// <param name="peer">The peer key (key id or peer address).</param>
|
||||||
|
/// <returns><see langword="true"/> when the peer should be short-circuited.</returns>
|
||||||
|
public bool IsBlocked(string peer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(peer);
|
||||||
|
if (_limit <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_peers.TryGetValue(peer, out PeerState? state))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = _clock.GetUtcNow().UtcTicks;
|
||||||
|
lock (state)
|
||||||
|
{
|
||||||
|
Prune(state, now);
|
||||||
|
return state.FailureTicks.Count >= _limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a failed verification attempt for the peer.</summary>
|
||||||
|
/// <param name="peer">The peer key (key id or peer address).</param>
|
||||||
|
public void RecordFailure(string peer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(peer);
|
||||||
|
if (_limit <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = _clock.GetUtcNow().UtcTicks;
|
||||||
|
PeerState state = _peers.GetOrAdd(peer, static _ => new PeerState());
|
||||||
|
lock (state)
|
||||||
|
{
|
||||||
|
Prune(state, now);
|
||||||
|
state.FailureTicks.Enqueue(now);
|
||||||
|
state.LastActivityTicks = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
EvictIfOverCapacity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears the peer's failure count after a successful verification.</summary>
|
||||||
|
/// <param name="peer">The peer key (key id or peer address).</param>
|
||||||
|
public void Reset(string peer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(peer);
|
||||||
|
_peers.TryRemove(peer, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Prune(PeerState state, long now)
|
||||||
|
{
|
||||||
|
while (state.FailureTicks.Count > 0 && now - state.FailureTicks.Peek() >= _windowTicks)
|
||||||
|
{
|
||||||
|
state.FailureTicks.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EvictIfOverCapacity()
|
||||||
|
{
|
||||||
|
// Best-effort eviction: only runs when the map exceeds the cap (rare, since only peers with
|
||||||
|
// recent failures are tracked). Removes the least-recently-active peer. Racy under
|
||||||
|
// concurrency, which is acceptable for a bound rather than an exact policy.
|
||||||
|
while (_peers.Count > _maxPeers)
|
||||||
|
{
|
||||||
|
string? oldest = null;
|
||||||
|
long oldestTicks = long.MaxValue;
|
||||||
|
foreach (KeyValuePair<string, PeerState> entry in _peers)
|
||||||
|
{
|
||||||
|
long activity = Volatile.Read(ref entry.Value.LastActivityTicks);
|
||||||
|
if (activity < oldestTicks)
|
||||||
|
{
|
||||||
|
oldestTicks = activity;
|
||||||
|
oldest = entry.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldest is null || !_peers.TryRemove(oldest, out _))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PeerState
|
||||||
|
{
|
||||||
|
public Queue<long> FailureTicks { get; } = new();
|
||||||
|
|
||||||
|
public long LastActivityTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-1
@@ -15,7 +15,8 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
|||||||
IApiKeyVerifier apiKeyVerifier,
|
IApiKeyVerifier apiKeyVerifier,
|
||||||
GatewayGrpcScopeResolver scopeResolver,
|
GatewayGrpcScopeResolver scopeResolver,
|
||||||
IGatewayRequestIdentityAccessor identityAccessor,
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
IOptions<GatewayOptions> options) : Interceptor
|
IOptions<GatewayOptions> options,
|
||||||
|
ApiKeyFailureLimiter failureLimiter) : Interceptor
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||||
@@ -63,6 +64,19 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
|||||||
|
|
||||||
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
||||||
|
|
||||||
|
// SEC-11: short-circuit a peer that has already failed too many times inside the sliding
|
||||||
|
// window BEFORE the verification store read, so online guessing cannot spend a SQLite read
|
||||||
|
// per attempt. The peer key prefers the presented key id over the transport address (NAT
|
||||||
|
// caveat). ResourceExhausted signals throttling without revealing whether any particular
|
||||||
|
// secret was valid.
|
||||||
|
string peerKey = ResolvePeerKey(authorizationHeader, context);
|
||||||
|
if (failureLimiter.IsBlocked(peerKey))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.ResourceExhausted,
|
||||||
|
"Too many authentication attempts. Try again later."));
|
||||||
|
}
|
||||||
|
|
||||||
// The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
|
// The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
|
||||||
// returning a discriminated failure rather than throwing. Every authentication failure maps
|
// returning a discriminated failure rather than throwing. Every authentication failure maps
|
||||||
// to Unauthenticated with an opaque message; the client never learns which stage failed.
|
// to Unauthenticated with an opaque message; the client never learns which stage failed.
|
||||||
@@ -72,11 +86,16 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
|||||||
|
|
||||||
if (!verification.Succeeded || verification.Identity is null)
|
if (!verification.Succeeded || verification.Identity is null)
|
||||||
{
|
{
|
||||||
|
failureLimiter.RecordFailure(peerKey);
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.Unauthenticated,
|
StatusCode.Unauthenticated,
|
||||||
"Missing or invalid API key."));
|
"Missing or invalid API key."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successful authentication clears the peer's failure count so a legitimate client that
|
||||||
|
// fat-fingered a few attempts is not penalised once it recovers.
|
||||||
|
failureLimiter.Reset(peerKey);
|
||||||
|
|
||||||
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
||||||
|
|
||||||
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
||||||
@@ -89,4 +108,30 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
|||||||
|
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves the failure-limiter partition key: the presented key id (token shape
|
||||||
|
// mxgw_<keyId>_<secret>) when the header parses, otherwise the transport peer address. Keying on
|
||||||
|
// key id throttles a single abusive credential without locking out co-located clients behind NAT.
|
||||||
|
private static string ResolvePeerKey(string? authorizationHeader, ServerCallContext context)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(authorizationHeader))
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||||
|
const string bearer = "Bearer ";
|
||||||
|
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? header[bearer.Length..].Trim()
|
||||||
|
: header;
|
||||||
|
|
||||||
|
// mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment. The
|
||||||
|
// secret may itself contain underscores, but the key id is unaffected.
|
||||||
|
string tokenText = token.ToString();
|
||||||
|
string[] parts = tokenText.Split('_');
|
||||||
|
if (parts.Length >= 3 && parts[0].Length > 0 && parts[1].Length > 0)
|
||||||
|
{
|
||||||
|
return "key:" + parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "peer:" + context.Peer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class GatewayGrpcScopeResolver
|
|||||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||||
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
|
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
||||||
TestConnectionRequest or
|
TestConnectionRequest or
|
||||||
GetLastDeployTimeRequest or
|
GetLastDeployTimeRequest or
|
||||||
DiscoverHierarchyRequest or
|
DiscoverHierarchyRequest or
|
||||||
|
|||||||
+7
@@ -19,6 +19,13 @@ public static class GrpcAuthorizationServiceCollectionExtensions
|
|||||||
services.AddSingleton<GatewayGrpcScopeResolver>();
|
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||||
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||||
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
||||||
|
// SEC-11 per-peer failure counter, checked before the verification store read. Bind the knobs
|
||||||
|
// from IConfiguration directly (not IOptions<GatewayOptions>) to avoid coupling this
|
||||||
|
// registration to the whole-options validation pipeline.
|
||||||
|
services.AddSingleton(sp => new ApiKeyFailureLimiter(
|
||||||
|
sp.GetRequiredService<IConfiguration>().GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
|
||||||
|
?? new SecurityOptions(),
|
||||||
|
sp.GetService<TimeProvider>() ?? TimeProvider.System));
|
||||||
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||||
services
|
services
|
||||||
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ public sealed class GatewayOptionsTests
|
|||||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
|
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
|
||||||
|
|
||||||
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
||||||
Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath);
|
// SEC-01: the default is derived from CommonApplicationData (C:\ProgramData on Windows,
|
||||||
|
// /usr/share on Unix) rather than a Windows literal, so assert against the same derivation
|
||||||
|
// to stay platform-correct.
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"MxGateway",
|
||||||
|
"gateway-auth.db"),
|
||||||
|
options.Authentication.SqlitePath);
|
||||||
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
|
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
|
||||||
Assert.True(options.Authentication.RunMigrationsOnStartup);
|
Assert.True(options.Authentication.RunMigrationsOnStartup);
|
||||||
|
|
||||||
|
|||||||
@@ -683,4 +683,92 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- SEC-11: MxGateway:Security validation ----
|
||||||
|
|
||||||
|
private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security };
|
||||||
|
|
||||||
|
/// <summary>Verifies the default security options pass validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WithDefaultSecurityOptions()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions()));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a zero verification-cache TTL is allowed (disables caching).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenVerificationCacheSecondsZero()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 }));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a negative verification-cache TTL fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenVerificationCacheSecondsNegative()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = -1 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyVerificationCacheSeconds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a negative last-used coalesce window fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLastUsedCoalesceSecondsNegative()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { ApiKeyLastUsedCoalesceSeconds = -5 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyLastUsedCoalesceSeconds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a zero login rate-limit permit fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLoginRateLimitPermitLimitZero()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { LoginRateLimitPermitLimit = 0 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitPermitLimit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a zero login rate-limit window fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLoginRateLimitWindowSecondsZero()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { LoginRateLimitWindowSeconds = 0 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitWindowSeconds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a zero API-key failure limit fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenApiKeyFailureLimitZero()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { ApiKeyFailureLimit = 0 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureLimit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a zero tracked-peer cap fails validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenApiKeyFailureTrackedPeersZero()
|
||||||
|
{
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
|
||||||
|
null,
|
||||||
|
WithSecurity(new SecurityOptions { ApiKeyFailureTrackedPeers = 0 }));
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureTrackedPeers"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEC-11: the <c>POST /auth/login</c> fixed-window per-IP rate limiter. Exercised through the same
|
||||||
|
/// partition factory the production policy registers, so the test pins the real permit limit and
|
||||||
|
/// per-IP partitioning without standing up an HTTP server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DashboardLoginRateLimitTests
|
||||||
|
{
|
||||||
|
/// <summary>A burst beyond the permit limit from one IP has its excess attempts rejected (HTTP 429 in the pipeline).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void LoginRateLimiter_BurstBeyondPermitLimit_RejectsExcessFromSameIp()
|
||||||
|
{
|
||||||
|
SecurityOptions security = new()
|
||||||
|
{
|
||||||
|
LoginRateLimitPermitLimit = 3,
|
||||||
|
LoginRateLimitWindowSeconds = 60,
|
||||||
|
};
|
||||||
|
using PartitionedRateLimiter<HttpContext> limiter =
|
||||||
|
DashboardEndpointRouteBuilderExtensions.CreateLoginRateLimiter(security);
|
||||||
|
HttpContext context = ContextWithIp("10.0.0.7");
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
using RateLimitLease lease = limiter.AttemptAcquire(context);
|
||||||
|
Assert.True(lease.IsAcquired);
|
||||||
|
}
|
||||||
|
|
||||||
|
using RateLimitLease rejected = limiter.AttemptAcquire(context);
|
||||||
|
Assert.False(rejected.IsAcquired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Distinct client IPs are partitioned independently — one IP's burst does not starve another.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void LoginRateLimiter_DistinctIps_PartitionedIndependently()
|
||||||
|
{
|
||||||
|
SecurityOptions security = new()
|
||||||
|
{
|
||||||
|
LoginRateLimitPermitLimit = 1,
|
||||||
|
LoginRateLimitWindowSeconds = 60,
|
||||||
|
};
|
||||||
|
using PartitionedRateLimiter<HttpContext> limiter =
|
||||||
|
DashboardEndpointRouteBuilderExtensions.CreateLoginRateLimiter(security);
|
||||||
|
|
||||||
|
using RateLimitLease first = limiter.AttemptAcquire(ContextWithIp("10.0.0.1"));
|
||||||
|
using RateLimitLease exhaustedForFirst = limiter.AttemptAcquire(ContextWithIp("10.0.0.1"));
|
||||||
|
using RateLimitLease second = limiter.AttemptAcquire(ContextWithIp("10.0.0.2"));
|
||||||
|
|
||||||
|
Assert.True(first.IsAcquired);
|
||||||
|
Assert.False(exhaustedForFirst.IsAcquired);
|
||||||
|
Assert.True(second.IsAcquired);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpContext ContextWithIp(string ip)
|
||||||
|
{
|
||||||
|
DefaultHttpContext context = new();
|
||||||
|
context.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,4 +115,63 @@ public sealed class HubTokenServiceTests
|
|||||||
|
|
||||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
|
||||||
|
/// validates and reconstructs the caller's identity and roles.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
||||||
|
{
|
||||||
|
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.Name, "bob"),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "bob-id"),
|
||||||
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||||
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
||||||
|
],
|
||||||
|
authenticationType: "test",
|
||||||
|
nameType: ClaimTypes.Name,
|
||||||
|
roleType: ClaimTypes.Role);
|
||||||
|
|
||||||
|
string token = service.Issue(new ClaimsPrincipal(identity));
|
||||||
|
ClaimsPrincipal? result = service.Validate(token);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("bob", result.Identity?.Name);
|
||||||
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
||||||
|
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default token lifetime is the short (5-minute) window mandated by SEC-05, not the
|
||||||
|
/// former 30-minute window. Pins the value so a regression that widens the exposure window
|
||||||
|
/// of an irrevocable, query-string-carried token is caught in CI.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TokenLifetime_IsFiveMinutes()
|
||||||
|
{
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
|
||||||
|
/// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
|
||||||
|
/// expired at mint time — deterministic, no wall-clock delay.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ExpiredToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[new Claim(ClaimTypes.Name, "carol")],
|
||||||
|
authenticationType: "test");
|
||||||
|
|
||||||
|
string expiredToken = service.Issue(
|
||||||
|
new ClaimsPrincipal(identity),
|
||||||
|
TimeSpan.FromMinutes(-1));
|
||||||
|
|
||||||
|
Assert.Null(service.Validate(expiredToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEC-08 hot-path decorators. Covers both mechanisms: <see cref="CachingApiKeyVerifier"/>
|
||||||
|
/// (read/verification coalescing plus revoke/rotate invalidation) and
|
||||||
|
/// <see cref="CoalescingMarkApiKeyStore"/> (the <c>last_used</c> write coalescing that keeps the
|
||||||
|
/// per-RPC database write off the throughput ceiling).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CachingApiKeyVerifierTests
|
||||||
|
{
|
||||||
|
private const string Header = "Bearer mxgw_operator01_super-secret";
|
||||||
|
|
||||||
|
/// <summary>A cache hit within the TTL returns the cached result and never calls the inner verifier.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_RepeatedWithinTtl_CallsInnerOnce()
|
||||||
|
{
|
||||||
|
FakeVerifier inner = new(Success("operator01"));
|
||||||
|
using MemoryCache cache = NewCache();
|
||||||
|
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
ApiKeyVerification first = await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
ApiKeyVerification second = await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(second.Succeeded);
|
||||||
|
Assert.Equal(1, inner.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Different presented secrets are cached under distinct keys (no cross-secret aliasing).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_DifferentTokens_NotAliased()
|
||||||
|
{
|
||||||
|
FakeVerifier inner = new(Success("operator01"));
|
||||||
|
using MemoryCache cache = NewCache();
|
||||||
|
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
await verifier.VerifyAsync("Bearer mxgw_operator01_secret-a", CancellationToken.None);
|
||||||
|
await verifier.VerifyAsync("Bearer mxgw_operator01_secret-b", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Failed verifications are never cached; every attempt reaches the inner verifier.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_FailedVerification_NotCached()
|
||||||
|
{
|
||||||
|
FakeVerifier inner = new(Failure(ApiKeyFailure.SecretMismatch));
|
||||||
|
using MemoryCache cache = NewCache();
|
||||||
|
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A TTL of zero disables caching: the inner verifier is called on every request.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_ZeroTtl_DisablesCache()
|
||||||
|
{
|
||||||
|
FakeVerifier inner = new(Success("operator01"));
|
||||||
|
using MemoryCache cache = NewCache();
|
||||||
|
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.Zero);
|
||||||
|
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invalidating a key id (revoke/rotate) drops its cached verification, forcing a re-verify.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalidate_DropsCachedEntry_ForcesReverify()
|
||||||
|
{
|
||||||
|
FakeVerifier inner = new(Success("operator01"));
|
||||||
|
using MemoryCache cache = NewCache();
|
||||||
|
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
Assert.Equal(1, inner.CallCount);
|
||||||
|
|
||||||
|
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
|
||||||
|
|
||||||
|
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||||
|
Assert.Equal(2, inner.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
|
||||||
|
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescingStore_RepeatedMarksWithinWindow_ForwardsOnce()
|
||||||
|
{
|
||||||
|
FakeStore inner = new();
|
||||||
|
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||||
|
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||||
|
|
||||||
|
// Ten rapid authenticated calls in the same minute.
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(1, inner.MarkUsedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>After the window elapses the next mark is forwarded again (staleness is bounded, not frozen).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescingStore_AfterWindow_ForwardsAgain()
|
||||||
|
{
|
||||||
|
FakeStore inner = new();
|
||||||
|
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||||
|
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||||
|
|
||||||
|
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.MarkUsedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Distinct keys are coalesced independently.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescingStore_DistinctKeys_TrackedSeparately()
|
||||||
|
{
|
||||||
|
FakeStore inner = new();
|
||||||
|
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||||
|
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.FromMinutes(1), clock);
|
||||||
|
|
||||||
|
await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
await store.MarkUsedAsync("key-b", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
await store.MarkUsedAsync("key-a", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.MarkUsedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A zero window disables coalescing: every mark is forwarded.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescingStore_ZeroWindow_ForwardsEveryMark()
|
||||||
|
{
|
||||||
|
FakeStore inner = new();
|
||||||
|
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
|
||||||
|
CoalescingMarkApiKeyStore store = new(inner, TimeSpan.Zero, clock);
|
||||||
|
|
||||||
|
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
await store.MarkUsedAsync("operator01", clock.GetUtcNow(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, inner.MarkUsedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemoryCache NewCache() => new(new MemoryCacheOptions());
|
||||||
|
|
||||||
|
private static ApiKeyVerification Success(string keyId) => new(
|
||||||
|
Succeeded: true,
|
||||||
|
Identity: new LibApiKeyIdentity(
|
||||||
|
KeyId: keyId,
|
||||||
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||||
|
Constraints: null),
|
||||||
|
Failure: null);
|
||||||
|
|
||||||
|
private static ApiKeyVerification Failure(ApiKeyFailure failure) =>
|
||||||
|
new(Succeeded: false, Identity: null, Failure: failure);
|
||||||
|
|
||||||
|
private sealed class FakeVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||||
|
{
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeStore : IApiKeyStore
|
||||||
|
{
|
||||||
|
public int MarkUsedCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
|
=> Task.FromResult<ApiKeyRecord?>(null);
|
||||||
|
|
||||||
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
||||||
|
=> Task.FromResult<ApiKeyRecord?>(null);
|
||||||
|
|
||||||
|
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||||
|
{
|
||||||
|
MarkUsedCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-4
@@ -327,7 +327,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => interceptor.ServerStreamingServerHandler(
|
() => interceptor.ServerStreamingServerHandler(
|
||||||
new StreamAlarmsRequest(),
|
new QueryActiveAlarmsRequest(),
|
||||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
(_, _, _) => Task.CompletedTask));
|
(_, _, _) => Task.CompletedTask));
|
||||||
@@ -347,7 +347,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
||||||
|
|
||||||
await interceptor.ServerStreamingServerHandler(
|
await interceptor.ServerStreamingServerHandler(
|
||||||
new StreamAlarmsRequest(),
|
new QueryActiveAlarmsRequest(),
|
||||||
streamWriter,
|
streamWriter,
|
||||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||||
async (_, writer, _) =>
|
async (_, writer, _) =>
|
||||||
@@ -358,6 +358,102 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
Assert.Single(streamWriter.Messages);
|
Assert.Single(streamWriter.Messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEC-11: once a peer has exceeded the failure limit, the interceptor short-circuits with
|
||||||
|
/// <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so an online guessing
|
||||||
|
/// loop stops spending a store read per attempt. A verifier that always fails is used; after the
|
||||||
|
/// limit is reached the verifier is no longer invoked.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task UnaryServerHandler_ExceedsFailureLimit_ShortCircuitsBeforeVerify()
|
||||||
|
{
|
||||||
|
CountingFailureVerifier verifier = new(Failure(ApiKeyFailure.SecretMismatch));
|
||||||
|
ApiKeyFailureLimiter limiter = new(
|
||||||
|
limit: 3,
|
||||||
|
window: TimeSpan.FromMinutes(1),
|
||||||
|
maxPeers: 16,
|
||||||
|
clock: TimeProvider.System);
|
||||||
|
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||||
|
verifier,
|
||||||
|
new GatewayRequestIdentityAccessor(),
|
||||||
|
failureLimiter: limiter);
|
||||||
|
|
||||||
|
// The first three attempts reach the verifier and fail (recording a failure each time).
|
||||||
|
for (int attempt = 0; attempt < 3; attempt++)
|
||||||
|
{
|
||||||
|
RpcException failure = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => interceptor.UnaryServerHandler(
|
||||||
|
new OpenSessionRequest(),
|
||||||
|
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||||
|
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, failure.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(3, verifier.CallCount);
|
||||||
|
|
||||||
|
// The fourth attempt is short-circuited: ResourceExhausted, and the verifier is NOT called.
|
||||||
|
RpcException throttled = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => interceptor.UnaryServerHandler(
|
||||||
|
new OpenSessionRequest(),
|
||||||
|
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||||
|
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode);
|
||||||
|
Assert.Equal(3, verifier.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEC-11: a successful verification resets the peer's failure counter, so accumulated failures
|
||||||
|
/// from a fat-fingered secret do not lock out a client that subsequently authenticates.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task UnaryServerHandler_SuccessResetsFailureCounter()
|
||||||
|
{
|
||||||
|
ApiKeyFailureLimiter limiter = new(
|
||||||
|
limit: 3,
|
||||||
|
window: TimeSpan.FromMinutes(1),
|
||||||
|
maxPeers: 16,
|
||||||
|
clock: TimeProvider.System);
|
||||||
|
|
||||||
|
// Two failures against the same key id, then a success (which resets), then two more
|
||||||
|
// failures — without the reset the fifth attempt would be blocked at the limit of 3.
|
||||||
|
GatewayGrpcAuthorizationInterceptor failing = CreateInterceptor(
|
||||||
|
new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
|
||||||
|
new GatewayRequestIdentityAccessor(),
|
||||||
|
failureLimiter: limiter);
|
||||||
|
GatewayGrpcAuthorizationInterceptor succeeding = CreateInterceptor(
|
||||||
|
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)),
|
||||||
|
new GatewayRequestIdentityAccessor(),
|
||||||
|
failureLimiter: limiter);
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => failing.UnaryServerHandler(
|
||||||
|
new OpenSessionRequest(),
|
||||||
|
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||||
|
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||||
|
}
|
||||||
|
|
||||||
|
await succeeding.UnaryServerHandler(
|
||||||
|
new OpenSessionRequest(),
|
||||||
|
ContextWithAuthorization("Bearer mxgw_operator01_good"),
|
||||||
|
(_, _) => Task.FromResult(new OpenSessionReply { SessionId = "s" }));
|
||||||
|
|
||||||
|
// Post-reset: two more failures still map to Unauthenticated (not ResourceExhausted).
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
RpcException ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => failing.UnaryServerHandler(
|
||||||
|
new OpenSessionRequest(),
|
||||||
|
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||||
|
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MxAccessGatewayService CreateService(
|
private static MxAccessGatewayService CreateService(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IGatewayRequestIdentityAccessor identityAccessor)
|
IGatewayRequestIdentityAccessor identityAccessor)
|
||||||
@@ -377,7 +473,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
||||||
IApiKeyVerifier apiKeyVerifier,
|
IApiKeyVerifier apiKeyVerifier,
|
||||||
IGatewayRequestIdentityAccessor identityAccessor,
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey)
|
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey,
|
||||||
|
ApiKeyFailureLimiter? failureLimiter = null)
|
||||||
{
|
{
|
||||||
return new GatewayGrpcAuthorizationInterceptor(
|
return new GatewayGrpcAuthorizationInterceptor(
|
||||||
apiKeyVerifier,
|
apiKeyVerifier,
|
||||||
@@ -389,7 +486,12 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
{
|
{
|
||||||
Mode = authenticationMode
|
Mode = authenticationMode
|
||||||
}
|
}
|
||||||
}));
|
}),
|
||||||
|
failureLimiter ?? new ApiKeyFailureLimiter(
|
||||||
|
limit: 1000,
|
||||||
|
window: TimeSpan.FromMinutes(1),
|
||||||
|
maxPeers: 1000,
|
||||||
|
clock: TimeProvider.System));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
|
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
|
||||||
@@ -531,6 +633,22 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class CountingFailureVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||||
|
{
|
||||||
|
/// <summary>Gets the number of times the verifier was invoked.</summary>
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Returns the configured result and counts the invocation.</summary>
|
||||||
|
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The configured verification result.</returns>
|
||||||
|
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||||
{
|
{
|
||||||
/// <summary>Gets whether the verifier was called.</summary>
|
/// <summary>Gets whether the verifier was called.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defaults the host environment to Development for the whole test assembly.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Many tests build the full gateway host through <c>GatewayApplication.Build</c> against the dev
|
||||||
|
/// <c>appsettings.json</c> (which ships <c>Ldap:Transport=None</c> and other dev-only defaults).
|
||||||
|
/// An unset environment resolves to Production, where the SEC-04/06 production guards fail startup
|
||||||
|
/// by design — so those host-building tests would trip the guards. Setting the environment to
|
||||||
|
/// Development once, before any test runs, keeps that suite exercising app wiring rather than
|
||||||
|
/// production-config validation. Tests that specifically assert production behavior construct
|
||||||
|
/// <c>GatewayOptionsValidator</c> with its <c>isProduction</c> constructor (no host environment) and
|
||||||
|
/// are unaffected; a test that needs Production can still pass <c>--environment=Production</c>, which
|
||||||
|
/// overrides this default.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TestHostEnvironmentInitializer
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void SetDevelopmentEnvironmentDefault()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")))
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user