169 lines
8.5 KiB
C#
169 lines
8.5 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
|
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
|
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
|
/// session identities. Roles are resolved through the shared
|
|
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
|
|
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
|
|
/// session identity for the downstream data-plane ACL evaluator.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
|
|
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
|
|
/// resolve the mapper so the singleton never captures a scoped dependency.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Resilience (arch-review 03/S2).</b> The OPC UA SDK raises the impersonation callback that
|
|
/// reaches here <em>synchronously</em>, inside a <c>SessionManager</c>-wide event lock (verified
|
|
/// against <c>OPCFoundation.NetStandard.Opc.Ua.Server</c> 1.5.378 —
|
|
/// <c>SessionManager.ActivateSessionAsync</c> holds <c>m_eventLock</c> while invoking the
|
|
/// <c>void</c> impersonation delegate). A slow / hung LDAP bind inside that callback therefore does
|
|
/// not merely stall one SDK thread — it serializes <em>every</em> session activation server-wide
|
|
/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no
|
|
/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow
|
|
/// wall-clock bound (<see cref="LdapOptions.AuthTimeoutMs"/>) and a bounded in-flight concurrency cap
|
|
/// (<see cref="LdapOptions.MaxConcurrentAuthentications"/>), both fail-closed. All new paths deny.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
private readonly ILdapAuthService _ldap;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<LdapOpcUaUserAuthenticator> _logger;
|
|
private readonly LdapOptions _options;
|
|
|
|
/// <summary>Bounds in-flight authentication flows. A boundary timeout abandons its core task, which
|
|
/// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded
|
|
/// orphan binds.</summary>
|
|
private readonly SemaphoreSlim _gate;
|
|
|
|
/// <summary>Constructs the authenticator from its dependencies and the bound LDAP options.</summary>
|
|
/// <param name="ldap">The app LDAP auth service (shared with the Admin UI login flow).</param>
|
|
/// <param name="scopeFactory">Opens a per-call DI scope to resolve the scoped role mapper.</param>
|
|
/// <param name="options">The bound <see cref="LdapOptions"/> (timeout + concurrency knobs).</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public LdapOpcUaUserAuthenticator(
|
|
ILdapAuthService ldap,
|
|
IServiceScopeFactory scopeFactory,
|
|
IOptions<LdapOptions> options,
|
|
ILogger<LdapOpcUaUserAuthenticator> logger)
|
|
{
|
|
_ldap = ldap;
|
|
_scopeFactory = scopeFactory;
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
_gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs));
|
|
|
|
// Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots
|
|
// busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed).
|
|
if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false))
|
|
{
|
|
_logger.LogWarning(
|
|
"LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}",
|
|
_options.MaxConcurrentAuthentications, username);
|
|
return OpcUaUserAuthResult.Deny("Authentication backend busy");
|
|
}
|
|
|
|
// Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary
|
|
// timeout below abandons the core task (it runs to completion in the background; the underlying
|
|
// library is documented never-throw, so there are no unobserved exceptions), and it must keep
|
|
// counting against the cap until it finishes. The point of the boundary is to release the SDK
|
|
// thread (and m_eventLock), not to cancel the bind.
|
|
var core = AuthenticateCoreAsync(username, password, ct);
|
|
_ = core.ContinueWith(
|
|
static (_, state) => ((SemaphoreSlim)state!).Release(),
|
|
_gate,
|
|
CancellationToken.None,
|
|
TaskContinuationOptions.None,
|
|
TaskScheduler.Default);
|
|
|
|
try
|
|
{
|
|
return await core.WaitAsync(authTimeout, ct).ConfigureAwait(false);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
_logger.LogWarning(
|
|
"LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)",
|
|
authTimeout, username);
|
|
return OpcUaUserAuthResult.Deny("Authentication timed out");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The unbounded core authenticate flow (bind + role-map) that the outer
|
|
/// <see cref="AuthenticateUserNameAsync"/> wraps in a wall-clock bound + concurrency cap. Fail-closed:
|
|
/// any backend throw becomes a denial rather than escaping into the SDK.
|
|
/// </summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The cleartext password.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
private async Task<OpcUaUserAuthResult> AuthenticateCoreAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
|
if (!result.Success)
|
|
{
|
|
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
|
}
|
|
|
|
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
|
|
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
|
|
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the user's roles from their LDAP groups via the scoped
|
|
/// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
|
|
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
|
|
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
|
|
/// </summary>
|
|
/// <param name="groups">The LDAP groups returned by the directory.</param>
|
|
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
|
|
/// <param name="username">The login name, for diagnostics.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
|
|
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
|
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
|
|
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
|
|
|
|
var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var role in mapping.Roles)
|
|
roles.Add(role);
|
|
return [.. roles];
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
|
|
return preResolved;
|
|
}
|
|
}
|
|
}
|