feat(opcua,host): F13c LDAP-bound UserName validator
Adds IOpcUaUserAuthenticator seam in OpcUaServer.Security with a deny-all NullOpcUaUserAuthenticator default. OpcUaApplicationHost subscribes to SessionManager.ImpersonateUser after _application.Start so UserName tokens flow through the authenticator and either attach a UserIdentity to the session (Allow) or set IdentityValidationError = BadIdentityTokenRejected (Deny / authenticator exception). Anonymous + X509 tokens fall through to SDK defaults. LdapOpcUaUserAuthenticator (Host project) bridges to the same ILdapAuthService that AddOtOpcUaAuth uses for Admin cookies / JWT, so a single LDAP source-of-truth governs both Admin control plane and OPC UA data plane. Program.cs registers LdapOptions + LdapAuthService + IOpcUaUserAuthenticator on driver-role hosts; admin-only nodes are unchanged. OtOpcUaServerHostedService threads the resolved authenticator into OpcUaApplicationHost so the seam respects Host DI. 10 new tests: 6 in OpcUaServer.Tests cover the pure HandleImpersonation static method (success / denial / anonymous fallthrough / authenticator- throw / null-username / Null authenticator); 4 in Host.IntegrationTests cover the LdapOpcUaUserAuthenticator adapter (LDAP allow → Allow with roles, LDAP deny → Deny, exception → backend-error denial, display-name fallback). OpcUaServer suite is 40 / 40 green. Closes #104. Unblocks Task 60 (dual-endpoint + ServiceLevel tests) once #81 residual lands.
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -77,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
|
||||
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
private readonly OpcUaApplicationHostOptions _options;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
private StandardServer? _server;
|
||||
private ImpersonateEventHandler? _impersonateHandler;
|
||||
|
||||
public OpcUaApplicationHost(
|
||||
OpcUaApplicationHostOptions options,
|
||||
ILogger<OpcUaApplicationHost> logger)
|
||||
ILogger<OpcUaApplicationHost> logger,
|
||||
IOpcUaUserAuthenticator? userAuthenticator = null)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
||||
}
|
||||
|
||||
public ApplicationInstance? ApplicationInstance => _application;
|
||||
@@ -106,10 +111,99 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _application.Start(server).ConfigureAwait(false);
|
||||
|
||||
AttachUserAuthenticator();
|
||||
|
||||
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
||||
_options.PublicHostname, _options.OpcUaPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
|
||||
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
|
||||
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
|
||||
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
|
||||
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
|
||||
///
|
||||
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
|
||||
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
|
||||
/// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
|
||||
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
|
||||
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
|
||||
/// </summary>
|
||||
private void AttachUserAuthenticator()
|
||||
{
|
||||
var sessionManager = _server?.CurrentInstance?.SessionManager;
|
||||
if (sessionManager is null)
|
||||
{
|
||||
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_impersonateHandler = OnImpersonateUser;
|
||||
sessionManager.ImpersonateUser += _impersonateHandler;
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
|
||||
/// and logging.
|
||||
/// </summary>
|
||||
internal static void HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger)
|
||||
{
|
||||
if (args.NewIdentity is not UserNameIdentityToken token)
|
||||
{
|
||||
// Anonymous + X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
}
|
||||
|
||||
string password;
|
||||
try
|
||||
{
|
||||
password = token.DecryptedPassword ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"UserName token decryption failed");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
result = authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
|
||||
token.UserName, result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Invalid credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new UserIdentity(token);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
|
||||
token.UserName, string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
||||
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
|
||||
@@ -253,6 +347,13 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
{
|
||||
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
|
||||
}
|
||||
_impersonateHandler = null;
|
||||
|
||||
try { _application?.Stop(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user