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,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