Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/SecurityConfigurationTests.cs
Joseph Doherty 22d3b0d23c Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile. Replaces the anonymous-only endpoint with a configurable security profile and an LDAP-backed UserName token validator. New IUserAuthenticator abstraction in Backend/Security/: LdapUserAuthenticator binds to the configured directory (reuses the pattern from Admin.Security.LdapAuthService without the cross-app dependency — Novell.Directory.Ldap.NETStandard 3.6.0 package ref added to Server alongside the existing OPCFoundation packages) and maps group membership to OPC UA roles via LdapOptions.GroupToRole (case-insensitive). DenyAllUserAuthenticator is the default when Ldap.Enabled=false so UserName token attempts return a clean BadUserAccessDenied rather than hanging on a localhost:3893 bind attempt. OpcUaSecurityProfile enum + LdapOptions nested record on OpcUaServerOptions. Profile=None keeps the PR 17 shape (SecurityPolicies.None + Anonymous token only) so existing integration tests stay green; Profile=Basic256Sha256SignAndEncrypt adds a second ServerSecurityPolicy (Basic256Sha256 + SignAndEncrypt) to the collection and, when Ldap.Enabled=true, adds a UserName token policy scoped to SecurityPolicies.Basic256Sha256 only — passwords must ride an encrypted channel, the stack rejects UserName over None. OtOpcUaServer.OnServerStarted hooks SessionManager.ImpersonateUser: AnonymousIdentityToken passes through; UserNameIdentityToken delegates to IUserAuthenticator.AuthenticateAsync — rejected identities throw ServiceResultException(BadUserAccessDenied); accepted identities get a RoleBasedIdentity that carries the resolved roles through session.Identity so future PRs can gate writes by role. OpcUaApplicationHost + OtOpcUaServer constructors take IUserAuthenticator as a dependency. Program.cs binds the new OpcUaServer:Ldap section from appsettings (Enabled defaults false, GroupToRole parsed as Dictionary<string,string>), registers IUserAuthenticator as LdapUserAuthenticator when enabled or DenyAllUserAuthenticator otherwise. PR 17 integration test updated to pass DenyAllUserAuthenticator so it keeps exercising the anonymous-only path unchanged. Tests — SecurityConfigurationTests (new, 13 cases): DenyAllAuthenticator rejects every credential; LdapAuthenticator rejects blank creds without hitting the server; rejects when Enabled=false; rejects plaintext when both UseTls=false AND AllowInsecureLdap=false (safety guard matching the Admin service); EscapeLdapFilter theory (4 rows: plain passthrough, parens/asterisk/backslash → hex escape) — regression guard against LDAP injection; ExtractOuSegment theory (3 rows: finds ou=, returns null when absent, handles multiple ou segments by returning first); ExtractFirstRdnValue theory (3 rows: strips cn= prefix, handles single-segment DN, returns plain string unchanged when no =). OpcUaServerOptions_default_is_anonymous_only asserts the default posture preserves PR 17 behavior. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Server.Tests') added to Server csproj so ExtractOuSegment and siblings are reachable from the tests. Full solution: 0 errors, 180 tests pass (8 Core + 14 Proxy + 24 Configuration + 6 Shared + 91 Galaxy.Host + 19 Server (17 unit + 2 integration) + 18 Admin). Live-LDAP integration test (connect via Basic256Sha256 endpoint with a real user from GLAuth, assert the session.Identity carries the mapped role) is deferred to a follow-up — it requires the GLAuth dev instance to be running at localhost:3893 which is dev-machine-specific, and the test harness for that also needs a fresh client-side certificate provisioned by the live server's trusted store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:49:46 -04:00

89 lines
3.3 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class SecurityConfigurationTests
{
[Fact]
public async Task DenyAllAuthenticator_rejects_every_credential()
{
var auth = new DenyAllUserAuthenticator();
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("not supported");
}
[Fact]
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
{
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
empty.Success.ShouldBeFalse();
empty.Error.ShouldContain("Credentials");
}
[Fact]
public async Task LdapAuthenticator_rejects_when_disabled()
{
var options = new LdapOptions { Enabled = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("disabled");
}
[Fact]
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
{
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("Insecure");
}
[Theory]
[InlineData("hello", "hello")]
[InlineData("hi(there)", "hi\\28there\\29")]
[InlineData("name*", "name\\2a")]
[InlineData("a\\b", "a\\5cb")]
public void LdapFilter_escapes_reserved_characters(string input, string expected)
{
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
}
[Theory]
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
[InlineData("cn=bob,dc=example,dc=com", null)]
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
{
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
}
[Theory]
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
[InlineData("cn=LoneValue", "LoneValue")]
[InlineData("plain-no-equals", "plain-no-equals")]
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
{
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
}
[Fact]
public void OpcUaServerOptions_default_is_anonymous_only()
{
var opts = new OpcUaServerOptions();
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
opts.Ldap.Enabled.ShouldBeFalse();
}
}