fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
This commit is contained in:
@@ -10,13 +10,24 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces
|
||||
/// (<see cref="ManagementEndpoints"/>, <see cref="AuditEndpoints"/>, and the debug-stream hub).
|
||||
/// Centralising the flow here means the dev/test <c>DisableLogin</c> bypass is applied
|
||||
/// <em>identically</em> on every CLI-facing surface.
|
||||
/// Shared authentication for the management/CLI surfaces (<see cref="ManagementEndpoints"/>,
|
||||
/// <see cref="AuditEndpoints"/>, and the debug-stream hub). Accepts either
|
||||
/// <c>Authorization: Bearer <jwt></c> (a token minted by <c>POST /auth/token</c>) or
|
||||
/// HTTP Basic (an LDAP bind per request). Centralising the flow here means the dev/test
|
||||
/// <c>DisableLogin</c> bypass and both schemes are applied <em>identically</em> on every
|
||||
/// CLI-facing surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bearer</b> (review 2026-10-21 05/U1, 05/P1): the token is validated by
|
||||
/// <see cref="JwtTokenService.ValidateToken"/> — HS256 signature with the shared
|
||||
/// <c>JwtSigningKey</c>, issuer, audience and lifetime (small clock skew). The username, roles
|
||||
/// and permitted site ids inside a valid token are authoritative for its lifetime: a bearer
|
||||
/// request performs no LDAP bind and no role re-map, mirroring "LDAP failure: active sessions
|
||||
/// continue with current roles". An invalid token is a plain 401 — there is no LDAP bind to
|
||||
/// throttle, so it does not feed <see cref="LoginThrottle"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ScadaBridge:Security:Auth:DisableLogin</c> swaps the <em>cookie</em> authentication
|
||||
/// scheme for <c>AutoLoginAuthenticationHandler</c>, which lets the interactive browser UI
|
||||
/// in without credentials. The CLI does not use cookies — it authenticates the
|
||||
@@ -61,9 +72,10 @@ public static class ManagementAuthenticator
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a management/CLI request. Honours the dev/test <c>DisableLogin</c> bypass
|
||||
/// first (<see cref="TryDisableLoginUser"/>); otherwise decodes HTTP Basic Auth, binds
|
||||
/// against LDAP, and resolves roles. Returns a populated <see cref="AuthenticatedUser"/> on
|
||||
/// success, or an <see cref="IResult"/> carrying the 401 response on any failure.
|
||||
/// first (<see cref="TryDisableLoginUser"/>); otherwise accepts a bearer JWT (validated
|
||||
/// locally, no LDAP) or decodes HTTP Basic Auth, binds against LDAP, and resolves roles.
|
||||
/// Returns a populated <see cref="AuthenticatedUser"/> on success, or an
|
||||
/// <see cref="IResult"/> carrying the 401 (or 429 throttle) response on any failure.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP request to authenticate.</param>
|
||||
/// <returns>An <see cref="AuthOutcome"/> carrying either the user or the 401 failure result.</returns>
|
||||
@@ -75,12 +87,17 @@ public static class ManagementAuthenticator
|
||||
if (bypass is not null)
|
||||
return new AuthOutcome(bypass, null);
|
||||
|
||||
// 1. Decode Basic Auth
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
|
||||
// 0. Bearer token minted by POST /auth/token — validated locally, no LDAP bind.
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateBearer(context, authHeader["Bearer ".Length..].Trim());
|
||||
|
||||
// 1. Decode Basic Auth
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
new { error = "Authorization header required (Basic or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
string username, password;
|
||||
@@ -144,4 +161,39 @@ public static class ManagementAuthenticator
|
||||
|
||||
return new AuthOutcome(resolvedUser, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bearer JWT and projects its claims onto an <see cref="AuthenticatedUser"/>.
|
||||
/// The token's roles and site scope are taken as-is (authoritative until expiry); an
|
||||
/// empty site-id set is the same system-wide signal the Basic path produces.
|
||||
/// </summary>
|
||||
private static AuthOutcome AuthenticateBearer(HttpContext context, string token)
|
||||
{
|
||||
var jwt = context.RequestServices.GetService<JwtTokenService>();
|
||||
var principal = jwt?.ValidateToken(token);
|
||||
var username = principal?.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
||||
if (principal is null || string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Invalid or expired bearer token.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
var roles = principal.FindAll(JwtTokenService.RoleClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new AuthOutcome(
|
||||
new AuthenticatedUser(
|
||||
username,
|
||||
string.IsNullOrWhiteSpace(displayName) ? username : displayName,
|
||||
roles,
|
||||
permittedSiteIds),
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user