This commit is contained in:
+7
@@ -39,5 +39,12 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
|
||||
+ "OpcUa:CredentialLockout:SlotWaitSeconds must all be "
|
||||
+ "greater than 0 while OpcUa:CredentialLockout:MaxFailedAttempts is greater than 0 (set "
|
||||
+ "MaxFailedAttempts to 0 to disable the lockout)");
|
||||
|
||||
// #844: the per-client cap (which a success never resets) below the per-credential threshold would make every
|
||||
// per-credential lockout a client-wide one; 0 means 2 x MaxFailedAttempts.
|
||||
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0 || lockout.MaxFailedAttemptsPerClient <= 0
|
||||
|| lockout.MaxFailedAttemptsPerClient >= lockout.MaxFailedAttempts,
|
||||
"OpcUa:CredentialLockout:MaxFailedAttemptsPerClient must be 0 (= 2 x MaxFailedAttempts) or at least "
|
||||
+ "OpcUa:CredentialLockout:MaxFailedAttempts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +107,11 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
|
||||
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
|
||||
// lmxopcua#845: a store timeout, miss-budget refusal or outage says nothing about the credential, so it
|
||||
// is reported Transient and the failed-credential lockout does not count it (a ConfigDb stall must not
|
||||
// lock every keyed client out). The client still sees the same opaque denial.
|
||||
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
|
||||
return OpcUaUserAuthResult.Deny(DenyMessage);
|
||||
return OpcUaUserAuthResult.Unavailable(DenyMessage);
|
||||
}
|
||||
|
||||
if (lookup is null)
|
||||
@@ -129,7 +132,7 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
_log.LogError(ex,
|
||||
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
|
||||
ApiKeyOptions.SectionName);
|
||||
return OpcUaUserAuthResult.Deny(DenyMessage);
|
||||
return OpcUaUserAuthResult.Unavailable(DenyMessage); // a deployment error, not a bad credential (#845)
|
||||
}
|
||||
|
||||
if (!verified)
|
||||
@@ -161,6 +164,13 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// lmxopcua#844 — the key id is public (it is the lookup index, never the secret), so guesses at different keys
|
||||
// are counted apart and a client's own successful key cannot clear the count of a key it is guessing at.
|
||||
// A credential that is not a well-formed key falls back to the username (all such guesses share one count).
|
||||
public string DescribeCredential(string username, string password) =>
|
||||
ApiKeySecret.TryParse(password, out var apiKeyId, out _) ? "key:" + apiKeyId : "user:" + username;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
|
||||
// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
|
||||
|
||||
@@ -272,22 +272,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// 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.
|
||||
// The ApplicationUri is deliberately NOT part of the key (lmxopcua#845): on a None channel it is a free,
|
||||
// unauthenticated string, so an attacker could reset its count by varying it.
|
||||
var lockoutKey = tokenType == UserTokenType.UserName
|
||||
? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer)
|
||||
: null;
|
||||
// lmxopcua#844 — failures are counted per (client, credential identity) as well as per client, so a
|
||||
// success clears only its own identity's count.
|
||||
var credentialIdentity = lockoutKey is null
|
||||
? FailedCredentialLockout.NoIdentity
|
||||
: DescribeCredential(host._userAuthenticator, args.UserIdentityTokenHandler);
|
||||
if (lockoutKey is not null)
|
||||
{
|
||||
// Waits (bounded) for one of this client's verification slots — parallel activations queue, they are
|
||||
// never refused for parallelism. Refused only when locked out, or (uncounted) when no slot freed in time.
|
||||
var decision = await host._credentialLockout.BeginAsync(lockoutKey, ct).ConfigureAwait(false);
|
||||
var decision = await host._credentialLockout.BeginAsync(lockoutKey, credentialIdentity, ct).ConfigureAwait(false);
|
||||
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
|
||||
{
|
||||
// Logged once per lockout episode at Information; every further refusal at Debug, so a client
|
||||
// hammering a lockout cannot flood the log (#833 review).
|
||||
var seconds = (long)Math.Ceiling(decision.Remaining.TotalSeconds);
|
||||
host._logger.Log(decision.FirstRefusal ? LogLevel.Information : LogLevel.Debug,
|
||||
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out for {Seconds}s more",
|
||||
lockoutKey, seconds);
|
||||
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out ({Scope}: {Identity}) for {Seconds}s more",
|
||||
lockoutKey, decision.Scope,
|
||||
decision.Scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity, seconds);
|
||||
return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied,
|
||||
$"Too many failed authentication attempts. Try again in {seconds} seconds."));
|
||||
}
|
||||
@@ -309,8 +317,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
// 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);
|
||||
var transient = await HandleImpersonationAsync(host._userAuthenticator, args, host._logger,
|
||||
host._options.AllowAnonymous, peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct)
|
||||
.ConfigureAwait(false);
|
||||
var result = ServiceResult.IsBad(args.IdentityValidationError)
|
||||
? AuthenticationResult.Reject(args.IdentityValidationError)
|
||||
: args.Identity is { } identity
|
||||
@@ -321,19 +330,29 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
var admitted = args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError);
|
||||
completed = true;
|
||||
if (host._credentialLockout.Complete(lockoutKey, admitted))
|
||||
if (!admitted && transient)
|
||||
{
|
||||
// lmxopcua#845 — a store timeout / miss-budget refusal / authenticator fault is not a bad
|
||||
// credential: release the slot uncounted.
|
||||
host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
|
||||
}
|
||||
else if (host._credentialLockout.Complete(lockoutKey, credentialIdentity, admitted) is var scope
|
||||
&& scope != CredentialLockoutScope.None)
|
||||
{
|
||||
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);
|
||||
"OpcUaApplicationHost: client {Client} locked out of UserName activation ({Scope}: {Identity}) "
|
||||
+ "for {Seconds}s after repeated failed attempts (OpcUa:CredentialLockout: {PerCredential} per "
|
||||
+ "credential, {PerClient} per client); anonymous activation is unaffected",
|
||||
lockoutKey, scope, scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity,
|
||||
host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts,
|
||||
host._credentialLockout.ClientThreshold);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey);
|
||||
if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,7 +426,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <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(
|
||||
/// <returns><see langword="true"/> when a UserName denial was TRANSIENT — not a verdict on the credential
|
||||
/// (the authenticator threw or reported <see cref="OpcUaUserAuthResult.Transient"/>), so the failed-credential
|
||||
/// lockout must not count it (lmxopcua#845).</returns>
|
||||
internal static bool HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger,
|
||||
@@ -416,7 +438,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
=> HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
|
||||
.AsTask().GetAwaiter().GetResult();
|
||||
|
||||
private static async ValueTask HandleImpersonationAsync(
|
||||
private static async ValueTask<bool> HandleImpersonationAsync(
|
||||
IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
|
||||
bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -424,13 +446,13 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
|
||||
{
|
||||
// X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
string password;
|
||||
@@ -444,7 +466,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
@@ -456,22 +478,45 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Includes cancellation: neither says anything about the credential (lmxopcua#845).
|
||||
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
|
||||
token.UserName, result.Error);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}{Transient}: {Error}",
|
||||
token.UserName, result.Transient ? " (transient, not counted toward the lockout)" : string.Empty, result.Error);
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
|
||||
return;
|
||||
return result.Transient;
|
||||
}
|
||||
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
|
||||
token.UserName, result.Principal, string.Join(",", result.Roles));
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The credential identity a UserName activation presents, for the lockout's per-identity count
|
||||
/// (lmxopcua#844): <see cref="IOpcUaUserAuthenticator.DescribeCredential"/> over the decrypted token. Never
|
||||
/// throws: an unreadable token or a faulting implementation falls back to the username.
|
||||
/// </summary>
|
||||
internal static string DescribeCredential(IOpcUaUserAuthenticator authenticator, IUserIdentityTokenHandler handler)
|
||||
{
|
||||
if (handler.Token is not UserNameIdentityToken token) return FailedCredentialLockout.NoIdentity;
|
||||
var username = token.UserName ?? string.Empty;
|
||||
try
|
||||
{
|
||||
var decrypted = (handler as UserNameIdentityTokenHandler)?.DecryptedPassword;
|
||||
var password = decrypted is null ? string.Empty : Encoding.UTF8.GetString(decrypted);
|
||||
return authenticator.DescribeCredential(username, password) ?? "user:" + username;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "user:" + username;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1387,7 +1387,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// subscriptions, per item, through the ACL (see OnConditionReportEvent).
|
||||
var snapshot = new InstanceStateSnapshot();
|
||||
snapshot.Initialize(SystemContext, alarm);
|
||||
alarm.ReportEvent(SystemContext, snapshot);
|
||||
ReportEventWithoutBlocking(alarm, snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1400,6 +1400,95 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes <see cref="_eventReportChain"/>.</summary>
|
||||
private readonly object _eventReportGate = new();
|
||||
|
||||
/// <summary>The tail of the deferred condition-event reports (Gitea #838); completed when none is pending.</summary>
|
||||
private Task _eventReportChain = Task.CompletedTask;
|
||||
|
||||
private int _deferredEventReports;
|
||||
|
||||
/// <summary>Condition-event reports currently deferred because a notifier's delivery channel was full (test observation).</summary>
|
||||
internal int DeferredEventReports => Volatile.Read(ref _deferredEventReports);
|
||||
|
||||
/// <summary>
|
||||
/// Reports a condition event through the condition's notifier chain WITHOUT blocking the caller, in report
|
||||
/// order (Gitea #838).
|
||||
/// </summary>
|
||||
/// <param name="source">The condition.</param>
|
||||
/// <param name="e">The frozen snapshot of the event.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why not <c>NodeState.ReportEvent</c>.</b> Each notifier with event items has an SDK
|
||||
/// <c>MonitoredNode2</c> whose delivery task drains a BOUNDED channel (4096, <c>FullMode = Wait</c>), and
|
||||
/// the synchronous <c>ReportEvent</c> BLOCKS when that channel is full. Condition events are reported
|
||||
/// under <c>Lock</c>, and the delivery task takes <c>Lock</c> on every per-event verdict-cache miss (the
|
||||
/// SDK's own <c>GetNodeMetadata</c>, and #826's <see cref="IsNotifierEventVisibleTo"/>). So a burst of more
|
||||
/// than 4096 events under one hold of <c>Lock</c>, starting at a miss, parked the consumer on <c>Lock</c>
|
||||
/// while the producer waited on the consumer: a deadlock that froze the whole node manager (pinned by
|
||||
/// <c>FolderEventFloodWireTests</c>, which stalled at exactly 4097 events before this).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What this does.</b> The common case is unchanged and synchronous: <c>ReportEventAsync</c> completes
|
||||
/// inline while every channel has room. Only when a channel is full does that report continue
|
||||
/// asynchronously (awaiting channel space, holding no thread and no lock), and every later report queues
|
||||
/// behind it, so events still leave in the order they were reported. The deferred work holds only frozen
|
||||
/// snapshots, so it is bounded by the burst that caused it and drains once <c>Lock</c> is released.
|
||||
/// Delivery to clients stays bounded by each monitored item's own queue (oldest discarded). A deferred
|
||||
/// report runs the condition's synchronous <c>OnReportEvent</c> hook (<see cref="OnConditionReportEvent"/>)
|
||||
/// off <c>Lock</c>; that hook takes <c>Lock</c> itself where it reads guarded state.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ReportEventWithoutBlocking(NodeState source, IFilterTarget e)
|
||||
{
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
if (_eventReportChain.IsCompleted)
|
||||
{
|
||||
var report = source.ReportEventAsync(SystemContext, e);
|
||||
if (report.IsCompletedSuccessfully) return;
|
||||
Interlocked.Increment(ref _deferredEventReports);
|
||||
_eventReportChain = ObserveEventReportAsync(report.AsTask(), source);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _deferredEventReports);
|
||||
_eventReportChain = ReportAfterAsync(_eventReportChain, source, e);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportAfterAsync(Task previous, NodeState source, IFilterTarget e)
|
||||
{
|
||||
await previous.ConfigureAwait(false); // never faults: every link observes its own failure
|
||||
Task report;
|
||||
try
|
||||
{
|
||||
report = source.ReportEventAsync(SystemContext, e).AsTask();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
report = Task.FromException(ex);
|
||||
}
|
||||
|
||||
await ObserveEventReportAsync(report, source).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ObserveEventReportAsync(Task report, NodeState source)
|
||||
{
|
||||
try
|
||||
{
|
||||
await report.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", source.NodeId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _deferredEventReports);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
|
||||
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
|
||||
|
||||
@@ -12,9 +12,23 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
/// </remarks>
|
||||
public sealed class OpcUaCredentialLockoutOptions
|
||||
{
|
||||
/// <summary>Failed UserName activations that lock a client out; <c>0</c> or less disables the lockout.</summary>
|
||||
/// <summary>
|
||||
/// Failed UserName activations for ONE credential identity (an API key id, else the username) from one client
|
||||
/// that lock that client out of using that identity; <c>0</c> or less disables the whole lockout. A success
|
||||
/// with the same identity clears its count (lmxopcua#844).
|
||||
/// </summary>
|
||||
public int MaxFailedAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Failed UserName activations from one client, across EVERY identity, that lock the client out of all
|
||||
/// UserName activations (lmxopcua#844). A success does NOT reset this count — it only ages out through
|
||||
/// <see cref="FailureWindowSeconds"/> or an expired lockout — so a client cannot make
|
||||
/// <see cref="MaxFailedAttempts"/> − 1 guesses at someone else's key, sign in with its own, and repeat; nor
|
||||
/// spray a few guesses at each of many key ids. <c>0</c> (the default) means 2 × <see cref="MaxFailedAttempts"/>;
|
||||
/// an explicit value must be at least <see cref="MaxFailedAttempts"/>.
|
||||
/// </summary>
|
||||
public int MaxFailedAttemptsPerClient { get; set; }
|
||||
|
||||
/// <summary>A failure more than this many seconds after the previous one starts a fresh count.</summary>
|
||||
public int FailureWindowSeconds { get; set; } = 60;
|
||||
|
||||
@@ -31,29 +45,45 @@ public sealed class OpcUaCredentialLockoutOptions
|
||||
public int SlotWaitSeconds { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>The outcome of <see cref="FailedCredentialLockout.BeginAsync"/>.</summary>
|
||||
/// <summary>The outcome of <see cref="FailedCredentialLockout.BeginAsync(string, string, CancellationToken)"/>.</summary>
|
||||
public enum CredentialLockoutVerdict
|
||||
{
|
||||
/// <summary>A verification slot is held — authenticate, then call <see cref="FailedCredentialLockout.Complete"/>.</summary>
|
||||
/// <summary>A verification slot is held — authenticate, then call <see cref="FailedCredentialLockout.Complete(string, string, bool)"/>.</summary>
|
||||
Proceed,
|
||||
|
||||
/// <summary>The client is locked out; refuse without authenticating.</summary>
|
||||
/// <summary>The client, or the credential identity it presented, is locked out; refuse without authenticating.</summary>
|
||||
LockedOut,
|
||||
|
||||
/// <summary>No slot freed within the wait; fail this one attempt, uncounted.</summary>
|
||||
SlotWaitTimedOut,
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="FailedCredentialLockout.BeginAsync"/> decided.</summary>
|
||||
/// <summary>What <see cref="FailedCredentialLockout.BeginAsync(string, string, CancellationToken)"/> decided.</summary>
|
||||
/// <param name="Verdict">Proceed, locked out, or timed out waiting.</param>
|
||||
/// <param name="Remaining">Time left on the lockout when <see cref="CredentialLockoutVerdict.LockedOut"/>.</param>
|
||||
/// <param name="FirstRefusal">True for the first refusal of a lockout episode (log it at Information, the rest at Debug).</param>
|
||||
/// <param name="Scope">What is locked out when <see cref="CredentialLockoutVerdict.LockedOut"/>.</param>
|
||||
public readonly record struct CredentialLockoutDecision(
|
||||
CredentialLockoutVerdict Verdict, TimeSpan Remaining, bool FirstRefusal);
|
||||
CredentialLockoutVerdict Verdict, TimeSpan Remaining, bool FirstRefusal,
|
||||
CredentialLockoutScope Scope = CredentialLockoutScope.None);
|
||||
|
||||
/// <summary>What a lockout covers (lmxopcua#844).</summary>
|
||||
public enum CredentialLockoutScope
|
||||
{
|
||||
/// <summary>Nothing is locked.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>One credential identity (key id / username) from one client.</summary>
|
||||
Credential,
|
||||
|
||||
/// <summary>Every UserName activation from one client.</summary>
|
||||
Client,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brute-force lockout for UserName (API-key) activations, scoped so it can never refuse an
|
||||
/// <b>anonymous</b> activation (lmxopcua#833).
|
||||
/// <b>anonymous</b> activation (lmxopcua#833), and counted per credential identity as well as per client
|
||||
/// (lmxopcua#844).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -62,42 +92,56 @@ public readonly record struct CredentialLockoutDecision(
|
||||
/// 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.
|
||||
/// activation does not clear it. 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; Anonymous never consults it.
|
||||
/// The key is the presented client certificate thumbprint <b>and</b> the observed peer address together.
|
||||
/// ⚠️ That only separates clients that present a certificate: on a <c>None</c> endpoint clients commonly
|
||||
/// present none, so the key is <b>effectively the IP alone</b> and NAT neighbours there share one count
|
||||
/// (for UserName activations only — never for anonymous ones); and when the transport reports no peer
|
||||
/// address, every certificate-less client shares the single key <c>-@-</c>. Timing: 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
|
||||
/// counted (the lockout never extends itself); an expired lockout starts a fresh count (whatever the
|
||||
/// window); a successful UserName activation clears the client's count.
|
||||
/// <b>Client key.</b> Only UserName activations are counted and refused; Anonymous never consults it. The
|
||||
/// client key is the presented client certificate thumbprint <b>and</b> the observed peer address together.
|
||||
/// ⚠️ On a <c>None</c> endpoint clients commonly present no certificate, so the key is <b>effectively the IP
|
||||
/// alone</b> and NAT neighbours there share the client-wide count (UserName only). The client's
|
||||
/// <c>ApplicationUri</c> is deliberately NOT part of the key (lmxopcua#845): it is a free, unauthenticated
|
||||
/// string on a <c>None</c> channel, so adding it would let one attacker reset its count by varying it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Concurrency — parallelism waits, it is never refused.</b> The authenticate call is awaited between the
|
||||
/// check and the outcome, so a check and a separate record would let a parallel burst of guesses all pass
|
||||
/// before the first failure lands. <see cref="BeginAsync"/> therefore hands out at most
|
||||
/// <c>MaxFailedAttempts − recordedFailures</c> verification <b>slots</b> per key; an activation beyond that
|
||||
/// <b>waits</b> (FIFO) for a slot, and <see cref="Complete"/> / <see cref="Abandon"/> release it and wake the
|
||||
/// next waiter. So no more than <c>MaxFailedAttempts</c> unverified guesses are ever in progress-or-failed
|
||||
/// per window, while a legitimate client opening many keyed sessions at once (a ScadaBridge site node behind
|
||||
/// one NAT IP) just queues for milliseconds. Waiters are refused only when the key actually locks out (they
|
||||
/// never reach the authenticator) or after <see cref="OpcUaCredentialLockoutOptions.SlotWaitSeconds"/>
|
||||
/// (uncounted); a cancelled waiter is removed from the queue.
|
||||
/// <b>Two counts (lmxopcua#844).</b> Each failure is charged to the (client, <i>credential identity</i>)
|
||||
/// pair — the API key id, or the username when the credential carries no key id — AND to the client as a
|
||||
/// whole. A success clears only its own identity's count. At
|
||||
/// <see cref="OpcUaCredentialLockoutOptions.MaxFailedAttempts"/> failures of one identity, that client is
|
||||
/// refused <i>that identity</i> for <see cref="OpcUaCredentialLockoutOptions.LockoutSeconds"/>; at the
|
||||
/// per-client cap (<see cref="ClientThreshold"/>, default 2× that), which a success never resets, the client
|
||||
/// is refused <i>every</i> UserName activation. So a client holding a valid key can no longer launder guesses
|
||||
/// at another key id by signing in with its own between them (the success does not touch the victim
|
||||
/// identity's count), and spraying a few guesses at each of many key ids still hits the client-wide cap. A
|
||||
/// legitimate client with one stale key and one good key loses only the stale one. Timing per count: a
|
||||
/// failure more than <see cref="OpcUaCredentialLockoutOptions.FailureWindowSeconds"/> after the previous one
|
||||
/// of that count starts it afresh; a refusal during a lockout is not counted; an expired lockout starts a
|
||||
/// fresh count (an expired client-wide lockout clears every identity's too).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Memory is bounded at <see cref="MaxTrackedClients"/>: entries with no failures, slots or waiters 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).
|
||||
/// <b>Only a definite bad credential counts (lmxopcua#845).</b> The caller completes a transient denial — a
|
||||
/// key-store timeout, a miss-budget refusal, an authenticator exception, cancellation — with
|
||||
/// <see cref="Abandon(string, string)"/>, which releases the slot uncounted; a ConfigDb stall therefore
|
||||
/// cannot lock every keyed client out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Concurrency — parallelism waits, it is never refused.</b>
|
||||
/// <see cref="BeginAsync(string, string, CancellationToken)"/> hands out a verification <b>slot</b> only
|
||||
/// while both <c>identityFailures + identityInFlight < MaxFailedAttempts</c> and
|
||||
/// <c>clientFailures + clientInFlight < ClientThreshold</c>; an activation beyond that <b>waits</b> for a
|
||||
/// slot (a waiter whose identity has room is not held behind one whose identity has none). No more
|
||||
/// unverified guesses than either threshold are ever in progress-or-failed, while a legitimate client opening
|
||||
/// many keyed sessions at once (a ScadaBridge site node behind one NAT IP) just queues for milliseconds.
|
||||
/// Waiters are refused only when what they need locks out, or (uncounted) after
|
||||
/// <see cref="OpcUaCredentialLockoutOptions.SlotWaitSeconds"/>; a cancelled waiter is dequeued.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Memory is bounded at <see cref="MaxTrackedClients"/> clients and
|
||||
/// <see cref="MaxTrackedIdentitiesPerClient"/> identities per client (identities beyond that share one
|
||||
/// overflow count — conservative, never laxer). Entries with nothing to remember are pruned when the table
|
||||
/// fills; a client that still finds it full is not tracked. When a prune frees nothing, the next one waits
|
||||
/// until the earliest entry could have expired (lmxopcua#845), so a flood of new client keys at a full table
|
||||
/// does not rescan it under the lock on every failure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FailedCredentialLockout
|
||||
@@ -105,10 +149,21 @@ public sealed class FailedCredentialLockout
|
||||
/// <summary>The most clients tracked at once.</summary>
|
||||
public const int MaxTrackedClients = 4096;
|
||||
|
||||
/// <summary>The most credential identities tracked per client; further ones share one overflow count.</summary>
|
||||
public const int MaxTrackedIdentitiesPerClient = 64;
|
||||
|
||||
/// <summary>The identity used when the caller supplies none (a direct caller / test seam).</summary>
|
||||
public const string NoIdentity = "";
|
||||
|
||||
private const string OverflowIdentity = "\0overflow";
|
||||
private const int MaxIdentityLength = 256;
|
||||
|
||||
private readonly OpcUaCredentialLockoutOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly object _gate = new();
|
||||
private DateTimeOffset _nextPruneAt = DateTimeOffset.MinValue;
|
||||
private int _pruneScans;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FailedCredentialLockout"/> class.</summary>
|
||||
/// <param name="options">The configured thresholds.</param>
|
||||
@@ -122,6 +177,13 @@ public sealed class FailedCredentialLockout
|
||||
/// <summary>Whether the lockout is switched on.</summary>
|
||||
public bool Enabled => _options.MaxFailedAttempts > 0;
|
||||
|
||||
/// <summary>The effective per-client cap: the configured value, or 2 × MaxFailedAttempts when unset.</summary>
|
||||
public int ClientThreshold => _options.MaxFailedAttemptsPerClient > 0
|
||||
? _options.MaxFailedAttemptsPerClient
|
||||
: 2 * Math.Max(0, _options.MaxFailedAttempts);
|
||||
|
||||
private int IdentityThreshold => _options.MaxFailedAttempts;
|
||||
|
||||
/// <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>
|
||||
@@ -129,17 +191,28 @@ public sealed class FailedCredentialLockout
|
||||
public static string KeyFor(string? certificateThumbprint, System.Net.IPAddress? peer) =>
|
||||
$"{(string.IsNullOrEmpty(certificateThumbprint) ? "-" : certificateThumbprint)}@{peer?.ToString() ?? "-"}";
|
||||
|
||||
/// <summary><see cref="BeginAsync(string, string, CancellationToken)"/> with <see cref="NoIdentity"/>.</summary>
|
||||
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
|
||||
/// <param name="ct">Cancels the wait.</param>
|
||||
/// <returns>The decision.</returns>
|
||||
public ValueTask<CredentialLockoutDecision> BeginAsync(string key, CancellationToken ct = default) =>
|
||||
BeginAsync(key, NoIdentity, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a verification slot for <paramref name="key"/>, waiting for one when the client already has as
|
||||
/// many in progress as it may. On <see cref="CredentialLockoutVerdict.Proceed"/> the caller MUST release the
|
||||
/// slot with <see cref="Complete"/> or <see cref="Abandon"/>.
|
||||
/// Acquires a verification slot for <paramref name="identity"/> from <paramref name="key"/>, waiting for one
|
||||
/// when the client or the identity already has as many in progress as it may. On
|
||||
/// <see cref="CredentialLockoutVerdict.Proceed"/> the caller MUST release the slot with
|
||||
/// <see cref="Complete(string, string, bool)"/> or <see cref="Abandon(string, string)"/>, passing the same
|
||||
/// identity.
|
||||
/// </summary>
|
||||
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
|
||||
/// <param name="identity">The credential identity (key id, else username) the attempt presents.</param>
|
||||
/// <param name="ct">Cancels the wait; a cancelled waiter is dequeued and the exception propagates.</param>
|
||||
/// <returns>The decision.</returns>
|
||||
public async ValueTask<CredentialLockoutDecision> BeginAsync(string key, CancellationToken ct = default)
|
||||
public async ValueTask<CredentialLockoutDecision> BeginAsync(string key, string identity, CancellationToken ct = default)
|
||||
{
|
||||
if (!Enabled) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
|
||||
identity = Normalize(identity);
|
||||
|
||||
Waiter waiter;
|
||||
Entry entry;
|
||||
@@ -148,24 +221,25 @@ public sealed class FailedCredentialLockout
|
||||
var now = _time.GetUtcNow();
|
||||
if (!_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (_entries.Count >= MaxTrackedClients) Prune(now);
|
||||
// Untracked when still full: it gets no slot accounting, as before (see remarks).
|
||||
if (_entries.Count >= MaxTrackedClients) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
|
||||
existing = new Entry();
|
||||
_entries[key] = existing;
|
||||
existing = TryAddEntry(key, now);
|
||||
// Untracked when still full: it gets no slot accounting (see remarks).
|
||||
if (existing is null) return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
|
||||
}
|
||||
|
||||
entry = existing;
|
||||
Refresh(entry, now);
|
||||
if (TryLockedOut(entry, now, out var refused)) return refused;
|
||||
if (entry.Waiters.Count == 0 && HasFreeSlot(entry))
|
||||
var slotIdentity = SlotIdentity(entry, identity);
|
||||
if (TryLockedOut(entry, slotIdentity, now, out var refused)) return refused;
|
||||
if (entry.Waiters.Count == 0 && HasFreeSlot(entry, slotIdentity))
|
||||
{
|
||||
entry.InFlight++;
|
||||
TakeSlot(entry, slotIdentity);
|
||||
return new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false);
|
||||
}
|
||||
|
||||
waiter = new Waiter();
|
||||
waiter = new Waiter(identity);
|
||||
entry.Waiters.AddLast(waiter.Node = new LinkedListNode<Waiter>(waiter));
|
||||
// A waiter whose identity has room is not held behind one whose identity has none.
|
||||
Pump(entry, now);
|
||||
}
|
||||
|
||||
var wait = TimeSpan.FromSeconds(Math.Max(1, _options.SlotWaitSeconds));
|
||||
@@ -183,12 +257,13 @@ public sealed class FailedCredentialLockout
|
||||
// cancellation hands a granted slot back (waking the next waiter) so nothing leaks.
|
||||
var decided = waiter.Completion.Task.Result;
|
||||
if (ex is not OperationCanceledException) return decided;
|
||||
if (decided.Verdict == CredentialLockoutVerdict.Proceed) ReleaseCore(key, entry, outcome: null);
|
||||
if (decided.Verdict == CredentialLockoutVerdict.Proceed) ReleaseCore(key, entry, identity, outcome: null);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Waiters.Remove(waiter.Node!);
|
||||
waiter.Completion.TrySetCanceled();
|
||||
Pump(entry, _time.GetUtcNow());
|
||||
RemoveIfIdle(key, entry);
|
||||
}
|
||||
}
|
||||
@@ -198,79 +273,123 @@ public sealed class FailedCredentialLockout
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases a slot from <see cref="BeginAsync"/> and records the activation's outcome.</summary>
|
||||
/// <summary><see cref="Complete(string, string, bool)"/> with <see cref="NoIdentity"/>.</summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
/// <param name="succeeded">Whether the activation was admitted.</param>
|
||||
/// <returns><see langword="true"/> when this failure locked the client out.</returns>
|
||||
public bool Complete(string key, bool succeeded)
|
||||
/// <returns>What this failure locked out, if anything.</returns>
|
||||
public CredentialLockoutScope Complete(string key, bool succeeded) => Complete(key, NoIdentity, succeeded);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a slot from <see cref="BeginAsync(string, string, CancellationToken)"/> and records a DEFINITE
|
||||
/// outcome: admitted, or refused for a bad credential. A transient denial goes to
|
||||
/// <see cref="Abandon(string, string)"/> instead (#845).
|
||||
/// </summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
/// <param name="identity">The identity passed to BeginAsync.</param>
|
||||
/// <param name="succeeded">Whether the activation was admitted.</param>
|
||||
/// <returns>What this failure locked out, if anything.</returns>
|
||||
public CredentialLockoutScope Complete(string key, string identity, bool succeeded)
|
||||
{
|
||||
if (!Enabled) return false;
|
||||
if (!Enabled) return CredentialLockoutScope.None;
|
||||
identity = Normalize(identity);
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.TryGetValue(key, out var entry) && ReleaseCore(key, entry, succeeded);
|
||||
return _entries.TryGetValue(key, out var entry)
|
||||
? ReleaseCore(key, entry, identity, succeeded)
|
||||
: CredentialLockoutScope.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases a slot without recording an outcome (the activation was cancelled or threw).</summary>
|
||||
/// <summary><see cref="Abandon(string, string)"/> with <see cref="NoIdentity"/>.</summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
public void Abandon(string key)
|
||||
public void Abandon(string key) => Abandon(key, NoIdentity);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a slot without recording an outcome: the activation was cancelled or threw, or was denied for a
|
||||
/// transient reason (store timeout, miss budget, authenticator fault) rather than a bad credential (#845).
|
||||
/// </summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
/// <param name="identity">The identity passed to BeginAsync.</param>
|
||||
public void Abandon(string key, string identity)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
identity = Normalize(identity);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var entry)) ReleaseCore(key, entry, outcome: null);
|
||||
if (_entries.TryGetValue(key, out var entry)) ReleaseCore(key, entry, identity, outcome: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="key"/> is locked out right now (observation; reserves nothing).</summary>
|
||||
/// <summary>Whether <paramref name="key"/> is locked out client-wide or for <see cref="NoIdentity"/>.</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 are refused.</returns>
|
||||
public bool IsLockedOut(string key, out TimeSpan remaining)
|
||||
/// <returns><see langword="true"/> when such an activation would be refused.</returns>
|
||||
public bool IsLockedOut(string key, out TimeSpan remaining) => IsLockedOut(key, NoIdentity, out remaining);
|
||||
|
||||
/// <summary>
|
||||
/// Whether an activation presenting <paramref name="identity"/> from <paramref name="key"/> would be refused
|
||||
/// right now — client-wide or for that identity (observation; reserves nothing).
|
||||
/// </summary>
|
||||
/// <param name="key">The client key from <see cref="KeyFor"/>.</param>
|
||||
/// <param name="identity">The credential identity.</param>
|
||||
/// <param name="remaining">The time left on the lockout when this returns true.</param>
|
||||
/// <returns><see langword="true"/> when the activation would be refused.</returns>
|
||||
public bool IsLockedOut(string key, string identity, out TimeSpan remaining)
|
||||
{
|
||||
remaining = TimeSpan.Zero;
|
||||
if (!Enabled) return false;
|
||||
identity = Normalize(identity);
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry)) return false;
|
||||
Refresh(entry, now);
|
||||
if (entry.LockedUntil <= now) return false;
|
||||
remaining = entry.LockedUntil - now;
|
||||
if (entry.LockedUntil > now)
|
||||
{
|
||||
remaining = entry.LockedUntil - now;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!entry.Identities.TryGetValue(SlotIdentity(entry, identity), out var id) || id.LockedUntil <= now)
|
||||
return false;
|
||||
remaining = id.LockedUntil - now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records one failed UserName activation with no slot (test seam / direct callers).</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)
|
||||
/// <param name="identity">The credential identity.</param>
|
||||
/// <returns><see langword="true"/> when this failure locked the identity or the client out.</returns>
|
||||
public bool RecordFailure(string key, string identity = NoIdentity)
|
||||
{
|
||||
if (!Enabled) return false;
|
||||
identity = Normalize(identity);
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (_entries.Count >= MaxTrackedClients) Prune(now);
|
||||
if (_entries.Count >= MaxTrackedClients) return false;
|
||||
entry = new Entry();
|
||||
_entries[key] = entry;
|
||||
entry = TryAddEntry(key, now);
|
||||
if (entry is null) return false;
|
||||
}
|
||||
var locked = RecordFailureCore(entry, now);
|
||||
|
||||
var locked = RecordFailureCore(entry, SlotIdentity(entry, identity), now);
|
||||
Pump(entry, now);
|
||||
return locked;
|
||||
return locked != CredentialLockoutScope.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears a client's count after a successful UserName activation (no slot).</summary>
|
||||
/// <summary>Clears one identity's count after a successful UserName activation (no slot).</summary>
|
||||
/// <param name="key">The client key.</param>
|
||||
public void RecordSuccess(string key)
|
||||
/// <param name="identity">The credential identity that succeeded.</param>
|
||||
public void RecordSuccess(string key, string identity = NoIdentity)
|
||||
{
|
||||
identity = Normalize(identity);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry)) return;
|
||||
ClearCount(entry);
|
||||
RecordSuccessCore(entry, SlotIdentity(entry, identity));
|
||||
Pump(entry, _time.GetUtcNow());
|
||||
RemoveIfIdle(key, entry);
|
||||
}
|
||||
@@ -282,126 +401,305 @@ public sealed class FailedCredentialLockout
|
||||
get { lock (_gate) return _entries.Count; }
|
||||
}
|
||||
|
||||
/// <summary>How many full-table prune scans have run (test observation, #845).</summary>
|
||||
internal int PruneScanCount
|
||||
{
|
||||
get { lock (_gate) return _pruneScans; }
|
||||
}
|
||||
|
||||
/// <summary>How many activations are waiting for a slot on <paramref name="key"/> (test observation).</summary>
|
||||
internal int WaitingCount(string key)
|
||||
{
|
||||
lock (_gate) return _entries.TryGetValue(key, out var e) ? e.Waiters.Count : 0;
|
||||
}
|
||||
|
||||
private bool HasFreeSlot(Entry entry) => entry.Failures + entry.InFlight < _options.MaxFailedAttempts;
|
||||
/// <summary>Bounds an attacker-chosen identity string; distinct long values stay distinct.</summary>
|
||||
private static string Normalize(string? identity)
|
||||
{
|
||||
identity ??= NoIdentity;
|
||||
if (identity.Length <= MaxIdentityLength) return identity;
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identity));
|
||||
return "\0sha256:" + Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private bool TryLockedOut(Entry entry, DateTimeOffset now, out CredentialLockoutDecision refused)
|
||||
private Entry? TryAddEntry(string key, DateTimeOffset now)
|
||||
{
|
||||
if (_entries.Count >= MaxTrackedClients && now >= _nextPruneAt) Prune(now);
|
||||
if (_entries.Count >= MaxTrackedClients) return null;
|
||||
var entry = new Entry();
|
||||
_entries[key] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The identity a slot / failure is charged to: the identity itself while it is tracked or there is room to
|
||||
/// track it, else the one shared overflow identity (a slot is always reserved for it).
|
||||
/// </summary>
|
||||
private static string SlotIdentity(Entry entry, string identity) =>
|
||||
entry.Identities.ContainsKey(identity) || entry.Identities.Count < MaxTrackedIdentitiesPerClient - 1
|
||||
? identity
|
||||
: OverflowIdentity;
|
||||
|
||||
private static IdentityState GetOrAddIdentity(Entry entry, string slotIdentity)
|
||||
{
|
||||
if (!entry.Identities.TryGetValue(slotIdentity, out var id))
|
||||
{
|
||||
id = new IdentityState();
|
||||
entry.Identities[slotIdentity] = id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private bool ClientHasRoom(Entry entry) => entry.Failures + entry.InFlight < ClientThreshold;
|
||||
|
||||
private bool HasFreeSlot(Entry entry, string slotIdentity) =>
|
||||
ClientHasRoom(entry)
|
||||
&& (!entry.Identities.TryGetValue(slotIdentity, out var id) || id.Failures + id.InFlight < IdentityThreshold);
|
||||
|
||||
private static void TakeSlot(Entry entry, string slotIdentity)
|
||||
{
|
||||
entry.InFlight++;
|
||||
GetOrAddIdentity(entry, slotIdentity).InFlight++;
|
||||
}
|
||||
|
||||
private static bool TryLockedOut(Entry entry, string slotIdentity, DateTimeOffset now, out CredentialLockoutDecision refused)
|
||||
{
|
||||
refused = default;
|
||||
if (entry.LockedUntil <= now) return false;
|
||||
refused = new(CredentialLockoutVerdict.LockedOut, entry.LockedUntil - now, !entry.RefusalLogged);
|
||||
entry.RefusalLogged = true;
|
||||
if (entry.LockedUntil > now)
|
||||
{
|
||||
refused = new(CredentialLockoutVerdict.LockedOut, entry.LockedUntil - now, !entry.RefusalLogged,
|
||||
CredentialLockoutScope.Client);
|
||||
entry.RefusalLogged = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!entry.Identities.TryGetValue(slotIdentity, out var id) || id.LockedUntil <= now) return false;
|
||||
refused = new(CredentialLockoutVerdict.LockedOut, id.LockedUntil - now, !id.RefusalLogged,
|
||||
CredentialLockoutScope.Credential);
|
||||
id.RefusalLogged = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Releases one slot, records <paramref name="outcome"/> (null = uncounted) and wakes waiters.</summary>
|
||||
private bool ReleaseCore(string key, Entry entry, bool? outcome)
|
||||
private CredentialLockoutScope ReleaseCore(string key, Entry entry, string identity, bool? outcome)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (entry.InFlight > 0) entry.InFlight--;
|
||||
var lockedNow = false;
|
||||
if (outcome == true) ClearCount(entry);
|
||||
else if (outcome == false) lockedNow = RecordFailureCore(entry, now);
|
||||
|
||||
// The slot was charged to the identity itself or, when the table was full, to the overflow identity.
|
||||
var slotIdentity = entry.Identities.TryGetValue(identity, out var own) && own.InFlight > 0
|
||||
? identity
|
||||
: entry.Identities.TryGetValue(OverflowIdentity, out var overflow) && overflow.InFlight > 0
|
||||
? OverflowIdentity
|
||||
: SlotIdentity(entry, identity);
|
||||
if (entry.Identities.TryGetValue(slotIdentity, out var held) && held.InFlight > 0) held.InFlight--;
|
||||
|
||||
var locked = CredentialLockoutScope.None;
|
||||
if (outcome == true) RecordSuccessCore(entry, slotIdentity);
|
||||
else if (outcome == false) locked = RecordFailureCore(entry, slotIdentity, now);
|
||||
Pump(entry, now);
|
||||
RemoveIfIdle(key, entry);
|
||||
return lockedNow;
|
||||
return locked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands freed slots to waiters in order — or, when the key is locked out, refuses every waiter (they never
|
||||
/// reach the authenticator). Called under the gate after anything that can free a slot or lock the key.
|
||||
/// Hands freed slots to waiters in order — passing over one whose identity is full — and refuses every
|
||||
/// waiter whose identity, or whose client, is locked out (they never reach the authenticator). Called under
|
||||
/// the gate after anything that can free a slot or lock the key.
|
||||
/// </summary>
|
||||
private void Pump(Entry entry, DateTimeOffset now)
|
||||
{
|
||||
Refresh(entry, now);
|
||||
while (entry.Waiters.First is { } first)
|
||||
var node = entry.Waiters.First;
|
||||
while (node is not null)
|
||||
{
|
||||
if (TryLockedOut(entry, now, out var refused))
|
||||
var next = node.Next;
|
||||
var waiter = node.Value;
|
||||
var slotIdentity = SlotIdentity(entry, waiter.Identity);
|
||||
if (TryLockedOut(entry, slotIdentity, now, out var refused))
|
||||
{
|
||||
entry.Waiters.RemoveFirst();
|
||||
first.Value.Completion.TrySetResult(refused);
|
||||
continue;
|
||||
entry.Waiters.Remove(node);
|
||||
waiter.Completion.TrySetResult(refused);
|
||||
}
|
||||
else if (ClientHasRoom(entry) && HasFreeSlot(entry, slotIdentity))
|
||||
{
|
||||
entry.Waiters.Remove(node);
|
||||
if (waiter.Completion.TrySetResult(new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false)))
|
||||
TakeSlot(entry, slotIdentity); // a waiter that already gave up never takes the slot
|
||||
}
|
||||
|
||||
if (!HasFreeSlot(entry)) return;
|
||||
entry.Waiters.RemoveFirst();
|
||||
entry.InFlight++;
|
||||
if (!first.Value.Completion.TrySetResult(new(CredentialLockoutVerdict.Proceed, TimeSpan.Zero, false)))
|
||||
entry.InFlight--; // a waiter that already gave up never takes the slot
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearCount(Entry entry)
|
||||
private static void RecordSuccessCore(Entry entry, string slotIdentity)
|
||||
{
|
||||
entry.Failures = 0;
|
||||
entry.LockedUntil = default;
|
||||
entry.RefusalLogged = false;
|
||||
// Only this identity's count — never the client-wide one (#844).
|
||||
if (!entry.Identities.TryGetValue(slotIdentity, out var id)) return;
|
||||
id.Failures = 0;
|
||||
id.LockedUntil = default;
|
||||
id.RefusalLogged = false;
|
||||
if (id.InFlight == 0) entry.Identities.Remove(slotIdentity);
|
||||
}
|
||||
|
||||
private bool RecordFailureCore(Entry entry, DateTimeOffset now)
|
||||
private CredentialLockoutScope RecordFailureCore(Entry entry, string slotIdentity, DateTimeOffset now)
|
||||
{
|
||||
Refresh(entry, now);
|
||||
if (entry.LockedUntil > now) return false; // a refusal (or a late in-flight failure) never extends it
|
||||
if (entry.LockedUntil > now) return CredentialLockoutScope.None; // a late in-flight failure never extends it
|
||||
|
||||
var lockFor = TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds));
|
||||
var result = CredentialLockoutScope.None;
|
||||
var id = GetOrAddIdentity(entry, slotIdentity);
|
||||
if (id.LockedUntil <= now)
|
||||
{
|
||||
id.Failures++;
|
||||
id.LastFailure = now;
|
||||
if (id.Failures >= IdentityThreshold && lockFor > TimeSpan.Zero)
|
||||
{
|
||||
id.LockedUntil = now + lockFor;
|
||||
id.RefusalLogged = false;
|
||||
result = CredentialLockoutScope.Credential;
|
||||
}
|
||||
}
|
||||
|
||||
entry.Failures++;
|
||||
entry.LastFailure = now;
|
||||
if (entry.Failures < _options.MaxFailedAttempts) return false;
|
||||
entry.LockedUntil = now + TimeSpan.FromSeconds(Math.Max(0, _options.LockoutSeconds));
|
||||
entry.RefusalLogged = false;
|
||||
return entry.LockedUntil > now;
|
||||
if (entry.Failures >= ClientThreshold && lockFor > TimeSpan.Zero)
|
||||
{
|
||||
entry.LockedUntil = now + lockFor;
|
||||
entry.RefusalLogged = false;
|
||||
result = CredentialLockoutScope.Client;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets a run of failures that has gone stale: an expired lockout always starts a fresh count (else a
|
||||
/// Forgets runs of failures that have gone stale: an expired lockout always starts a fresh count (else a
|
||||
/// <c>LockoutSeconds</c> shorter than <c>FailureWindowSeconds</c> relocked on the first failure after it),
|
||||
/// and so does a failure older than the window.
|
||||
/// and so does a failure older than the window. Applies to the client-wide count and each identity's.
|
||||
/// </summary>
|
||||
private void Refresh(Entry entry, DateTimeOffset now)
|
||||
{
|
||||
var window = TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds));
|
||||
if (entry.LockedUntil != default && entry.LockedUntil <= now)
|
||||
{
|
||||
ClearCount(entry);
|
||||
// An expired client-wide lockout clears every identity's count as well.
|
||||
ClearCount(ref entry.Failures, ref entry.LockedUntil, ref entry.RefusalLogged);
|
||||
foreach (var id in entry.Identities.Values)
|
||||
ClearCount(ref id.Failures, ref id.LockedUntil, ref id.RefusalLogged);
|
||||
}
|
||||
else if (entry.LockedUntil == default && entry.Failures > 0
|
||||
&& now - entry.LastFailure > TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds)))
|
||||
else if (entry.LockedUntil == default && entry.Failures > 0 && now - entry.LastFailure > window)
|
||||
{
|
||||
entry.Failures = 0;
|
||||
entry.RefusalLogged = false;
|
||||
}
|
||||
|
||||
if (entry.Identities.Count == 0) return;
|
||||
List<string>? idle = null;
|
||||
foreach (var (name, id) in entry.Identities)
|
||||
{
|
||||
if (id.LockedUntil != default && id.LockedUntil <= now)
|
||||
{
|
||||
ClearCount(ref id.Failures, ref id.LockedUntil, ref id.RefusalLogged);
|
||||
}
|
||||
else if (id.LockedUntil == default && id.Failures > 0 && now - id.LastFailure > window)
|
||||
{
|
||||
id.Failures = 0;
|
||||
id.RefusalLogged = false;
|
||||
}
|
||||
|
||||
if (id.Failures == 0 && id.InFlight == 0 && id.LockedUntil == default) (idle ??= []).Add(name);
|
||||
}
|
||||
|
||||
if (idle is null) return;
|
||||
foreach (var name in idle) entry.Identities.Remove(name);
|
||||
}
|
||||
|
||||
private static void ClearCount(ref int failures, ref DateTimeOffset lockedUntil, ref bool refusalLogged)
|
||||
{
|
||||
failures = 0;
|
||||
lockedUntil = default;
|
||||
refusalLogged = false;
|
||||
}
|
||||
|
||||
private void RemoveIfIdle(string key, Entry entry)
|
||||
{
|
||||
if (IsIdle(entry, _time.GetUtcNow())) _entries.Remove(key);
|
||||
if (!IsIdle(entry, _time.GetUtcNow())) return;
|
||||
_entries.Remove(key);
|
||||
_nextPruneAt = DateTimeOffset.MinValue; // room was made; a full table may prune again
|
||||
}
|
||||
|
||||
private void Prune(DateTimeOffset now)
|
||||
{
|
||||
foreach (var key in _entries.Where(e => IsIdle(e.Value, now)).Select(e => e.Key).ToList())
|
||||
_entries.Remove(key);
|
||||
_pruneScans++;
|
||||
var earliest = DateTimeOffset.MaxValue;
|
||||
List<string>? idle = null;
|
||||
foreach (var (key, entry) in _entries)
|
||||
{
|
||||
if (IsIdle(entry, now)) (idle ??= []).Add(key);
|
||||
else if (EarliestIdle(entry) is var at && at < earliest) earliest = at;
|
||||
}
|
||||
|
||||
if (idle is not null)
|
||||
{
|
||||
foreach (var key in idle) _entries.Remove(key);
|
||||
}
|
||||
|
||||
// #845 — when nothing could be freed, do not rescan the full table on every new client's failure: wait
|
||||
// until the earliest entry could have gone idle by the clock. An entry holding a slot or a waiter goes
|
||||
// idle only through a release, and RemoveIfIdle resets this when that frees room.
|
||||
_nextPruneAt = _entries.Count >= MaxTrackedClients ? earliest : DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>Idle = nothing to remember: no failures, no lockout, no slot held and nobody waiting.</summary>
|
||||
/// <summary>The earliest instant <paramref name="entry"/> could be idle by the clock alone.</summary>
|
||||
private DateTimeOffset EarliestIdle(Entry entry)
|
||||
{
|
||||
if (entry.InFlight > 0 || entry.Waiters.Count > 0) return DateTimeOffset.MaxValue;
|
||||
var window = TimeSpan.FromSeconds(Math.Max(0, _options.FailureWindowSeconds));
|
||||
var at = Expiry(entry.LockedUntil, entry.Failures, entry.LastFailure, window);
|
||||
foreach (var id in entry.Identities.Values)
|
||||
{
|
||||
var idAt = Expiry(id.LockedUntil, id.Failures, id.LastFailure, window);
|
||||
if (idAt > at) at = idAt;
|
||||
}
|
||||
return at;
|
||||
|
||||
static DateTimeOffset Expiry(DateTimeOffset lockedUntil, int failures, DateTimeOffset lastFailure, TimeSpan window) =>
|
||||
lockedUntil != default ? lockedUntil
|
||||
: failures > 0 ? lastFailure + window + TimeSpan.FromTicks(1)
|
||||
: DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>Idle = nothing to remember: no failures, lockout, slot, waiter or identity.</summary>
|
||||
private bool IsIdle(Entry entry, DateTimeOffset now)
|
||||
{
|
||||
Refresh(entry, now);
|
||||
return entry.InFlight == 0 && entry.Waiters.Count == 0 && entry.Failures == 0 && entry.LockedUntil == default;
|
||||
return entry.InFlight == 0 && entry.Waiters.Count == 0 && entry.Failures == 0
|
||||
&& entry.LockedUntil == default && entry.Identities.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>One activation waiting for a slot.</summary>
|
||||
private sealed class Waiter
|
||||
private sealed class Waiter(string identity)
|
||||
{
|
||||
public readonly TaskCompletionSource<CredentialLockoutDecision> Completion =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public readonly string Identity = identity;
|
||||
public LinkedListNode<Waiter>? Node;
|
||||
}
|
||||
|
||||
/// <summary>One client's failure count, slots, waiters and lockout. Mutated only under the gate.</summary>
|
||||
/// <summary>One credential identity's count, slots and lockout within one client. Mutated only under the gate.</summary>
|
||||
private sealed class IdentityState
|
||||
{
|
||||
public int Failures;
|
||||
public int InFlight;
|
||||
public DateTimeOffset LastFailure;
|
||||
public DateTimeOffset LockedUntil;
|
||||
public bool RefusalLogged;
|
||||
}
|
||||
|
||||
/// <summary>One client's aggregate count, slots, waiters, lockout and identities. Mutated only under the gate.</summary>
|
||||
private sealed class Entry
|
||||
{
|
||||
public int Failures;
|
||||
@@ -410,5 +708,8 @@ public sealed class FailedCredentialLockout
|
||||
public DateTimeOffset LockedUntil;
|
||||
public bool RefusalLogged;
|
||||
public readonly LinkedList<Waiter> Waiters = new();
|
||||
public readonly Dictionary<string, IdentityState> Identities = new(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ public interface IOpcUaUserAuthenticator
|
||||
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
|
||||
AuthenticateUserNameAsync(username, password, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Names the credential identity a UserName attempt presents, for the failed-credential lockout's
|
||||
/// per-identity count (lmxopcua#844): a success clears only its own identity's count, so a client holding one
|
||||
/// valid credential cannot use it to launder guesses at another. Called before authentication; must be cheap,
|
||||
/// do no I/O and not throw. The default is the username; an implementation whose secret carries a public
|
||||
/// identifier (an API key id) should return that, so guesses at different keys are told apart.
|
||||
/// </summary>
|
||||
/// <param name="username">The presented username.</param>
|
||||
/// <param name="password">The presented cleartext password.</param>
|
||||
/// <returns>A stable, non-secret identity string. It is logged — never include secret material.</returns>
|
||||
string DescribeCredential(string username, string password) => "user:" + username;
|
||||
|
||||
/// <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
|
||||
@@ -101,6 +113,14 @@ public sealed record OpcUaUserAuthResult(
|
||||
/// </summary>
|
||||
public string? ApiKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the denial is NOT a verdict on the credential — the backing store timed out or refused the
|
||||
/// lookup, the authenticator is misconfigured, the call was cancelled (lmxopcua#845). The failed-credential
|
||||
/// lockout does not count it, so an infrastructure stall cannot lock every keyed client out. The client still
|
||||
/// sees the same opaque denial.
|
||||
/// </summary>
|
||||
public bool Transient { get; init; }
|
||||
|
||||
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
|
||||
/// <param name="displayName">The display name for the authenticated user — also taken as the principal.</param>
|
||||
/// <param name="roles">The roles to assign to the authenticated user.</param>
|
||||
@@ -121,6 +141,15 @@ public sealed record OpcUaUserAuthResult(
|
||||
/// <returns>A failed OpcUaUserAuthResult.</returns>
|
||||
public static OpcUaUserAuthResult Deny(string error) =>
|
||||
new(false, null, Array.Empty<string>(), error);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a denial that is not a verdict on the credential (store outage, misconfiguration, cancellation) —
|
||||
/// see <see cref="Transient"/>.
|
||||
/// </summary>
|
||||
/// <param name="error">The error message returned to the client (keep it as opaque as a normal denial).</param>
|
||||
/// <returns>A failed, transient OpcUaUserAuthResult.</returns>
|
||||
public static OpcUaUserAuthResult Unavailable(string error) =>
|
||||
new(false, null, Array.Empty<string>(), error) { Transient = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user