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:
Joseph Doherty
2026-11-11 01:23:33 -05:00
parent 25a1e61141
commit 40969e5c4b
7 changed files with 562 additions and 2 deletions
@@ -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);
}