Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
T
Joseph Doherty ffba75bb93 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).
2026-11-11 03:47:10 -05:00

169 lines
10 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
/// application cert) and hands the cleartext username + password to this seam. Implementations
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
///
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
/// dev nodes don't silently accept credentials.
/// </summary>
public interface IOpcUaUserAuthenticator
{
/// <summary>
/// Resolves cleartext UserName credentials against the configured backing store. Must not
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
/// path where it surfaces as a generic <c>BadInternalError</c>.
/// </summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
/// <summary>
/// <see cref="AuthenticateUserNameAsync(string, string, CancellationToken)"/> with the activating client's
/// remote endpoint (lmxopcua#751), so an implementation can account pre-authentication failures per
/// client rather than process-wide. This is the overload the server calls; the default ignores the
/// endpoint and delegates, so an implementation that does not need it is unchanged.
/// </summary>
/// <remarks>
/// The endpoint uses the SDK's public transport peer address during activation and is null when unknown — an
/// implementation must treat null as "no per-client information", never as a distinct client. Same
/// must-not-throw contract as the three-argument overload.
/// </remarks>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="remoteEndpoint">The client's IP endpoint, or null when it could not be resolved.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, System.Net.IPEndPoint? remoteEndpoint, CancellationToken ct) =>
AuthenticateUserNameAsync(username, password, ct);
/// <summary>
/// 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
/// read or write through ordinary ACL rows.
/// </summary>
/// <remarks>
/// <para>
/// This is asked only AFTER the host has decided anonymous sessions are admissible at all
/// (<c>OpcUaApplicationHostOptions.AllowAnonymous</c>). Admission is the host's decision;
/// authority is this seam's.
/// </para>
/// <para>
/// The default implementation admits anonymous with <b>no roles</b>, which is exactly the
/// behaviour before per-key ACLs: an anonymous session connects, and every role-gated operation
/// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest
/// keep the safe status quo rather than being forced to reimplement it.
/// </para>
/// </remarks>
/// <param name="ct">The cancellation token.</param>
/// <returns>The roles to attach to the anonymous session, or a denial.</returns>
Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty<string>()));
}
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
/// <remarks>
/// <para>
/// ⚠️ <b><see cref="DisplayName"/> is not the principal, and assuming it was is a live trap</b>.
/// The authenticator passes the principal as the display name, but
/// <c>RoleCarryingUserIdentity</c> derives <c>UserIdentity.DisplayName</c> from the presented
/// <b>token</b> — which for a key-bearing client is the static username <c>apikey</c>, the same
/// for every key in the fleet. So the principal was being dropped on the floor at the identity
/// boundary, and the audit <c>ClientUserId</c> on a rejected write recorded <c>apikey</c> for
/// everyone. <see cref="Principal"/> carries it explicitly; nothing should infer it from a name.
/// </para>
/// </remarks>
public sealed record OpcUaUserAuthResult(
bool Success,
string? DisplayName,
IReadOnlyList<string> Roles,
string? Error)
{
/// <summary>
/// The ACL principal the session authenticates as — the name <c>NodeAcl</c> rows are keyed on.
/// Empty on a denial, and on a legacy authenticator that predates the principal model, which the
/// ACL gate treats as unauthorized rather than guessing.
/// </summary>
public string Principal { get; init; } = string.Empty;
/// <summary>
/// Public id of the API key that authenticated, or null for anonymous. Carried so an open session
/// can be re-checked against a later deployment that revoked the key.
/// </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>
/// <returns>A successful OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
new(true, displayName, roles, null) { Principal = displayName ?? string.Empty };
/// <summary>Creates a successful result for a key-bearing client, carrying the key id for revocation re-checks.</summary>
/// <param name="principal">The ACL principal the key maps to.</param>
/// <param name="roles">The interim server-wide roles.</param>
/// <param name="apiKeyId">The public key id that authenticated.</param>
/// <returns>A successful OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult AllowKey(string principal, IReadOnlyList<string> roles, string apiKeyId) =>
new(true, principal, roles, null) { Principal = principal, ApiKeyId = apiKeyId };
/// <summary>Creates a failed authentication result with the specified error message.</summary>
/// <param name="error">The error message describing why authentication failed.</param>
/// <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>
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
/// </summary>
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
public static readonly NullOpcUaUserAuthenticator Instance = new();
private NullOpcUaUserAuthenticator() { }
/// <inheritdoc />
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
}