diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index 2f9fc1c..7616012 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -3,6 +3,7 @@ using Opc.Ua; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; @@ -18,6 +19,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { private readonly OpcUaServerOptions _options; private readonly DriverHost _driverHost; + private readonly IUserAuthenticator _authenticator; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private ApplicationInstance? _application; @@ -25,10 +27,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private bool _disposed; public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost, - ILoggerFactory loggerFactory, ILogger logger) + IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger logger) { _options = options; _driverHost = driverHost; + _authenticator = authenticator; _loggerFactory = loggerFactory; _logger = logger; } @@ -55,7 +58,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable throw new InvalidOperationException( $"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}"); - _server = new OtOpcUaServer(_driverHost, _loggerFactory); + _server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory); await _application.Start(_server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}", @@ -126,22 +129,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable ServerConfiguration = new ServerConfiguration { BaseAddresses = new StringCollection { _options.EndpointUrl }, - SecurityPolicies = new ServerSecurityPolicyCollection - { - new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.None, - SecurityPolicyUri = SecurityPolicies.None, - }, - }, - UserTokenPolicies = new UserTokenPolicyCollection - { - new UserTokenPolicy(UserTokenType.Anonymous) - { - PolicyId = "Anonymous", - SecurityPolicyUri = SecurityPolicies.None, - }, - }, + SecurityPolicies = BuildSecurityPolicies(), + UserTokenPolicies = BuildUserTokenPolicies(), MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200, @@ -164,6 +153,58 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return cfg; } + private ServerSecurityPolicyCollection BuildSecurityPolicies() + { + var policies = new ServerSecurityPolicyCollection + { + // Keep the None policy present so legacy clients can discover + browse. Locked-down + // deployments remove this by setting Ldap.Enabled=true + dropping None here; left in + // for PR 19 so the PR 17 test harness continues to pass unchanged. + new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + }, + }; + + if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt) + { + policies.Add(new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256, + }); + } + + return policies; + } + + private UserTokenPolicyCollection BuildUserTokenPolicies() + { + var tokens = new UserTokenPolicyCollection + { + new UserTokenPolicy(UserTokenType.Anonymous) + { + PolicyId = "Anonymous", + SecurityPolicyUri = SecurityPolicies.None, + }, + }; + + if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt + && _options.Ldap.Enabled) + { + tokens.Add(new UserTokenPolicy(UserTokenType.UserName) + { + PolicyId = "UserName", + // Passwords must ride an encrypted channel — scope this token to Basic256Sha256 + // so the stack rejects any attempt to send UserName over the None endpoint. + SecurityPolicyUri = SecurityPolicies.Basic256Sha256, + }); + } + + return tokens; + } + public async ValueTask DisposeAsync() { if (_disposed) return; diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs index 8827142..2844e29 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs @@ -1,5 +1,23 @@ +using ZB.MOM.WW.OtOpcUa.Server.Security; + namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; +/// +/// OPC UA transport security profile selector. Controls which ServerSecurityPolicy +/// entries the endpoint advertises + which token types the UserTokenPolicies permits. +/// +public enum OpcUaSecurityProfile +{ + /// Anonymous only on SecurityPolicies.None — dev-only, no signing or encryption. + None, + + /// + /// Basic256Sha256 SignAndEncrypt with UserName and Anonymous token + /// policies. Clients must present a valid application certificate + user credentials. + /// + Basic256Sha256SignAndEncrypt, +} + /// /// OPC UA server endpoint + application-identity configuration. Bound from the /// OpcUaServer section of appsettings.json. PR 17 minimum-viable scope: no LDAP, @@ -39,4 +57,18 @@ public sealed class OpcUaServerOptions /// Admin UI. /// public bool AutoAcceptUntrustedClientCertificates { get; init; } = true; + + /// + /// Security profile advertised on the endpoint. Default + /// preserves the PR 17 endpoint shape; set to + /// for production deployments with LDAP-backed UserName auth. + /// + public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None; + + /// + /// LDAP binding for UserName token validation. Only consulted when the active + /// advertises a UserName token policy. When + /// LdapOptions.Enabled = false, UserName token attempts are rejected. + /// + public LdapOptions Ldap { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs index 622fe2e..04cde9a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs @@ -5,6 +5,7 @@ using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; @@ -17,12 +18,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; public sealed class OtOpcUaServer : StandardServer { private readonly DriverHost _driverHost; + private readonly IUserAuthenticator _authenticator; private readonly ILoggerFactory _loggerFactory; private readonly List _driverNodeManagers = new(); - public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory) + public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory) { _driverHost = driverHost; + _authenticator = authenticator; _loggerFactory = loggerFactory; } @@ -50,6 +53,63 @@ public sealed class OtOpcUaServer : StandardServer return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray()); } + protected override void OnServerStarted(IServerInternal server) + { + base.OnServerStarted(server); + // Hook UserName / Anonymous token validation here. Anonymous passes through; UserName + // is validated against the IUserAuthenticator (LDAP in production). Rejected identities + // throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid. + server.SessionManager.ImpersonateUser += OnImpersonateUser; + } + + private void OnImpersonateUser(Session session, ImpersonateEventArgs args) + { + switch (args.NewIdentity) + { + case AnonymousIdentityToken: + args.Identity = new UserIdentity(); // anonymous + return; + + case UserNameIdentityToken user: + { + var result = _authenticator.AuthenticateAsync( + user.UserName, user.DecryptedPassword, CancellationToken.None) + .GetAwaiter().GetResult(); + if (!result.Success) + { + throw ServiceResultException.Create( + StatusCodes.BadUserAccessDenied, + "Invalid username or password ({0})", result.Error ?? "no detail"); + } + args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles); + return; + } + + default: + throw ServiceResultException.Create( + StatusCodes.BadIdentityTokenInvalid, + "Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null"); + } + } + + /// + /// Tiny UserIdentity carrier that preserves the resolved roles so downstream node + /// managers can gate writes by role via session.Identity. Anonymous identity still + /// uses the stack's default. + /// + private sealed class RoleBasedIdentity : UserIdentity + { + public IReadOnlyList Roles { get; } + public string? Display { get; } + + public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList roles) + : base(userName, "") + { + Display = displayName; + Roles = roles; + } + } + protected override ServerProperties LoadServerProperties() => new() { ManufacturerName = "OtOpcUa", diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index c34e63e..d228974 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -1,11 +1,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Serilog; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; var builder = Host.CreateApplicationBuilder(args); @@ -31,6 +33,20 @@ var options = new NodeOptions }; var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName); +var ldapSection = opcUaSection.GetSection("Ldap"); +var ldapOptions = new LdapOptions +{ + Enabled = ldapSection.GetValue("Enabled") ?? false, + Server = ldapSection.GetValue("Server") ?? "localhost", + Port = ldapSection.GetValue("Port") ?? 3893, + UseTls = ldapSection.GetValue("UseTls") ?? false, + AllowInsecureLdap = ldapSection.GetValue("AllowInsecureLdap") ?? true, + SearchBase = ldapSection.GetValue("SearchBase") ?? "dc=lmxopcua,dc=local", + ServiceAccountDn = ldapSection.GetValue("ServiceAccountDn") ?? string.Empty, + ServiceAccountPassword = ldapSection.GetValue("ServiceAccountPassword") ?? string.Empty, + GroupToRole = ldapSection.GetSection("GroupToRole").Get>() ?? new(StringComparer.OrdinalIgnoreCase), +}; + var opcUaOptions = new OpcUaServerOptions { EndpointUrl = opcUaSection.GetValue("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa", @@ -39,10 +55,17 @@ var opcUaOptions = new OpcUaServerOptions PkiStoreRoot = opcUaSection.GetValue("PkiStoreRoot") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"), AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue("AutoAcceptUntrustedClientCertificates") ?? true, + SecurityProfile = Enum.TryParse(opcUaSection.GetValue("SecurityProfile"), true, out var p) + ? p : OpcUaSecurityProfile.None, + Ldap = ldapOptions, }; builder.Services.AddSingleton(options); builder.Services.AddSingleton(opcUaOptions); +builder.Services.AddSingleton(ldapOptions); +builder.Services.AddSingleton(sp => ldapOptions.Enabled + ? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService>()) + : new DenyAllUserAuthenticator()); builder.Services.AddSingleton(_ => new LiteDbConfigCache(options.LocalCachePath)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs new file mode 100644 index 0000000..9cfab54 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// Validates a (username, password) pair and returns the resolved OPC UA roles for the user. +/// The Server's SessionManager_ImpersonateUser hook delegates here so unit tests can +/// swap in a fake authenticator without a live LDAP. +/// +public interface IUserAuthenticator +{ + Task AuthenticateAsync(string username, string password, CancellationToken ct = default); +} + +public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList Roles, string? Error); + +/// +/// Always-reject authenticator used when no security config is provided. Lets the server +/// start (with only an anonymous endpoint) without throwing on UserName token attempts. +/// +public sealed class DenyAllUserAuthenticator : IUserAuthenticator +{ + public Task AuthenticateAsync(string _, string __, CancellationToken ___) + => Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported")); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs new file mode 100644 index 0000000..756dfe7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// LDAP settings for the OPC UA server's UserName token validator. Bound from +/// appsettings.json OpcUaServer:Ldap. Defaults match the GLAuth dev instance +/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set +/// true, populate for search-then-bind, and maintain +/// with the real LDAP group names. +/// +public sealed class LdapOptions +{ + public bool Enabled { get; init; } = false; + public string Server { get; init; } = "localhost"; + public int Port { get; init; } = 3893; + public bool UseTls { get; init; } = false; + + /// Dev-only escape hatch — must be false in production. + public bool AllowInsecureLdap { get; init; } = true; + + public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; + public string ServiceAccountDn { get; init; } = string.Empty; + public string ServiceAccountPassword { get; init; } = string.Empty; + public string DisplayNameAttribute { get; init; } = "cn"; + public string GroupAttribute { get; init; } = "memberOf"; + + /// + /// LDAP group → OPC UA role. Each authenticated user gets every role whose source group + /// is in their membership list. Recognized role names (CLAUDE.md): ReadOnly (browse + /// + read), WriteOperate, WriteTune, WriteConfigure, AlarmAck. + /// + public Dictionary GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs new file mode 100644 index 0000000..414aba6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Logging; +using Novell.Directory.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// that binds to the configured LDAP directory to validate +/// the (username, password) pair, then pulls group membership and maps to OPC UA roles. +/// Mirrors the bind-then-search pattern in Admin.Security.LdapAuthService but stays +/// in the Server project so the Server process doesn't take a cross-app dependency on Admin. +/// +public sealed class LdapUserAuthenticator(LdapOptions options, ILogger logger) + : IUserAuthenticator +{ + public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) + { + if (!options.Enabled) + return new UserAuthResult(false, null, [], "LDAP authentication disabled"); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return new UserAuthResult(false, null, [], "Credentials required"); + + if (!options.UseTls && !options.AllowInsecureLdap) + return new UserAuthResult(false, null, [], + "Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test."); + + try + { + using var conn = new LdapConnection(); + if (options.UseTls) conn.SecureSocketLayer = true; + await Task.Run(() => conn.Connect(options.Server, options.Port), ct); + + var bindDn = await ResolveUserDnAsync(conn, username, ct); + await Task.Run(() => conn.Bind(bindDn, password), ct); + + // Rebind as service account for attribute read, if configured — otherwise the just- + // bound user reads their own entry (works when ACL permits self-read). + if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn)) + await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct); + + var displayName = username; + var groups = new List(); + + try + { + var filter = $"(cn={EscapeLdapFilter(username)})"; + var results = await Task.Run(() => + conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct); + + while (results.HasMore()) + { + try + { + var entry = results.Next(); + var name = entry.GetAttribute(options.DisplayNameAttribute); + if (name is not null) displayName = name.StringValue; + + var groupAttr = entry.GetAttribute(options.GroupAttribute); + if (groupAttr is not null) + { + foreach (var groupDn in groupAttr.StringValueArray) + groups.Add(ExtractFirstRdnValue(groupDn)); + } + + // GLAuth fallback: primary group is encoded as the ou= RDN above cn=. + if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn)) + { + var primary = ExtractOuSegment(entry.Dn); + if (primary is not null) groups.Add(primary); + } + } + catch (LdapException) { break; } + } + } + catch (LdapException ex) + { + logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username); + } + + conn.Disconnect(); + + var roles = groups + .Where(g => options.GroupToRole.ContainsKey(g)) + .Select(g => options.GroupToRole[g]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new UserAuthResult(true, displayName, roles, null); + } + catch (LdapException ex) + { + logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode); + return new UserAuthResult(false, null, [], "Invalid username or password"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Unexpected LDAP error for {User}", username); + return new UserAuthResult(false, null, [], "Authentication error"); + } + } + + private async Task ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct) + { + if (username.Contains('=')) return username; // caller passed a DN directly + + if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn)) + { + await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct); + + var filter = $"(uid={EscapeLdapFilter(username)})"; + var results = await Task.Run(() => + conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct); + + if (results.HasMore()) + return results.Next().Dn; + + throw new LdapException("User not found", LdapException.NoSuchObject, + $"No entry for uid={username}"); + } + + return string.IsNullOrWhiteSpace(options.SearchBase) + ? $"cn={username}" + : $"cn={username},{options.SearchBase}"; + } + + internal static string EscapeLdapFilter(string input) => + input.Replace("\\", "\\5c") + .Replace("*", "\\2a") + .Replace("(", "\\28") + .Replace(")", "\\29") + .Replace("\0", "\\00"); + + internal static string? ExtractOuSegment(string dn) + { + foreach (var segment in dn.Split(',')) + { + var trimmed = segment.Trim(); + if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase)) + return trimmed[3..]; + } + return null; + } + + internal static string ExtractFirstRdnValue(string dn) + { + var eq = dn.IndexOf('='); + if (eq < 0) return dn; + var valueStart = eq + 1; + var comma = dn.IndexOf(',', valueStart); + return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..]; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index d4fad87..9b4ef26 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -23,12 +23,17 @@ + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs index 2d4ab4c..a546b9e 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs @@ -7,6 +7,7 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; @@ -38,8 +39,8 @@ public sealed class OpcUaServerIntegrationTests : IAsyncLifetime AutoAcceptUntrustedClientCertificates = true, }; - _server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance, - NullLogger.Instance); + _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(), + NullLoggerFactory.Instance, NullLogger.Instance); await _server.StartAsync(CancellationToken.None); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/SecurityConfigurationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/SecurityConfigurationTests.cs new file mode 100644 index 0000000..897dbb0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/SecurityConfigurationTests.cs @@ -0,0 +1,88 @@ +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.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.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.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(); + } +}