Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/CredentialLockoutWireTests.cs
T
Joseph Doherty 2314a53842 fix(opcua): parallel UserName activations wait for a slot instead of being refused (#833)
A legitimate consumer opening several keyed sessions at once from one key
(e.g. a ScadaBridge site node behind one NAT IP on a None endpoint) must
never see BadUserAccessDenied for parallelism. BeginAsync now hands out at
most MaxFailedAttempts - recordedFailures verification slots per key;
further activations queue FIFO and are woken as slots free. A waiter is
refused only when the key locks out (never reaching the authenticator) or,
uncounted, after OpcUa:CredentialLockout:SlotWaitSeconds (default 10,
BadTimeout). Cancelled waiters are dequeued; pruning accounts for waiters.
The security property is unchanged: no more than MaxFailedAttempts
unverified guesses in progress-or-failed per window.
2026-11-11 02:43:38 -05:00

209 lines
9.2 KiB
C#

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;
/// <summary>
/// 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
/// <c>BadUserAccessDenied</c> for 5 minutes. The host now disables the SDK lockout and enforces a
/// UserName-only replacement (<see cref="FailedCredentialLockout"/>).
/// </summary>
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<ServiceResultException>(() => 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<ServiceResultException>(() => rig.ConnectAsync($"otua_k-bad{i}_x", ct));
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
var callsBefore = auth.UserNameCalls;
var locked = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(GoodKey, ct));
locked.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
auth.UserNameCalls.ShouldBe(callsBefore, "a locked-out client's credential is refused without being checked");
}
/// <summary>
/// #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.
/// </summary>
[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<ServiceResultException>(() => 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<Rig> 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<OpcUaApplicationHost>.Instance,
authenticator);
},
() => new OtOpcUaSdkServer(), ct);
return rig;
}
/// <summary>Opens a session; a null credential activates anonymously.</summary>
public async Task<ISession> 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 */ }
}
}
}
/// <summary>Admits <see cref="GoodKey"/> and anonymous; denies everything else; counts calls.</summary>
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<OpcUaUserAuthResult> 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<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct) =>
AuthenticateUserNameAsync(username, password, ct);
public Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
{
Interlocked.Increment(ref _anonymousCalls);
return Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, []));
}
}
}