1b3928a0a2
- 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.
200 lines
10 KiB
C#
200 lines
10 KiB
C#
using System.Text;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>POST /management</c> (and audit REST) surfaces with HTTP Basic, which run their own
|
|
/// manual Basic → LDAP check. That check is independent of the cookie scheme, so without the
|
|
/// bypass below the CLI is locked out of a login-disabled deployment whenever LDAP is not in
|
|
/// use (the bind fails and every call returns <c>401 AUTH_FAILED</c>).
|
|
/// </para>
|
|
/// <para>
|
|
/// The bypass mirrors <c>AutoLoginAuthenticationHandler</c> exactly: the configured dev user
|
|
/// (default <c>multi-role</c>) with ALL <see cref="Roles.All"/> roles, system-wide (empty
|
|
/// <see cref="AuthenticatedUser.PermittedSiteIds"/>). The startup <c>AUTH DISABLED</c> warning
|
|
/// (emitted by <c>AddSecurity</c>) already announces this posture. Dev/test ONLY.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ManagementAuthenticator
|
|
{
|
|
/// <summary>Outcome of <see cref="AuthenticateAsync"/>: exactly one of the two fields is set.</summary>
|
|
/// <param name="User">The authenticated principal on success; otherwise <see langword="null"/>.</param>
|
|
/// <param name="Failure">The 401 <see cref="IResult"/> to return on failure; otherwise <see langword="null"/>.</param>
|
|
public readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
|
|
|
|
/// <summary>
|
|
/// When <c>ScadaBridge:Security:Auth:DisableLogin</c> is true, returns the synthesized dev
|
|
/// principal (configured user, ALL roles, system-wide) that mirrors the cookie-scheme
|
|
/// <c>AutoLoginAuthenticationHandler</c> — so the Basic-Auth CLI surfaces are reachable in a
|
|
/// login-disabled (e.g. no-LDAP) deployment. Returns <see langword="null"/> when the flag is
|
|
/// off, in which case the caller must fall back to real Basic → LDAP authentication.
|
|
/// Dev/test ONLY.
|
|
/// </summary>
|
|
/// <param name="context">The current HTTP request context (used to resolve the options).</param>
|
|
/// <returns>The dev principal when login is disabled; otherwise <see langword="null"/>.</returns>
|
|
public static AuthenticatedUser? TryDisableLoginUser(HttpContext context)
|
|
{
|
|
var opts = context.RequestServices.GetService<IOptions<AuthDisableLoginOptions>>()?.Value;
|
|
if (opts is null || !opts.DisableLogin)
|
|
return null;
|
|
|
|
var user = string.IsNullOrWhiteSpace(opts.User) ? "multi-role" : opts.User;
|
|
return new AuthenticatedUser(user, user, Roles.All, Array.Empty<string>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates a management/CLI request. Honours the dev/test <c>DisableLogin</c> bypass
|
|
/// 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>
|
|
public static async Task<AuthOutcome> AuthenticateAsync(HttpContext context)
|
|
{
|
|
// Dev/test bypass: when login is disabled, the CLI surface auto-authenticates as the
|
|
// configured dev user — no credential check — mirroring the interactive cookie handler.
|
|
var bypass = TryDisableLoginUser(context);
|
|
if (bypass is not null)
|
|
return new AuthOutcome(bypass, null);
|
|
|
|
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 or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
|
}
|
|
|
|
string username, password;
|
|
try
|
|
{
|
|
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
|
|
var colon = decoded.IndexOf(':');
|
|
if (colon < 0) throw new FormatException();
|
|
username = decoded[..colon];
|
|
password = decoded[(colon + 1)..];
|
|
}
|
|
catch
|
|
{
|
|
return new AuthOutcome(null, Results.Json(
|
|
new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
return new AuthOutcome(null, Results.Json(
|
|
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
|
|
}
|
|
|
|
// 1a. Login throttle: refuse a bind while this username+IP key is locked out
|
|
// (fixed-window password-spray guard, arch-review P1/UA5). Consulted BELOW the
|
|
// DisableLogin bypass — a login-disabled deployment never binds, so it never throttles.
|
|
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
|
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
|
if (throttle.IsLockedOut(username, remoteIp!))
|
|
{
|
|
return new AuthOutcome(null, Results.Json(
|
|
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
|
statusCode: 429));
|
|
}
|
|
|
|
// 2. LDAP authentication
|
|
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
|
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
|
if (!authResult.Succeeded)
|
|
{
|
|
throttle.RecordFailure(username, remoteIp!);
|
|
return new AuthOutcome(null, Results.Json(
|
|
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
|
|
}
|
|
|
|
throttle.RecordSuccess(username, remoteIp!);
|
|
|
|
// 3. Role resolution
|
|
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
|
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
|
|
|
|
var permittedSiteIds = mappingResult.IsSystemWideDeployment
|
|
? Array.Empty<string>()
|
|
: mappingResult.PermittedSiteIds.ToArray();
|
|
|
|
var resolvedUser = new AuthenticatedUser(
|
|
authResult.Username,
|
|
authResult.DisplayName,
|
|
mappingResult.Roles.ToArray(),
|
|
permittedSiteIds);
|
|
|
|
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);
|
|
}
|
|
}
|