fix(opcua): credential lockout counts per credential identity, ignores transient denials (#844, #845)
#844: a success cleared the whole per-client count, so a client holding a valid key could make four guesses at another key id, sign in with its own, and repeat forever. Failures are now charged to the (client, credential identity) pair -- IOpcUaUserAuthenticator.DescribeCredential, key:<keyId> for API keys -- and to the client as a whole. A success clears only its own identity's count; the per-identity threshold locks that identity; a new per-client cap (OpcUa:CredentialLockout:MaxFailedAttemptsPerClient, default 2 x MaxFailedAttempts) that a success never resets bounds spraying across key ids and locks every credential. Slots are bounded on both counts; a waiter whose identity has room is not held behind one whose identity has none. #845: (1) store timeouts, miss-budget refusals, store/authenticator exceptions, an unset pepper and cancellation are reported Transient (OpcUaUserAuthResult.Unavailable) and released uncounted, so a ConfigDb stall can no longer lock every keyed client out; the client still sees the same opaque denial. (2) a full 4096-entry table defers its next prune until the earliest entry could age out instead of rescanning under the lock per failure. (3) ApplicationUri deliberately NOT added to the key: on a None channel it is a free string, so it would let an attacker reset the count per guess -- documented. (4) wire tests: distinct client certificates on a Sign endpoint are locked out separately; the ApplicationConfigPath branch keeps the SDK lockout off (falsified by removing the loaded-branch disable).
This commit is contained in:
@@ -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