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:
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
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 flow through unchanged — the data-plane ACL evaluator reads
|
||||||
|
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LdapOpcUaUserAuthenticator(
|
||||||
|
ILdapAuthService ldap,
|
||||||
|
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||||
|
: IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(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");
|
||||||
|
}
|
||||||
|
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||||
|
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||||
|
|
||||||
@@ -30,10 +32,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
public OtOpcUaServerHostedService(
|
public OtOpcUaServerHostedService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
DeferredAddressSpaceSink deferredSink,
|
DeferredAddressSpaceSink deferredSink,
|
||||||
|
IOpcUaUserAuthenticator userAuthenticator,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_deferredSink = deferredSink;
|
_deferredSink = deferredSink;
|
||||||
|
_userAuthenticator = userAuthenticator;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||||
}
|
}
|
||||||
@@ -44,7 +48,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
_configuration.GetSection("OpcUa").Bind(options);
|
_configuration.GetSection("OpcUa").Bind(options);
|
||||||
|
|
||||||
_server = new OtOpcUaSdkServer();
|
_server = new OtOpcUaSdkServer();
|
||||||
_appHost = new OpcUaApplicationHost(options, _loggerFactory.CreateLogger<OpcUaApplicationHost>());
|
_appHost = new OpcUaApplicationHost(
|
||||||
|
options,
|
||||||
|
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
|
||||||
|
_userAuthenticator);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ using ZB.MOM.WW.OtOpcUa.Host;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security;
|
using ZB.MOM.WW.OtOpcUa.Security;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||||
@@ -56,6 +58,15 @@ if (hasDriver)
|
|||||||
builder.Services.AddSingleton<DeferredAddressSpaceSink>();
|
builder.Services.AddSingleton<DeferredAddressSpaceSink>();
|
||||||
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
|
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
|
||||||
sp.GetRequiredService<DeferredAddressSpaceSink>());
|
sp.GetRequiredService<DeferredAddressSpaceSink>());
|
||||||
|
|
||||||
|
// F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
|
||||||
|
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
|
||||||
|
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
|
||||||
|
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
|
||||||
|
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||||
|
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
@@ -77,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
|
|||||||
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly OpcUaApplicationHostOptions _options;
|
private readonly OpcUaApplicationHostOptions _options;
|
||||||
|
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||||
private ApplicationInstance? _application;
|
private ApplicationInstance? _application;
|
||||||
private StandardServer? _server;
|
private StandardServer? _server;
|
||||||
|
private ImpersonateEventHandler? _impersonateHandler;
|
||||||
|
|
||||||
public OpcUaApplicationHost(
|
public OpcUaApplicationHost(
|
||||||
OpcUaApplicationHostOptions options,
|
OpcUaApplicationHostOptions options,
|
||||||
ILogger<OpcUaApplicationHost> logger)
|
ILogger<OpcUaApplicationHost> logger,
|
||||||
|
IOpcUaUserAuthenticator? userAuthenticator = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplicationInstance? ApplicationInstance => _application;
|
public ApplicationInstance? ApplicationInstance => _application;
|
||||||
@@ -106,10 +111,99 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await _application.Start(server).ConfigureAwait(false);
|
await _application.Start(server).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AttachUserAuthenticator();
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
||||||
_options.PublicHostname, _options.OpcUaPort);
|
_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>
|
/// <summary>
|
||||||
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
/// 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
|
/// 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()
|
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(); }
|
try { _application?.Stop(); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
|
||||||
|
/// application cert) and hands the cleartext username + password to this seam. Implementations
|
||||||
|
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
|
||||||
|
///
|
||||||
|
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
|
||||||
|
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
|
||||||
|
/// dev nodes don't silently accept credentials.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves cleartext UserName credentials against the configured backing store. Must not
|
||||||
|
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
|
||||||
|
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
|
||||||
|
/// path where it surfaces as a generic <c>BadInternalError</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||||
|
public sealed record OpcUaUserAuthResult(
|
||||||
|
bool Success,
|
||||||
|
string? DisplayName,
|
||||||
|
IReadOnlyList<string> Roles,
|
||||||
|
string? Error)
|
||||||
|
{
|
||||||
|
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
|
||||||
|
new(true, displayName, roles, null);
|
||||||
|
|
||||||
|
public static OpcUaUserAuthResult Deny(string error) =>
|
||||||
|
new(false, null, Array.Empty<string>(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
|
||||||
|
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
|
||||||
|
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
public static readonly NullOpcUaUserAuthenticator Instance = new();
|
||||||
|
private NullOpcUaUserAuthenticator() { }
|
||||||
|
|
||||||
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||||
|
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
||||||
|
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
||||||
|
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
||||||
|
{
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.DisplayName.ShouldBe("Alice");
|
||||||
|
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||||
|
{
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe("Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||||
|
{
|
||||||
|
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error.ShouldContain("backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||||
|
{
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.DisplayName.ShouldBe("alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeLdap : ILdapAuthService
|
||||||
|
{
|
||||||
|
private readonly Func<string, LdapAuthResult> _handler;
|
||||||
|
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
|
||||||
|
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
|
||||||
|
|
||||||
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_handler(username));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Server;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F13c — verifies the impersonation handler routes UserName tokens through
|
||||||
|
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
|
||||||
|
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcUaApplicationHostImpersonationTests
|
||||||
|
{
|
||||||
|
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
|
||||||
|
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
|
||||||
|
{
|
||||||
|
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = "secret" };
|
||||||
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||||
|
var authenticator = new RecordingAuthenticator(
|
||||||
|
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
|
||||||
|
|
||||||
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||||
|
|
||||||
|
args.Identity.ShouldNotBeNull();
|
||||||
|
args.IdentityValidationError.ShouldBeNull();
|
||||||
|
authenticator.LastUsername.ShouldBe("alice");
|
||||||
|
authenticator.LastPassword.ShouldBe("secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
|
||||||
|
{
|
||||||
|
var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = "wrong" };
|
||||||
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||||
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
|
||||||
|
|
||||||
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||||
|
|
||||||
|
args.Identity.ShouldBeNull();
|
||||||
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||||
|
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
|
||||||
|
{
|
||||||
|
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||||
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||||
|
|
||||||
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||||
|
|
||||||
|
// Handler leaves anonymous tokens untouched — no identity, no validation error.
|
||||||
|
args.Identity.ShouldBeNull();
|
||||||
|
args.IdentityValidationError.ShouldBeNull();
|
||||||
|
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
|
||||||
|
{
|
||||||
|
var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = "x" };
|
||||||
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||||
|
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
|
||||||
|
|
||||||
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||||
|
|
||||||
|
args.Identity.ShouldBeNull();
|
||||||
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleImpersonation_null_username_treated_as_empty_string()
|
||||||
|
{
|
||||||
|
var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = "abc" };
|
||||||
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||||
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
|
||||||
|
|
||||||
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||||
|
|
||||||
|
authenticator.LastUsername.ShouldBe(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NullOpcUaUserAuthenticator_always_denies()
|
||||||
|
{
|
||||||
|
var result = await NullOpcUaUserAuthenticator.Instance
|
||||||
|
.AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Roles.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
public string? LastUsername { get; private set; }
|
||||||
|
public string? LastPassword { get; private set; }
|
||||||
|
|
||||||
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastUsername = username;
|
||||||
|
LastPassword = password;
|
||||||
|
return Task.FromResult(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
|
||||||
|
{
|
||||||
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||||
|
=> Task.FromException<OpcUaUserAuthResult>(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user