ffba75bb93
#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).
224 lines
12 KiB
C#
224 lines
12 KiB
C#
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// F13c — verifies the impersonation handler routes UserName tokens through
|
|
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
|
|
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHostImpersonationTests
|
|
{
|
|
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
|
|
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
|
|
|
|
/// <summary>Verifies successful UserName token impersonation sets identity and clears validation error.</summary>
|
|
[Fact]
|
|
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
|
|
{
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(
|
|
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
args.Identity.ShouldNotBeNull();
|
|
args.IdentityValidationError.ShouldBeNull();
|
|
authenticator.LastUsername.ShouldBe("alice");
|
|
authenticator.LastPassword.ShouldBe("secret");
|
|
}
|
|
|
|
/// <summary>T17 — verifies the granted identity carries the authenticator's resolved roles
|
|
/// (so a downstream method handler can read them off <c>context.UserIdentity</c> for the
|
|
/// AlarmAck gate). The identity must be a <see cref="RoleCarryingUserIdentity"/> whose
|
|
/// <see cref="RoleCarryingUserIdentity.Roles"/> equals <c>result.Roles</c>.</summary>
|
|
[Fact]
|
|
public void HandleImpersonation_username_success_carries_roles_on_identity()
|
|
{
|
|
var roles = new[] { "ReadOnly", "WriteOperate", "AlarmAck" };
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("Alice", roles));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
var identity = args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
|
|
identity.Roles.ShouldBe(roles);
|
|
}
|
|
|
|
/// <summary>Verifies failed UserName token impersonation sets validation error and clears identity.</summary>
|
|
[Fact]
|
|
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
|
|
{
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "mallory" }) { DecryptedPassword = Encoding.UTF8.GetBytes("wrong") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
args.Identity.ShouldBeNull();
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
|
args.IdentityValidationError.LocalizedText.Text!.ShouldContain("Invalid credentials");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An admitted anonymous session now carries a role-bearing identity (#520).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This test previously asserted the opposite — that anonymous fell through untouched to the SDK
|
|
/// default identity. That was the pre-#520 behaviour and is exactly what changed: anonymous is a
|
|
/// normal principal whose grants come from ACL rows, so it must arrive at the downstream gates as
|
|
/// the same <see cref="RoleCarryingUserIdentity"/> a key produces, or every gate would need an
|
|
/// anonymous-shaped branch.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity()
|
|
{
|
|
var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
args.IdentityValidationError.ShouldBeNull();
|
|
args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
|
|
// Still no UserName authentication — the anonymous path must not consult the credential seam.
|
|
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the UserName authenticator");
|
|
}
|
|
|
|
/// <summary>
|
|
/// With anonymous disallowed the session is rejected outright rather than admitted with no roles.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed()
|
|
{
|
|
var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(
|
|
authenticator, args, NullLogger<object>.Instance, allowAnonymous: false);
|
|
|
|
args.Identity.ShouldBeNull();
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
|
}
|
|
|
|
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
|
|
[Fact]
|
|
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
|
|
{
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "bob" }) { DecryptedPassword = Encoding.UTF8.GetBytes("x") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
|
|
|
|
var transient = OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
args.Identity.ShouldBeNull();
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
|
transient.ShouldBeTrue("#845: an authenticator fault is not a verdict on the credential");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #845 — a denial the authenticator marks Transient (store timeout, miss-budget refusal) is reported as such,
|
|
/// so the lockout does not count it, while the client sees the same opaque rejection as a bad credential.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HandleImpersonation_reports_a_transient_denial_and_a_definite_one_apart()
|
|
{
|
|
OpcUaUserAuthResult[] outcomes = [OpcUaUserAuthResult.Unavailable("Invalid API key."), OpcUaUserAuthResult.Deny("Invalid API key.")];
|
|
var verdicts = outcomes.Select(outcome =>
|
|
{
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "apikey" }) { DecryptedPassword = Encoding.UTF8.GetBytes("otua_k1_x") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var transient = OpcUaApplicationHost.HandleImpersonation(new RecordingAuthenticator(outcome), args, NullLogger<object>.Instance);
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
|
args.IdentityValidationError.LocalizedText.Text!.ShouldContain("Invalid API key.");
|
|
return transient;
|
|
}).ToArray();
|
|
|
|
verdicts.ShouldBe([true, false]);
|
|
}
|
|
|
|
/// <summary>#844 — the default credential identity is the username; a faulting implementation falls back to it.</summary>
|
|
[Fact]
|
|
public void DescribeCredential_defaults_to_the_username_and_never_throws()
|
|
{
|
|
var handler = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("pw") };
|
|
OpcUaApplicationHost.DescribeCredential(new RecordingAuthenticator(OpcUaUserAuthResult.Deny("x")), handler)
|
|
.ShouldBe("user:alice");
|
|
OpcUaApplicationHost.DescribeCredential(new DescribingAuthenticator(), handler).ShouldBe("custom:alice:pw".Length.ToString());
|
|
OpcUaApplicationHost.DescribeCredential(new DescribingAuthenticator(fail: true), handler).ShouldBe("user:alice");
|
|
}
|
|
|
|
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
|
|
[Fact]
|
|
public void HandleImpersonation_null_username_treated_as_empty_string()
|
|
{
|
|
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = null }) { DecryptedPassword = Encoding.UTF8.GetBytes("abc") };
|
|
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
|
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
|
|
|
authenticator.LastUsername.ShouldBe(string.Empty);
|
|
}
|
|
|
|
/// <summary>Verifies NullOpcUaUserAuthenticator always returns denial result.</summary>
|
|
[Fact]
|
|
public async Task NullOpcUaUserAuthenticator_always_denies()
|
|
{
|
|
var result = await NullOpcUaUserAuthenticator.Instance
|
|
.AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
result.Roles.ShouldBeEmpty();
|
|
}
|
|
|
|
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
|
|
{
|
|
/// <summary>Gets the username passed to the last authentication call.</summary>
|
|
public string? LastUsername { get; private set; }
|
|
/// <summary>Gets the password passed to the last authentication call.</summary>
|
|
public string? LastPassword { get; private set; }
|
|
|
|
/// <summary>Authenticates a username and password, recording them for inspection by tests.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to authenticate.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The predefined authentication result.</returns>
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
LastUsername = username;
|
|
LastPassword = password;
|
|
return Task.FromResult(outcome);
|
|
}
|
|
}
|
|
|
|
private sealed class DescribingAuthenticator(bool fail = false) : IOpcUaUserAuthenticator
|
|
{
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
|
Task.FromResult(OpcUaUserAuthResult.Deny("x"));
|
|
|
|
public string DescribeCredential(string username, string password) =>
|
|
fail ? throw new InvalidOperationException("boom") : $"custom:{username}:{password}".Length.ToString();
|
|
}
|
|
|
|
/// <summary>Test authenticator that throws an exception during authentication.</summary>
|
|
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
|
|
{
|
|
/// <summary>Authenticates by throwing the configured exception.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to authenticate.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>Never returns; always throws the configured exception.</returns>
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
=> Task.FromException<OpcUaUserAuthResult>(ex);
|
|
}
|
|
}
|