179 lines
7.9 KiB
C#
179 lines
7.9 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#751 over the wire — session activation hands the authenticator the client's remote endpoint,
|
|
/// and the per-client API-key miss budget therefore applies to a real client.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// SDK 2 supplies observed PeerAddress during activation. This test verifies that public evidence
|
|
/// reaches the authenticator and its per-IP miss budget; no private channel lookup is involved.
|
|
/// </remarks>
|
|
public sealed class ApiKeyMissBudgetPerEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Session_activation_hands_the_authenticator_the_clients_address()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var recorder = new RecordingAuthenticator();
|
|
await using var rig = await Rig.StartAsync("RemoteEndpoint", recorder, ct);
|
|
|
|
using var session = await rig.ConnectAsync("otua_k-probe_secret", ct);
|
|
|
|
recorder.Seen.ShouldNotBeNull("the server must resolve the activating client's endpoint");
|
|
IPAddress.IsLoopback(recorder.Seen!.Address).ShouldBeTrue($"connected from loopback, got {recorder.Seen}");
|
|
recorder.Seen.Port.ShouldBe(0, "the public SDK evidence contains an IP address, not a source port");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The production authenticator over the bounded store: with a per-client burst of 2 and the known-id
|
|
/// set unavailable, a client's third and fourth unknown keys are refused without reaching the store.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_client_past_its_miss_budget_is_refused_without_a_store_read()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var inner = new UnlistingStore();
|
|
var options = Options.Create(new ApiKeyOptions
|
|
{
|
|
Username = "apikey", Pepper = "integration-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 0,
|
|
});
|
|
var bounded = new BoundedApiKeyStore(
|
|
inner, options, NullLogger<BoundedApiKeyStore>.Instance, TimeProvider.System,
|
|
new ApiKeyStoreGuardOptions
|
|
{
|
|
MissBurst = 100, MissRefillPerSecond = 0, MissBurstPerEndpoint = 2, MissRefillPerSecondPerEndpoint = 0,
|
|
});
|
|
var authenticator = new ApiKeyOpcUaUserAuthenticator(
|
|
bounded, options, NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
|
|
await using var rig = await Rig.StartAsync("MissBudget", authenticator, ct);
|
|
|
|
for (var i = 0; i < 4; i++)
|
|
{
|
|
var rejected = await Should.ThrowAsync<ServiceResultException>(
|
|
() => rig.ConnectAsync($"otua_k-guess-{i}_secret", ct));
|
|
rejected.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
}
|
|
|
|
inner.FindCalls.ShouldBe(2, "only the client's first two misses may reach the store");
|
|
}
|
|
|
|
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(string name, IOpcUaUserAuthenticator authenticator, CancellationToken ct)
|
|
{
|
|
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
|
var applicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}";
|
|
(rig._host, _) = await OpcUaHostStart.StartAsync(
|
|
port =>
|
|
{
|
|
rig._port = port;
|
|
return new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = $"OtOpcUa.{name}",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = rig._pkiRoot,
|
|
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance,
|
|
authenticator);
|
|
},
|
|
() => new OtOpcUaSdkServer(), ct);
|
|
return rig;
|
|
}
|
|
|
|
public async Task<ISession> ConnectAsync(string credential, CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
|
{
|
|
ApplicationName = "OtOpcUa.MissBudgetClient",
|
|
ApplicationUri = $"urn:OtOpcUa.MissBudgetClient.{Guid.NewGuid():N}",
|
|
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("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
|
|
updateBeforeConnect: false,
|
|
checkDomain: false,
|
|
sessionName: "ApiKeyMissBudgetPerEndpointTests",
|
|
sessionTimeout: 60_000,
|
|
identity: 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 every UserName token and records the endpoint the server handed it.</summary>
|
|
private sealed class RecordingAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
public IPEndPoint? Seen { get; private set; }
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
|
throw new InvalidOperationException("the server must call the endpoint-carrying overload");
|
|
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
|
string username, string password, IPEndPoint? remoteEndpoint, CancellationToken ct)
|
|
{
|
|
Seen = remoteEndpoint;
|
|
return Task.FromResult(OpcUaUserAuthResult.AllowKey("probe", [], "k-probe"));
|
|
}
|
|
}
|
|
|
|
/// <summary>Holds no keys and cannot list ids, so every lookup is a miss under the budget.</summary>
|
|
private sealed class UnlistingStore : IApiKeyStore
|
|
{
|
|
private int _findCalls;
|
|
|
|
public int FindCalls => Volatile.Read(ref _findCalls);
|
|
|
|
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _findCalls);
|
|
return Task.FromResult<ApiKeyLookup?>(null);
|
|
}
|
|
|
|
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
|
|
}
|
|
}
|