using System.Net; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// lmxopcua#833 over the wire. A few failed UserName activations used to trip the OPC Foundation SDK's /// per-client lockout, which then refused EVERY activation from that client — Anonymous included — with /// BadUserAccessDenied for 5 minutes. The host now disables the SDK lockout and enforces a /// UserName-only replacement (). /// public sealed class CredentialLockoutWireTests { private const string GoodKey = "otua_k-good_secret"; [Fact] public async Task Failed_credentials_never_lock_the_client_out_of_anonymous_activation() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions(), ct); for (var i = 0; i < 6; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBeOneOf(StatusCodes.BadIdentityTokenRejected, StatusCodes.BadUserAccessDenied); } using var anonymous = await rig.ConnectAsync(credential: null, ct); var ns = await anonymous.ReadValueAsync(VariableIds.Server_NamespaceArray, ct); StatusCode.IsGood(ns.StatusCode).ShouldBeTrue(); auth.AnonymousCalls.ShouldBe(1, "the anonymous activation must reach the authenticator, not be refused before it"); } [Fact] public async Task Failed_credentials_still_lock_out_further_UserName_activations_from_that_client() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 3 }, ct); for (var i = 0; i < 3; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } var callsBefore = auth.UserNameCalls; var locked = await Should.ThrowAsync(() => rig.ConnectAsync(GoodKey, ct)); locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked"); } /// /// #833 review — one client opening more keyed sessions at once than MaxFailedAttempts (a ScadaBridge site /// node behind one NAT IP) is never refused for parallelism: the extra activations wait for a slot. /// [Fact] public async Task Parallel_valid_activations_beyond_the_threshold_all_succeed() { var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync(auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 2 }, ct); var sessions = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => rig.ConnectAsync(GoodKey, ct))); try { sessions.ShouldAllBe(s => s.Connected); auth.UserNameCalls.ShouldBe(8); } finally { foreach (var s in sessions) s.Dispose(); } } [Fact] public async Task A_disabled_lockout_admits_a_valid_key_after_any_number_of_failures() { // Also proves the SDK's own lockout (default 5) is switched off: with ours disabled nothing may refuse. var ct = TestContext.Current.CancellationToken; var auth = new CountingAuthenticator(); await using var rig = await Rig.StartAsync( auth, new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, ct); for (var i = 0; i < 7; i++) { var rejected = await Should.ThrowAsync(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct)); rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected); } using var session = await rig.ConnectAsync(GoodKey, ct); session.Connected.ShouldBeTrue(); } private sealed class Rig : IAsyncDisposable { private readonly string _pkiRoot; private OpcUaApplicationHost _host = null!; private int _port; private Rig(string pkiRoot) => _pkiRoot = pkiRoot; public static async Task StartAsync( IOpcUaUserAuthenticator authenticator, OpcUaCredentialLockoutOptions lockout, CancellationToken ct) { var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-lockout-{Guid.NewGuid():N}")); var applicationUri = $"urn:OtOpcUa.Lockout.{Guid.NewGuid():N}"; (rig._host, _) = await OpcUaHostStart.StartAsync( port => { rig._port = port; return new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Lockout", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = rig._pkiRoot, EnabledSecurityProfiles = [OpcUaSecurityProfile.None], AutoAcceptUntrustedClientCertificates = true, CredentialLockout = lockout, }, NullLogger.Instance, authenticator); }, () => new OtOpcUaSdkServer(), ct); return rig; } /// Opens a session; a null credential activates anonymously. public async Task ConnectAsync(string? credential, CancellationToken ct) { var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { })) { ApplicationName = "OtOpcUa.LockoutClient", ApplicationUri = "urn:OtOpcUa.LockoutClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("no endpoint"), EndpointConfiguration.Create(appConfig)), updateBeforeConnect: false, checkDomain: false, sessionName: "CredentialLockoutWireTests", sessionTimeout: 60_000, identity: credential is null ? new UserIdentity() : new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), preferredLocales: default, ct: ct); } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } } /// Admits and anonymous; denies everything else; counts calls. private sealed class CountingAuthenticator : IOpcUaUserAuthenticator { private int _userNameCalls; private int _anonymousCalls; public int UserNameCalls => Volatile.Read(ref _userNameCalls); public int AnonymousCalls => Volatile.Read(ref _anonymousCalls); public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _userNameCalls); return Task.FromResult(password == GoodKey ? OpcUaUserAuthResult.AllowKey("good", [], "k-good") : OpcUaUserAuthResult.Deny("Invalid API key.")); } public Task AuthenticateUserNameAsync( string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) => AuthenticateUserNameAsync(username, password, ct); public Task AuthenticateAnonymousAsync(CancellationToken ct) { Interlocked.Increment(ref _anonymousCalls); return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, [])); } } }