9ec0da1b2c
ExtractUserName returned the first value of a possibly multi-valued UserNameAttribute (default cn), so jsmith could sign in as "John Smith" depending on server ordering. It now prefers the value equal to the typed name ignoring case (still canonicalising case) and falls back to the first value only when none matches. (scadaproj#46) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
240 lines
9.7 KiB
C#
240 lines
9.7 KiB
C#
using System.Net.Security;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
public class LdapAuthServiceTests
|
|
{
|
|
// Sensible test defaults: insecure plaintext transport (dev/test), a service
|
|
// account set, and DisplayNameAttribute aligned with the fake's "displayName"
|
|
// key so display-name extraction is genuinely exercised.
|
|
private static LdapOptions Opts() => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "x",
|
|
Port = 3893,
|
|
Transport = LdapTransport.None,
|
|
AllowInsecure = true,
|
|
SearchBase = "dc=x",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
UserNameAttribute = "cn",
|
|
DisplayNameAttribute = "displayName",
|
|
GroupAttribute = "memberOf",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Succeeds_AndReturnsStrippedGroups_OnValidCredentials()
|
|
{
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x",
|
|
memberOf: new[] { "cn=Engineers,ou=g,dc=x", "cn=Viewers,ou=g,dc=x" },
|
|
displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync(" alice ", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("alice", r.Username); // trimmed
|
|
Assert.Equal("Alice", r.DisplayName); // from DisplayNameAttribute
|
|
Assert.Equal(new[] { "Engineers", "Viewers" }, r.Groups); // CN= stripped
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BindsServiceAccountThenUser_OnValidCredentials()
|
|
{
|
|
// Non-empty memberOf: fail-closed requires at least one group for success, and this
|
|
// test asserts bind ORDER, so the user must successfully resolve and bind.
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }, displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
// Service account first, user DN second (bind-then-search-then-bind).
|
|
Assert.Equal(new[] { "cn=svc,dc=x", "cn=alice,dc=x" }, fake.BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FallsBackToUsername_WhenNoDisplayName()
|
|
{
|
|
// Non-empty memberOf so fail-closed lets success through; this test only asserts the
|
|
// display-name fallback (no displayName attribute -> username).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=bob,dc=x", memberOf: new[] { "cn=Viewers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("bob", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("bob", r.DisplayName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fails_Disabled_WhenNotEnabled()
|
|
{
|
|
var svc = new LdapAuthService(
|
|
Opts() with { Enabled = false },
|
|
new FakeLdapConnectionFactory(new FakeLdapConnection()));
|
|
|
|
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
|
|
}
|
|
|
|
// --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation
|
|
// callback is threaded into the connection rather than being silently ignored. ---
|
|
|
|
[Fact]
|
|
public async Task Connect_ReceivesAllowInsecureFlag_FromOptions()
|
|
{
|
|
// The allowInsecure flag must reach the connection (it used to be an unused parameter).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(
|
|
Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.NotNull(fake.ConnectArgs);
|
|
Assert.True(fake.ConnectArgs!.Value.AllowInsecure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_ReceivesConfiguredCertValidationCallback()
|
|
{
|
|
// A consumer-supplied RemoteCertificateValidationCallback must be passed through to the
|
|
// connection so production callers can pin a CA / validate the SAN — the seam no longer
|
|
// discards it.
|
|
RemoteCertificateValidationCallback callback = (_, _, _, _) => true;
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(
|
|
Opts() with { ServerCertificateValidationCallback = callback },
|
|
new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.Same(callback, fake.ConnectCertCallback);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_NoCertCallbackConfigured_PassesNull()
|
|
{
|
|
// Default: no callback configured -> null reaches the connection, which means the
|
|
// production adapter falls back to OS-trust-store validation (documented behaviour).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.Null(fake.ConnectCertCallback);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
|
|
{
|
|
// C1: a group CN that legitimately contains a comma (escaped per RFC 4514)
|
|
// must be returned intact, not truncated at the escaped comma.
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x",
|
|
memberOf: new[] { @"cn=Eng\,ineers,ou=g,dc=x", @"cn=A\2cB,dc=x" },
|
|
displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
|
|
{
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
|
|
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("alice", r.Username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
|
|
{
|
|
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("ALICE", r.Username);
|
|
}
|
|
|
|
// ---- Auth-009 (scadaproj#46): a multi-valued UserNameAttribute ----------------------------
|
|
|
|
[Fact]
|
|
public async Task Username_FromAMultiValuedAttribute_IsTheValueTheUserMatched()
|
|
{
|
|
// OpenLDAP / 389-DS entries routinely carry both cn values; the server's ordering decides
|
|
// which comes first. The search matched "jsmith", so that is the identity, not "John Smith".
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=jsmith,dc=x", new[] { "cn=g,dc=x" }, "John",
|
|
new Dictionary<string, string[]> { ["cn"] = new[] { "John Smith", "jsmith" } });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("jsmith", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("jsmith", r.Username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_FromAMultiValuedAttribute_StillTakesTheDirectorysCase()
|
|
{
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=jsmith,dc=x", new[] { "cn=g,dc=x" }, "John",
|
|
new Dictionary<string, string[]> { ["cn"] = new[] { "John Smith", "JSmith" } });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("jsmith", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("JSmith", r.Username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_FallsBackToTheFirstValue_WhenNoValueMatchesTheTypedName()
|
|
{
|
|
// The directory matched on something the attribute values do not spell the same way
|
|
// (a matching rule beyond case, say): keep 0.4.1's first-value behaviour.
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=jsmith,dc=x", new[] { "cn=g,dc=x" }, "John",
|
|
new Dictionary<string, string[]> { ["cn"] = new[] { "J. Smith", "John Smith" } });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("jsmith", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("J. Smith", r.Username);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(null)]
|
|
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
|
|
var svc = new LdapAuthService(Opts(), factory);
|
|
|
|
var r = await svc.AuthenticateAsync("alice", password!, default);
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
}
|