using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Metrics; namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; /// Names shared by the optional /metrics authentication (MxGateway:Metrics:RequireAuthentication). public static class MetricsAuthorizationDefaults { /// Authentication scheme that authenticates a metrics scrape. public const string AuthenticationScheme = "MxGateway.Metrics"; /// Authorization policy attached to /metrics when authentication is required. public const string Policy = "MxGateway.Metrics"; /// Claim type carrying one API-key scope on a principal the metrics scheme authenticated. public const string ScopeClaimType = "mxgateway:scope"; } /// /// Authenticates a /metrics scrape from either credential the gateway already issues: an API /// key presented as Authorization: Bearer mxgw_… (verified through the same cached /// and per-peer failure limiter as gRPC calls), or, when no /// authorization header is sent, the dashboard login cookie. /// /// /// One scheme fronting both credentials keeps the challenge a plain 401 with /// WWW-Authenticate: Bearer, which is what a Prometheus scraper understands; listing the /// cookie scheme on the policy directly would turn an anonymous scrape into a 302 redirect to the /// login page. A throttled key partition answers 429, mirroring the gRPC /// ResourceExhausted. Failure reasons are never echoed to the caller and the presented /// secret is never logged. The limiter's per-peer partition here is the remote IP, whereas the gRPC /// interceptor's is the gRPC peer string (address and port, i.e. per connection), so the two paths /// share only the per-key-id aggregate layer. /// public sealed class MetricsAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IApiKeyVerifier apiKeyVerifier, ApiKeyFailureLimiter failureLimiter, IAuthenticationSchemeProvider schemeProvider, IOptions gatewayOptions, GatewayMetrics metrics) : AuthenticationHandler(options, logger, encoder) { private bool _throttled; /// protected override async Task HandleAuthenticateAsync() { string? authorizationHeader = Request.Headers.Authorization; if (string.IsNullOrWhiteSpace(authorizationHeader)) { return await AuthenticateDashboardCookieAsync().ConfigureAwait(false); } // Same throttle as the gRPC interceptor: checked before the store read so online guessing // against /metrics cannot spend a verification per attempt or bypass the gRPC limiter. ApiKeyThrottlePartition partition = new( Context.Connection.RemoteIpAddress?.ToString() ?? "unknown", GatewayGrpcAuthorizationInterceptor.TryResolveKeyId(authorizationHeader)); ApiKeyThrottleDecision decision = failureLimiter.Check( partition, out ApiKeyFailureLimiter.PartitionResolution resolution); if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate) { _throttled = true; metrics.RecordAuthThrottled(decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate"); return AuthenticateResult.Fail("Too many authentication attempts."); } ApiKeyVerification verification = await apiKeyVerifier .VerifyAsync(authorizationHeader, Context.RequestAborted) .ConfigureAwait(false); if (!verification.Succeeded || verification.Identity is null) { failureLimiter.RecordFailure(partition); return AuthenticateResult.Fail("Missing or invalid API key."); } failureLimiter.Reset(partition, resolution); List claims = [new Claim(ClaimTypes.Name, verification.Identity.KeyId)]; foreach (string scope in verification.Identity.Scopes) { claims.Add(new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope)); } ClaimsPrincipal principal = new(new ClaimsIdentity(claims, Scheme.Name)); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); } /// protected override Task HandleChallengeAsync(AuthenticationProperties properties) { if (_throttled) { Response.StatusCode = StatusCodes.Status429TooManyRequests; return Task.CompletedTask; } Response.StatusCode = StatusCodes.Status401Unauthorized; Response.Headers.WWWAuthenticate = "Bearer"; return Task.CompletedTask; } // No authorization header: fall back to the dashboard login cookie, so an operator already // signed in to the dashboard can open /metrics in the same browser. private async Task AuthenticateDashboardCookieAsync() { // Dashboard:DisableLogin registers an always-succeeding auto-login handler under the cookie // scheme name. Falling back to it would let every anonymous request satisfy /metrics, so // with DisableLogin on, RequireAuthentication=true accepts API keys only. if (gatewayOptions.Value.Dashboard.DisableLogin) { return AuthenticateResult.NoResult(); } if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false) is null) { return AuthenticateResult.NoResult(); } AuthenticateResult cookie = await Context .AuthenticateAsync(DashboardAuthenticationDefaults.AuthenticationScheme) .ConfigureAwait(false); return cookie.Succeeded && cookie.Principal is not null ? AuthenticateResult.Success(new AuthenticationTicket(cookie.Principal, Scheme.Name)) : AuthenticateResult.NoResult(); } } /// Requirement for /metrics access; see . public sealed class MetricsAccessRequirement : IAuthorizationRequirement { } /// /// Grants /metrics to an API key carrying the admin scope or to a dashboard login /// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither /// anonymous localhost nor Authentication:Mode=Disabled can satisfy it. /// /// /// A Viewer login qualifies because every dashboard role already sees the same aggregate counters /// on the dashboard itself; the metrics carry no per-session or per-tag data. An API key needs /// admin because metrics are an operator surface, not one of the data-plane scopes a client /// key is normally issued with. /// public sealed class MetricsAccessHandler : AuthorizationHandler { /// protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, MetricsAccessRequirement requirement) { foreach (ClaimsIdentity identity in context.User.Identities) { if (!identity.IsAuthenticated) { continue; } // Scope claims count only on an identity the metrics scheme minted from a verified key. bool isApiKeyIdentity = string.Equals( identity.AuthenticationType, MetricsAuthorizationDefaults.AuthenticationScheme, StringComparison.Ordinal); if (isApiKeyIdentity && identity.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)) { context.Succeed(requirement); return Task.CompletedTask; } if (!isApiKeyIdentity && (identity.HasClaim(identity.RoleClaimType, DashboardRoles.Admin) || identity.HasClaim(identity.RoleClaimType, DashboardRoles.Viewer))) { context.Succeed(requirement); return Task.CompletedTask; } } return Task.CompletedTask; } } /// Registration for the optional /metrics authentication. public static class MetricsAuthorizationServiceCollectionExtensions { /// /// Registers the metrics authentication scheme, the /// policy and its handler. Registration is unconditional and inert: the policy only takes effect /// when MxGateway:Metrics:RequireAuthentication attaches it to the endpoint. /// /// Service collection to register into. /// The same service collection, for chaining. public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services) { services.AddAuthentication() .AddScheme( MetricsAuthorizationDefaults.AuthenticationScheme, _ => { }); services.AddAuthorizationBuilder() .AddPolicy( MetricsAuthorizationDefaults.Policy, policy => policy .AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme) .AddRequirements(new MetricsAccessRequirement())); services.AddSingleton(); return services; } }