feat(security): per-client pre-auth API-key miss budget; widen IOpcUaUserAuthenticator with the remote endpoint (lmxopcua#751)
The #733 miss budget (MissBurst / MissRefillPerSecond) was process-wide, because the impersonation seam did not carry the client's address, so one noisy client could drain it for everybody while the known-id set was unavailable. - IOpcUaUserAuthenticator gains AuthenticateUserNameAsync(username, password, IPEndPoint? remoteEndpoint, ct) (default: ignore it and delegate); the server calls it. IApiKeyStore gains the matching FindAsync(..., IPAddress?, ...). - The SDK (1.5.378) exposes no client address publicly. SecureChannelRemoteEndpoint reads it from the TCP listener's private channel map by reflection (through the protected TransportListeners, exposed by OtOpcUaSdkServer), keyed by the session's secure channel id. Fail-soft: null => process-wide accounting only. - BoundedApiKeyStore charges a miss to the client's own bucket (keyed by IP address) and then to the process-wide one, which stays as the backstop. A refusal by either takes from neither; a hit refunds both. New options Security:ApiKeys:MissBurstPerEndpoint (10) / MissRefillPerSecondPerEndpoint (2), 0 disables; telemetry outcome endpoint_miss_budget. At most 4096 addresses are tracked; idle buckets are pruned and an address beyond the cap is charged process-wide only. Tests: BoundedApiKeyStorePerEndpointTests (noisy client vs quiet one, process-wide backstop, refund + refill, off switch, authenticator forwards the address) and over the wire ApiKeyMissBudgetPerEndpointTests (activation hands the authenticator the loopback endpoint; real authenticator + bounded store refuse a client's 3rd and 4th unknown key without a store read). Each new behaviour test fails with its fix mutated out. Host.Tests 224/0, OpcUaServer.Tests 1019/1 (the one failure is the wall-clock NodeManagerLockTimeBenchmarkTests under suite load; passes alone). Docs: security.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -69,8 +69,14 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||
AuthenticateUserNameAsync(username, password, remoteEndpoint: null, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
// The client's address rides into the store so BoundedApiKeyStore can charge a miss to that client's own
|
||||
// budget before the process-wide one (lmxopcua#751).
|
||||
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
||||
string username, string password, CancellationToken ct)
|
||||
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
|
||||
@@ -95,7 +101,7 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
ApiKeyLookup? lookup;
|
||||
try
|
||||
{
|
||||
lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
|
||||
lookup = await _store.FindAsync(apiKeyId, now, remoteEndpoint?.Address, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -18,9 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// set is unavailable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The budget is process-wide, not per remote endpoint: the OPC UA SDK's impersonation callback
|
||||
/// (the <c>IOpcUaUserAuthenticator</c> seam) does not carry the client's address. Per-endpoint
|
||||
/// accounting needs that seam widened in the OpcUaServer project — a follow-up, not this lane.
|
||||
/// Since lmxopcua#751 the miss budget is also kept <b>per client IP address</b>
|
||||
/// (<see cref="MissBurstPerEndpoint"/> / <see cref="MissRefillPerSecondPerEndpoint"/>): a miss is charged
|
||||
/// to the client's own bucket first and then to the process-wide one, which stays as the backstop. One
|
||||
/// noisy client therefore exhausts its own budget long before it can drain the process-wide one for
|
||||
/// everybody. A client whose address cannot be resolved is charged to the process-wide bucket only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyStoreGuardOptions
|
||||
@@ -44,6 +46,17 @@ public sealed class ApiKeyStoreGuardOptions
|
||||
/// <summary>How many miss tokens are restored per second, up to <see cref="MissBurst"/>.</summary>
|
||||
public double MissRefillPerSecond { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// How many store misses ONE client IP address may burst before its further lookups for unknown ids are
|
||||
/// refused without a store read (lmxopcua#751). Deliberately a tenth of <see cref="MissBurst"/>: a
|
||||
/// legitimate client presents a known key, which is never charged, so only a misconfigured or hostile
|
||||
/// client spends from it. <c>0</c> disables per-client accounting (process-wide only).
|
||||
/// </summary>
|
||||
public int MissBurstPerEndpoint { get; set; } = 10;
|
||||
|
||||
/// <summary>How many per-client miss tokens are restored per second, up to <see cref="MissBurstPerEndpoint"/>.</summary>
|
||||
public double MissRefillPerSecondPerEndpoint { get; set; } = 2;
|
||||
|
||||
/// <summary>Binds the section, with defaults for anything absent.</summary>
|
||||
/// <param name="configuration">The app configuration; null yields the defaults.</param>
|
||||
/// <returns>The bound options.</returns>
|
||||
|
||||
@@ -84,6 +84,32 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
private double _missTokens;
|
||||
private DateTimeOffset _missRefilledUtc;
|
||||
|
||||
/// <summary>
|
||||
/// How many client addresses the per-endpoint miss budget tracks at once (lmxopcua#751). When full, idle
|
||||
/// (refilled) buckets are pruned; if every tracked client is still active, a new address is charged to the
|
||||
/// process-wide bucket only — never a fresh, full per-client budget.
|
||||
/// </summary>
|
||||
public const int MaxTrackedEndpoints = 4096;
|
||||
|
||||
// lmxopcua#751 — the per-client miss buckets, keyed by IP address (not port: one client can open many
|
||||
// connections). Guarded by _bucketGate, like the process-wide bucket they sit in front of.
|
||||
private readonly Dictionary<System.Net.IPAddress, EndpointBucket> _endpointBuckets = new();
|
||||
|
||||
/// <summary>One client's miss bucket.</summary>
|
||||
private sealed class EndpointBucket
|
||||
{
|
||||
public double Tokens;
|
||||
public DateTimeOffset RefilledUtc;
|
||||
}
|
||||
|
||||
/// <summary>Which budget refused a lookup.</summary>
|
||||
private enum MissBudget
|
||||
{
|
||||
Granted,
|
||||
EndpointExhausted,
|
||||
ProcessExhausted,
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
|
||||
/// <param name="inner">The store being bounded.</param>
|
||||
/// <param name="options">The <c>Security:ApiKeys</c> options carrying the deadline and TTL.</param>
|
||||
@@ -136,7 +162,12 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
|
||||
// Enabled revocation may be, and nothing else.
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
||||
FindAsync(apiKeyId, utcNow, remoteAddress: null, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyLookup?> FindAsync(
|
||||
string apiKeyId, DateTime utcNow, System.Net.IPAddress? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
var ttl = Ttl;
|
||||
if (ttl > TimeSpan.Zero
|
||||
@@ -159,18 +190,31 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
}
|
||||
|
||||
// R03:3.3, guard 2 — a lookup that will reach the store needs a miss token unless the id is known
|
||||
// to exist (a known id is a real key being verified, never a guess).
|
||||
if (known is null && !TryTakeMissToken())
|
||||
// to exist (a known id is a real key being verified, never a guess). lmxopcua#751: the client's own
|
||||
// bucket is charged first, so one noisy client cannot drain the process-wide budget for everyone.
|
||||
if (known is null)
|
||||
{
|
||||
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "miss_budget"));
|
||||
throw new InvalidOperationException(
|
||||
"ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
|
||||
+ "MissRefillPerSecond); refusing this lookup without a store read.");
|
||||
switch (TryTakeMissToken(remoteAddress))
|
||||
{
|
||||
case MissBudget.EndpointExhausted:
|
||||
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
|
||||
1, new KeyValuePair<string, object?>("outcome", "endpoint_miss_budget"));
|
||||
throw new InvalidOperationException(
|
||||
"ApiKeyStore: this client's unknown-key miss budget is exhausted (Security:ApiKeys:"
|
||||
+ "MissBurstPerEndpoint / MissRefillPerSecondPerEndpoint); refusing this lookup without a "
|
||||
+ "store read.");
|
||||
case MissBudget.ProcessExhausted:
|
||||
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
|
||||
1, new KeyValuePair<string, object?>("outcome", "miss_budget"));
|
||||
throw new InvalidOperationException(
|
||||
"ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
|
||||
+ "MissRefillPerSecond); refusing this lookup without a store read.");
|
||||
}
|
||||
}
|
||||
|
||||
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
if (fresh is not null && known is null) RefundMissToken();
|
||||
if (fresh is not null && known is null) RefundMissToken(remoteAddress);
|
||||
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
|
||||
return fresh;
|
||||
}
|
||||
@@ -219,32 +263,99 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes one miss token, refilling by elapsed time first; always succeeds when the budget is disabled.</summary>
|
||||
/// <returns><see langword="false"/> when the budget is exhausted.</returns>
|
||||
private bool TryTakeMissToken()
|
||||
/// <summary>
|
||||
/// Takes one miss token from <paramref name="remoteAddress"/>'s bucket (when known and per-client accounting
|
||||
/// is on) AND from the process-wide bucket, refilling both by elapsed time first. All or nothing: a refusal
|
||||
/// by either takes from neither. A disabled budget always grants.
|
||||
/// </summary>
|
||||
/// <param name="remoteAddress">The client's IP address; null charges the process-wide bucket only.</param>
|
||||
/// <returns>Which budget, if any, refused the lookup.</returns>
|
||||
private MissBudget TryTakeMissToken(System.Net.IPAddress? remoteAddress)
|
||||
{
|
||||
var burst = _guard.MissBurst;
|
||||
if (burst <= 0) return true;
|
||||
var endpointBurst = _guard.MissBurstPerEndpoint;
|
||||
if (burst <= 0 && (endpointBurst <= 0 || remoteAddress is null)) return MissBudget.Granted;
|
||||
lock (_bucketGate)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var elapsed = (now - _missRefilledUtc).TotalSeconds;
|
||||
if (elapsed > 0)
|
||||
if (burst > 0)
|
||||
{
|
||||
_missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
|
||||
_missRefilledUtc = now;
|
||||
var elapsed = (now - _missRefilledUtc).TotalSeconds;
|
||||
if (elapsed > 0)
|
||||
{
|
||||
_missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
|
||||
_missRefilledUtc = now;
|
||||
}
|
||||
}
|
||||
if (_missTokens < 1) return false;
|
||||
_missTokens -= 1;
|
||||
return true;
|
||||
|
||||
var endpoint = endpointBurst > 0 && remoteAddress is not null ? EndpointBucketFor(remoteAddress, now) : null;
|
||||
if (endpoint is not null && endpoint.Tokens < 1) return MissBudget.EndpointExhausted;
|
||||
if (burst > 0 && _missTokens < 1) return MissBudget.ProcessExhausted;
|
||||
|
||||
if (endpoint is not null) endpoint.Tokens -= 1;
|
||||
if (burst > 0) _missTokens -= 1;
|
||||
return MissBudget.Granted;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A lookup that reached the store and HIT was not a miss: its token goes back.</summary>
|
||||
private void RefundMissToken()
|
||||
/// <summary>
|
||||
/// The refilled bucket for <paramref name="address"/>, created full on first sight; null when the table is
|
||||
/// at <see cref="MaxTrackedEndpoints"/> even after pruning idle buckets. Caller holds <c>_bucketGate</c>.
|
||||
/// </summary>
|
||||
/// <param name="address">The client's IP address.</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <returns>The bucket, or null when no slot is free.</returns>
|
||||
private EndpointBucket? EndpointBucketFor(System.Net.IPAddress address, DateTimeOffset now)
|
||||
{
|
||||
if (_guard.MissBurst <= 0) return;
|
||||
lock (_bucketGate) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
|
||||
var burst = _guard.MissBurstPerEndpoint;
|
||||
if (_endpointBuckets.TryGetValue(address, out var bucket))
|
||||
{
|
||||
Refill(bucket, now);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
if (_endpointBuckets.Count >= MaxTrackedEndpoints)
|
||||
{
|
||||
// An idle bucket (refilled to full) carries no history worth keeping — dropping it is the same as
|
||||
// re-creating it full later. A bucket still below full is a client being throttled; never reset it.
|
||||
foreach (var (key, candidate) in _endpointBuckets.ToList())
|
||||
{
|
||||
Refill(candidate, now);
|
||||
if (candidate.Tokens >= burst) _endpointBuckets.Remove(key);
|
||||
}
|
||||
|
||||
if (_endpointBuckets.Count >= MaxTrackedEndpoints) return null;
|
||||
}
|
||||
|
||||
bucket = new EndpointBucket { Tokens = burst, RefilledUtc = now };
|
||||
_endpointBuckets[address] = bucket;
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/// <summary>Refills a per-client bucket by elapsed time. Caller holds <c>_bucketGate</c>.</summary>
|
||||
private void Refill(EndpointBucket bucket, DateTimeOffset now)
|
||||
{
|
||||
var elapsed = (now - bucket.RefilledUtc).TotalSeconds;
|
||||
if (elapsed <= 0) return;
|
||||
bucket.Tokens = Math.Min(
|
||||
_guard.MissBurstPerEndpoint,
|
||||
bucket.Tokens + elapsed * Math.Max(0, _guard.MissRefillPerSecondPerEndpoint));
|
||||
bucket.RefilledUtc = now;
|
||||
}
|
||||
|
||||
/// <summary>A lookup that reached the store and HIT was not a miss: its tokens go back.</summary>
|
||||
/// <param name="remoteAddress">The client the token was charged to, or null.</param>
|
||||
private void RefundMissToken(System.Net.IPAddress? remoteAddress)
|
||||
{
|
||||
lock (_bucketGate)
|
||||
{
|
||||
if (_guard.MissBurst > 0) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
|
||||
if (_guard.MissBurstPerEndpoint > 0 && remoteAddress is not null
|
||||
&& _endpointBuckets.TryGetValue(remoteAddress, out var bucket))
|
||||
{
|
||||
bucket.Tokens = Math.Min(_guard.MissBurstPerEndpoint, bucket.Tokens + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
|
||||
|
||||
@@ -27,6 +27,20 @@ public interface IApiKeyStore
|
||||
/// <returns>The key and its principal's grants, or null when no usable key matches.</returns>
|
||||
Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="FindAsync(string, DateTime, CancellationToken)"/> on behalf of the client at
|
||||
/// <paramref name="remoteAddress"/> (lmxopcua#751), so a decorator can account pre-authentication misses
|
||||
/// per client. The default ignores the address; only <see cref="BoundedApiKeyStore"/> uses it.
|
||||
/// </summary>
|
||||
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
|
||||
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
|
||||
/// <param name="remoteAddress">The activating client's IP address; null when unknown.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The key and its principal's grants, or null when no usable key matches.</returns>
|
||||
Task<ApiKeyLookup?> FindAsync(
|
||||
string apiKeyId, DateTime utcNow, System.Net.IPAddress? remoteAddress, CancellationToken ct) =>
|
||||
FindAsync(apiKeyId, utcNow, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ACL grants naming a principal, with no key involved. Used for the
|
||||
/// <c>anonymous</c> principal, which has grants but obviously no credential.
|
||||
|
||||
@@ -303,7 +303,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous);
|
||||
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous,
|
||||
// Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so
|
||||
// the reflection walk is skipped for anonymous / X509.
|
||||
args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null);
|
||||
|
||||
/// <summary>The remote endpoint of the client activating <paramref name="session"/>, or null when unknown.</summary>
|
||||
/// <param name="session">The session being activated.</param>
|
||||
/// <returns>The client's IP endpoint, or null.</returns>
|
||||
/// <remarks>
|
||||
/// Read from the session's secure channel — the SDK raises impersonation without the request's channel
|
||||
/// context (<c>SecureChannelContext.Current</c> is obsolete in 1.5.378). A client that re-activates over
|
||||
/// a NEW channel after its old one closed resolves to null and is accounted process-wide only.
|
||||
/// </remarks>
|
||||
private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) =>
|
||||
_server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
@@ -329,11 +343,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <param name="args">The impersonation event arguments to process.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
/// <param name="allowAnonymous">Whether anonymous sessions may connect.</param>
|
||||
/// <param name="remoteEndpoint">The activating client's IP endpoint when known (lmxopcua#751), handed to
|
||||
/// the authenticator for per-client accounting; null when it could not be resolved.</param>
|
||||
internal static void HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger,
|
||||
bool allowAnonymous = true)
|
||||
bool allowAnonymous = true,
|
||||
System.Net.IPEndPoint? remoteEndpoint = null)
|
||||
{
|
||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
@@ -367,7 +384,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
result = authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -24,6 +24,16 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
/// <c>null</c> leaves it on a no-op logger.</param>
|
||||
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
|
||||
|
||||
/// <summary>
|
||||
/// The client's remote IP endpoint for the secure channel <paramref name="secureChannelId"/>, or null when it
|
||||
/// cannot be resolved (lmxopcua#751). Best effort — see <see cref="SecureChannelRemoteEndpoint"/>; the SDK
|
||||
/// exposes the listeners only to a subclass, which is why this lives here.
|
||||
/// </summary>
|
||||
/// <param name="secureChannelId">The global secure channel id a request arrived on.</param>
|
||||
/// <returns>The remote endpoint, or null.</returns>
|
||||
public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
|
||||
SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
|
||||
|
||||
/// <summary>The custom node manager once <c>StartAsync</c> has called
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
|
||||
|
||||
@@ -25,6 +25,26 @@ public interface IOpcUaUserAuthenticator
|
||||
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AuthenticateUserNameAsync(string, string, CancellationToken)"/> with the activating client's
|
||||
/// remote endpoint (lmxopcua#751), so an implementation can account pre-authentication failures per
|
||||
/// client rather than process-wide. This is the overload the server calls; the default ignores the
|
||||
/// endpoint and delegates, so an implementation that does not need it is unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The endpoint is best effort (<see cref="SecureChannelRemoteEndpoint"/>) and null when unknown — an
|
||||
/// implementation must treat null as "no per-client information", never as a distinct client. Same
|
||||
/// must-not-throw contract as the three-argument overload.
|
||||
/// </remarks>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The cleartext password to authenticate.</param>
|
||||
/// <param name="remoteEndpoint">The client's IP endpoint, or null when it could not be resolved.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
||||
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
|
||||
AuthenticateUserNameAsync(username, password, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves what an <b>anonymous</b> session is allowed to do. Anonymous is a normal
|
||||
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account
|
||||
/// pre-authentication API-key misses per client (lmxopcua#751).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The OPC UA SDK (1.5.378) does not expose the client address on any public surface</b> — not
|
||||
/// on <c>ISession</c>, <c>SecureChannelContext</c>, <c>ImpersonateEventArgs</c> nor the transport
|
||||
/// listener. The TCP listener holds it privately: <c>TcpTransportListener.m_channels</c> (channel id
|
||||
/// → channel) → the channel's non-public <c>Socket</c> → the public
|
||||
/// <c>IMessageSocket.RemoteEndpoint</c>. This class walks that path by reflection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is strictly <b>best effort and fail-soft</b>: any missing member (an SDK upgrade renamed it), an
|
||||
/// unknown channel or a closed socket yields <see langword="null"/>, and a caller treats null as
|
||||
/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It
|
||||
/// never throws. An over-the-wire test (<c>RemoteEndpointResolutionTests</c>) pins that it resolves on
|
||||
/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently
|
||||
/// degrading to process-wide accounting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecureChannelRemoteEndpoint
|
||||
{
|
||||
private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the remote endpoint of the secure channel <paramref name="secureChannelId"/> among
|
||||
/// <paramref name="listeners"/>.
|
||||
/// </summary>
|
||||
/// <param name="listeners">The server's transport listeners.</param>
|
||||
/// <param name="secureChannelId">The global secure channel id (<c>{ListenerId}-{ChannelId}</c>).</param>
|
||||
/// <returns>The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown.</returns>
|
||||
public static IPEndPoint? Resolve(IEnumerable<ITransportListener>? listeners, string? secureChannelId)
|
||||
{
|
||||
if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null;
|
||||
try
|
||||
{
|
||||
foreach (var listener in listeners)
|
||||
{
|
||||
if (listener is null) continue;
|
||||
var listenerId = listener.ListenerId;
|
||||
if (string.IsNullOrEmpty(listenerId)
|
||||
|| !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal)
|
||||
|| !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint)
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-soft by contract: the caller falls back to process-wide accounting.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The listener's channel with id <paramref name="channelId"/>, read from its private channel map.</summary>
|
||||
private static object? FindChannel(ITransportListener listener, uint channelId)
|
||||
{
|
||||
var field = FindField(listener.GetType(), "m_channels");
|
||||
if (field?.GetValue(listener) is not IDictionary channels) return null;
|
||||
return channels.Contains(channelId) ? channels[channelId] : null;
|
||||
}
|
||||
|
||||
/// <summary>The channel's socket's remote endpoint, as an <see cref="IPEndPoint"/>.</summary>
|
||||
private static IPEndPoint? RemoteEndpointOf(object channel)
|
||||
{
|
||||
var socketProperty = FindProperty(channel.GetType(), "Socket");
|
||||
var socket = socketProperty?.GetValue(channel);
|
||||
if (socket is null) return null;
|
||||
var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint;
|
||||
if (remote is null) return null;
|
||||
return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote;
|
||||
}
|
||||
|
||||
private static FieldInfo? FindField(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PropertyInfo? FindProperty(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user