fix(opcua): failed sign-ins no longer lock a client out of anonymous sessions (#833)
OPC Foundation SDK 2.0.1's SessionManager lockout refused EVERY activation (Anonymous included, before the token is read) from a client with 5 failed identity validations in 60 s, for 5 minutes, keyed on cert thumbprint else peer IP. Disable it (MaxFailedAuthenticationAttempts=0 on built and loaded configs) and enforce FailedCredentialLockout in the identity-registry adapter instead: UserName activations only, keyed on thumbprint+peer together, same 5/60s/300s defaults, configurable via OpcUa:CredentialLockout.
This commit is contained in:
@@ -206,6 +206,12 @@ Two things to know before relying on any of this:
|
||||
the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may
|
||||
**not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather
|
||||
than admitting with no roles.
|
||||
- ⚠️ **The SDK's failed-auth lockout is OFF; ours is UserName-only (#833).** SDK 2.0.1 `SessionManager` refuses
|
||||
EVERY activation (Anonymous included, before the token is read) from a client with 5 failures in 60 s, for 5 min,
|
||||
keyed cert-thumbprint-else-peer-IP. `OpcUaApplicationHost.DisableSdkAuthenticationLockout` zeroes
|
||||
`MaxFailedAuthenticationAttempts` on built AND loaded configs; `FailedCredentialLockout` (`OpcUa:CredentialLockout`,
|
||||
5 / 60 s / 300 s) counts and refuses UserName activations only, keyed thumbprint+peer together. Do not re-enable the
|
||||
SDK setting. `docs/security.md` §"Failed-credential lockout".
|
||||
- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots
|
||||
the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is
|
||||
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
|
||||
|
||||
@@ -74,6 +74,9 @@ Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the
|
||||
| `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). |
|
||||
| `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). |
|
||||
| `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). |
|
||||
| `CredentialLockout:MaxFailedAttempts` | int | `5` | Failed UserName (API-key) activations from one client before its UserName activations are refused `BadUserAccessDenied` (#833). `≤ 0` disables. **Never refuses Anonymous.** See [`security.md`](security.md#failed-credential-lockout-opcuacredentiallockout). |
|
||||
| `CredentialLockout:FailureWindowSeconds` | int | `60` | A failure more than this long after the client's previous one starts a fresh count. |
|
||||
| `CredentialLockout:LockoutSeconds` | int | `300` | How long a locked-out client's UserName activations are refused. |
|
||||
|
||||
> **Transport security profiles** (the values in `EnabledSecurityProfiles` — `None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`) and the PKI trust flow are documented in full in [`security.md`](security.md). This page does not duplicate them.
|
||||
|
||||
|
||||
@@ -313,6 +313,40 @@ charged process-wide only — never handed a fresh budget. Clients behind one NA
|
||||
guard applies to a driver-only node: its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never
|
||||
wrapped.
|
||||
|
||||
### Failed-credential lockout (`OpcUa:CredentialLockout`)
|
||||
|
||||
**A failed sign-in never locks a client out of anonymous access (#833).** OPC Foundation SDK 2.0.1's
|
||||
`SessionManager` has its own lockout: after `ServerConfiguration.MaxFailedAuthenticationAttempts` (SDK
|
||||
default **5**) failed identity validations — each within 60 s of the previous — it refuses **every**
|
||||
`ActivateSession` from that client for **5 minutes** with `BadUserAccessDenied` ("Too many failed
|
||||
authentication attempts"). Its check runs before the identity token is looked at, so it refused
|
||||
**Anonymous** too (even a namespace-array read needs an activated session), a successful anonymous
|
||||
activation did not clear it, and the authenticator was never called (no "anonymous admitted" line). Its key
|
||||
is the client certificate thumbprint when one is presented, else the peer IP — so certificate-less clients
|
||||
behind one NAT share a key, and on a `None` endpoint a presented certificate is not proven. That is what
|
||||
the docker-dev live gate hit: four failed `multi-role` UserName activations against central-1, then every
|
||||
anonymous session refused for five minutes while central-2 (which never saw the failures) kept admitting.
|
||||
|
||||
The host now sets `MaxFailedAuthenticationAttempts = 0` on every configuration it builds or loads
|
||||
(`OpcUaApplicationHost.DisableSdkAuthenticationLockout`) and enforces a narrower replacement,
|
||||
`FailedCredentialLockout`, in its identity-registry adapter:
|
||||
|
||||
- **UserName activations only.** A failed one counts; at `MaxFailedAttempts` (5) within
|
||||
`FailureWindowSeconds` (60) of each other, that client's **UserName** activations are refused
|
||||
`BadUserAccessDenied` for `LockoutSeconds` (300) without the key store being consulted, and a Warning is
|
||||
logged once. A refusal during the lockout is not counted (it never extends itself); a successful UserName
|
||||
activation clears the count. **Anonymous activations never consult it**, from any client.
|
||||
- **Keyed on certificate thumbprint *and* peer address together**, so locking a client out needs both its
|
||||
certificate and its source address, and NAT neighbours with different certificates do not share a count.
|
||||
- Every denial counts, including a key-store timeout or miss-budget refusal (as the SDK's did) — a ConfigDb
|
||||
outage can therefore lock a keyed client out for up to `LockoutSeconds` after recovery.
|
||||
- Bounded at 4096 tracked clients (expired entries are pruned; a client arriving at a full table is not
|
||||
tracked). API-key secrets are not guessable, so this is defence in depth, not the brute-force control.
|
||||
|
||||
Pinned over the wire by `CredentialLockoutWireTests` (OpcUaServer.IntegrationTests — anonymous admitted after
|
||||
six failures; a valid key refused while locked out; `MaxFailedAttempts = 0` admits after seven failures,
|
||||
proving the SDK lockout is off) and unit-tested by `FailedCredentialLockoutTests`.
|
||||
|
||||
### LDAP bind flow (`OtOpcUaLdapAuthService`)
|
||||
|
||||
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
|
||||
|
||||
@@ -113,6 +113,12 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// own transport defaults, so an absent section changes nothing.
|
||||
/// </summary>
|
||||
public OpcUaQuotaOptions Quotas { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Failed-credential lockout for UserName activations (<c>OpcUa:CredentialLockout:*</c>, lmxopcua#833).
|
||||
/// Replaces the SDK's own lockout, which also refused Anonymous; see <see cref="FailedCredentialLockout"/>.
|
||||
/// </summary>
|
||||
public OpcUaCredentialLockoutOptions CredentialLockout { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -175,6 +181,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private StandardServer? _server;
|
||||
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
|
||||
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
||||
private readonly FailedCredentialLockout _credentialLockout;
|
||||
private readonly object _lifetimeLock = new();
|
||||
private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _disposeStarted;
|
||||
@@ -191,6 +198,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
||||
_credentialLockout = new FailedCredentialLockout(options.CredentialLockout ?? new OpcUaCredentialLockoutOptions());
|
||||
}
|
||||
|
||||
/// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
|
||||
@@ -260,14 +268,47 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription);
|
||||
var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress;
|
||||
|
||||
// lmxopcua#833 — only credential-bearing (UserName) activations are counted and refused. Anonymous
|
||||
// never consults the lockout, so failed sign-ins cannot lock a client (or its NAT neighbours) out of
|
||||
// anonymous access the way the SDK's own lockout did.
|
||||
var lockoutKey = tokenType == UserTokenType.UserName
|
||||
? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer)
|
||||
: null;
|
||||
if (lockoutKey is not null && host._credentialLockout.IsLockedOut(lockoutKey, out var remaining))
|
||||
{
|
||||
var seconds = (long)Math.Ceiling(remaining.TotalSeconds);
|
||||
host._logger.LogInformation(
|
||||
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out for {Seconds}s more",
|
||||
lockoutKey, seconds);
|
||||
return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied,
|
||||
$"Too many failed authentication attempts. Try again in {seconds} seconds."));
|
||||
}
|
||||
|
||||
// Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
|
||||
await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous,
|
||||
peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false);
|
||||
return ServiceResult.IsBad(args.IdentityValidationError)
|
||||
var result = ServiceResult.IsBad(args.IdentityValidationError)
|
||||
? AuthenticationResult.Reject(args.IdentityValidationError)
|
||||
: args.Identity is { } identity
|
||||
? AuthenticationResult.Accept(identity)
|
||||
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected));
|
||||
|
||||
if (lockoutKey is not null)
|
||||
{
|
||||
if (args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError))
|
||||
{
|
||||
host._credentialLockout.RecordSuccess(lockoutKey);
|
||||
}
|
||||
else if (host._credentialLockout.RecordFailure(lockoutKey))
|
||||
{
|
||||
host._logger.LogWarning(
|
||||
"OpcUaApplicationHost: client {Client} locked out of UserName activation for {Seconds}s after "
|
||||
+ "{Attempts} failed attempts (OpcUa:CredentialLockout); anonymous activation is unaffected",
|
||||
lockoutKey, host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +529,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
|
||||
{
|
||||
return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
|
||||
var loaded = await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
|
||||
if (loaded.ServerConfiguration is { } loadedServer) DisableSdkAuthenticationLockout(loadedServer);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
@@ -496,6 +539,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"],
|
||||
};
|
||||
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||
DisableSdkAuthenticationLockout(serverConfig);
|
||||
|
||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
||||
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
||||
@@ -531,6 +575,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches off the SDK's per-client failed-authentication lockout (lmxopcua#833). It refuses EVERY
|
||||
/// activation from a locked client — Anonymous included — for 5 minutes; <see cref="FailedCredentialLockout"/>
|
||||
/// enforces the UserName-only replacement.
|
||||
/// </summary>
|
||||
/// <param name="serverConfig">The configuration to set.</param>
|
||||
internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) =>
|
||||
serverConfig.MaxFailedAuthenticationAttempts = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
|
||||
/// 5 / 100 / 200) to the SDK server configuration.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>OpcUa:CredentialLockout</c> section: how many failed UserName activations one client may make
|
||||
/// before its UserName activations are refused for a while (lmxopcua#833).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The defaults are the OPC Foundation SDK 2.0.1 <c>SessionManager</c> lockout this replaces: 5 failures,
|
||||
/// each inside 60 s of the previous one, lock the client out for 5 minutes.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaCredentialLockoutOptions
|
||||
{
|
||||
/// <summary>Failed UserName activations that lock a client out; <c>0</c> or less disables the lockout.</summary>
|
||||
public int MaxFailedAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>A failure more than this many seconds after the previous one starts a fresh count.</summary>
|
||||
public int FailureWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>How long a locked-out client's UserName activations are refused, in seconds.</summary>
|
||||
public int LockoutSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brute-force lockout for UserName (API-key) activations, scoped so it can never refuse an
|
||||
/// <b>anonymous</b> activation (lmxopcua#833).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists instead of the SDK's own lockout.</b> OPC Foundation SDK 2.0.1's
|
||||
/// <c>SessionManager.ActivateSessionAsync</c> counts every failed identity validation per client and,
|
||||
/// at <c>ServerConfiguration.MaxFailedAuthenticationAttempts</c> (default 5), refuses <b>every</b>
|
||||
/// activation from that client for 5 minutes with <c>BadUserAccessDenied</c> — the lockout check runs
|
||||
/// before the token is even looked at, so it refuses Anonymous too, and a successful anonymous
|
||||
/// activation does not clear it. Its key is the client certificate thumbprint when one is presented,
|
||||
/// else the peer IP: on a <c>None</c> endpoint the presented certificate is not proven, so a client
|
||||
/// could lock out another client's sessions by presenting its certificate, and behind a NAT every
|
||||
/// certificate-less client shares one key. The host therefore sets
|
||||
/// <c>MaxFailedAuthenticationAttempts = 0</c> and enforces this narrower lockout instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it does.</b> Only UserName activations are counted and refused. The key is the presented
|
||||
/// client certificate thumbprint <b>and</b> the observed peer address together, so locking a victim
|
||||
/// out needs both its certificate and its source address. Same timing as the SDK: a failure within
|
||||
/// <see cref="OpcUaCredentialLockoutOptions.FailureWindowSeconds"/> of the previous one adds to the
|
||||
/// count; at <see cref="OpcUaCredentialLockoutOptions.MaxFailedAttempts"/> the client is refused for
|
||||
/// <see cref="OpcUaCredentialLockoutOptions.LockoutSeconds"/>; a refusal during the lockout is not
|
||||
/// itself counted (the lockout never extends itself); a successful UserName activation clears the
|
||||
/// client's count.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Memory is bounded at <see cref="MaxTrackedClients"/>: expired entries are pruned when it fills, and
|
||||
/// a client that still finds it full is not tracked (it can only evade by rotating certificates or
|
||||
/// addresses, which it could do against the SDK lockout too; API-key secrets are not guessable).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FailedCredentialLockout
|
||||
{
|
||||
/// <summary>The most clients tracked at once.</summary>
|
||||
public const int MaxTrackedClients = 4096;
|
||||
|
||||
private readonly OpcUaCredentialLockoutOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FailedCredentialLockout"/> class.</summary>
|
||||
/// <param name="options">The configured thresholds.</param>
|
||||
/// <param name="time">The clock; <see cref="TimeProvider.System"/> when null.</param>
|
||||
public FailedCredentialLockout(OpcUaCredentialLockoutOptions options, TimeProvider? time = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>Whether the lockout is switched on.</summary>
|
||||
public bool Enabled => _options.MaxFailedAttempts > 0;
|
||||
|
||||
/// <summary>Builds the lockout key for a client.</summary>
|
||||
/// <param name="certificateThumbprint">The presented client certificate's thumbprint, if any.</param>
|
||||
/// <param name="peer">The transport-observed peer address, if known.</param>
|
||||
/// <returns>The key; never null.</returns>
|
||||
public static string KeyFor(string? certificateThumbprint, System.Net.IPAddress? peer) =>
|
||||
$"{(string.IsNullOrEmpty(certificateThumbprint) ? "-" : certificateThumbprint)}@{peer?.ToString() ?? "-"}";
|
||||
|
||||
/// <summary>Whether <paramref name="key"/> is locked out right now.</summary>
|
||||
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
|
||||
/// <param name="remaining">The time left on the lockout when this returns true.</param>
|
||||
/// <returns><see langword="true"/> when the client's UserName activations must be refused.</returns>
|
||||
public bool IsLockedOut(string key, out TimeSpan remaining)
|
||||
{
|
||||
remaining = TimeSpan.Zero;
|
||||
if (!Enabled) return false;
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry)) return false;
|
||||
if (entry.LockedUntil > now)
|
||||
{
|
||||
remaining = entry.LockedUntil - now;
|
||||
return true;
|
||||
}
|
||||
if (IsExpired(entry, now)) _entries.Remove(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records one failed UserName activation.</summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
/// <returns><see langword="true"/> when this failure locked the client out.</returns>
|
||||
public bool RecordFailure(string key)
|
||||
{
|
||||
if (!Enabled) return false;
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.LockedUntil > now) return false; // a refusal never extends the lockout
|
||||
entry = IsExpired(entry, now) ? new Entry(1, now, default) : entry with { Failures = entry.Failures + 1, LastFailure = now };
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_entries.Count >= MaxTrackedClients) Prune(now);
|
||||
if (_entries.Count >= MaxTrackedClients) return false;
|
||||
entry = new Entry(1, now, default);
|
||||
}
|
||||
|
||||
if (entry.Failures >= _options.MaxFailedAttempts)
|
||||
entry = entry with { LockedUntil = now + TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds)) };
|
||||
_entries[key] = entry;
|
||||
return entry.LockedUntil > now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears a client's count after a successful UserName activation.</summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
public void RecordSuccess(string key)
|
||||
{
|
||||
lock (_gate) _entries.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>How many clients are tracked (test observation).</summary>
|
||||
internal int TrackedCount
|
||||
{
|
||||
get { lock (_gate) return _entries.Count; }
|
||||
}
|
||||
|
||||
private bool IsExpired(Entry entry, DateTimeOffset now) =>
|
||||
entry.LockedUntil <= now && now - entry.LastFailure > TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds));
|
||||
|
||||
private void Prune(DateTimeOffset now)
|
||||
{
|
||||
foreach (var key in _entries.Where(e => IsExpired(e.Value, now)).Select(e => e.Key).ToList())
|
||||
_entries.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>One client's failure count and lockout.</summary>
|
||||
private readonly record struct Entry(int Failures, DateTimeOffset LastFailure, DateTimeOffset LockedUntil);
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
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");
|
||||
}
|
||||
|
||||
[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, []));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.</summary>
|
||||
public sealed class FailedCredentialLockoutTests
|
||||
{
|
||||
private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5"));
|
||||
|
||||
[Fact]
|
||||
public void Locks_at_the_threshold_and_releases_after_the_lockout()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks");
|
||||
lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue();
|
||||
remaining.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(299));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
||||
clock.Advance(TimeSpan.FromSeconds(1));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_refusal_during_the_lockout_does_not_extend_it()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock);
|
||||
lockout.RecordFailure(Client).ShouldBeTrue();
|
||||
clock.Advance(TimeSpan.FromSeconds(200));
|
||||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
clock.Advance(TimeSpan.FromSeconds(100));
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failures_spaced_wider_than_the_window_never_accumulate()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
}
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_clears_the_count()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client);
|
||||
lockout.RecordSuccess(Client);
|
||||
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
||||
var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99"));
|
||||
var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5"));
|
||||
var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99"));
|
||||
|
||||
lockout.RecordFailure(attacker).ShouldBeTrue();
|
||||
lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address");
|
||||
lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_attempts_disables_it()
|
||||
{
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock());
|
||||
for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
||||
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
||||
lockout.TrackedCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracking_is_bounded_and_prunes_expired_clients_first()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
||||
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
|
||||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
|
||||
|
||||
lockout.RecordFailure("overflow");
|
||||
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked");
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
lockout.RecordFailure("overflow");
|
||||
lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_host_switches_the_sdk_lockout_off()
|
||||
{
|
||||
var config = new Opc.Ua.ServerConfiguration();
|
||||
config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against");
|
||||
OpcUaApplicationHost.DisableSdkAuthenticationLockout(config);
|
||||
config.MaxFailedAuthenticationAttempts.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user