d549a782fd
BindMode=User bound as UserBindTemplate({0}) and then returned the groups of
whatever entry (UserNameAttribute={0}) found - two directory lookups that can
name two accounts (Auth-007). Reproduced live against GLAuth: mallory bound as
herself and signed in with boss's Admins group.
After the bind the service now asks RFC 4532 Who-am-I and requires the entry
to be that account (dn: -> DN match; AD u:DOMAIN\name -> sAMAccountName match).
Without a Who-am-I answer only a DN template is provable (bind DN must equal
the entry DN); anything else fails closed as GroupLookupFailed. {0} is
RFC 4514-escaped in DN templates; validator message leads with EXAMPLE\{0}.
Auth 0.4.3 -> 0.4.4 (unpublished).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
390 lines
15 KiB
C#
390 lines
15 KiB
C#
using Novell.Directory.Ldap;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="LdapBindMode.User"/>: bind as the signing-in user, then read their own entry on the
|
|
/// same connection. The service account in <see cref="Opts"/> is deliberately populated so the
|
|
/// tests prove it is never used.
|
|
/// </summary>
|
|
public class LdapAuthServiceUserBindTests
|
|
{
|
|
private static LdapOptions Opts(params string[] fallbacks) => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "dc1",
|
|
Port = 636,
|
|
Transport = LdapTransport.Ldaps,
|
|
SearchBase = "dc=x",
|
|
UserNameAttribute = "sAMAccountName",
|
|
DisplayNameAttribute = "displayName",
|
|
GroupAttribute = "memberOf",
|
|
BindMode = LdapBindMode.User,
|
|
UserBindTemplate = "{0}@corp.test",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
FallbackServers = fallbacks,
|
|
};
|
|
|
|
private const string JaneDn = "CN=Jane Doe,OU=u,DC=x";
|
|
|
|
private static FakeLdapConnection Jane() => new FakeLdapConnection()
|
|
.WithWhoAmI("dn:" + JaneDn)
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
|
|
|
|
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
|
|
svc.AuthenticateAsync(user, pw, CancellationToken.None);
|
|
|
|
[Fact]
|
|
public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal("jdoe", result.Username);
|
|
Assert.Equal("Jane Doe", result.DisplayName);
|
|
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
|
|
var conn = Assert.Single(factory.Created);
|
|
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "whoami", "search:(sAMAccountName=jdoe)" }, conn.Calls);
|
|
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(@"NAM\jdoe")]
|
|
[InlineData("jdoe@corp.test")]
|
|
[InlineData(" jdoe ")]
|
|
public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal("jdoe", result.Username);
|
|
Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("@x")]
|
|
[InlineData(@"a\b\c")]
|
|
[InlineData("a@b@c")]
|
|
public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
|
|
|
|
var result = await Login(svc, @"NAM\jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonCredentialBindError_FailsOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectFailure_FailsOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchFailure_FailsOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EveryEndpointDown_IsSystemSide()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI("dn:" + JaneDn).WithNoMatch());
|
|
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI("dn:cn=dup1,dc=x").WithDuplicateMatch());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoGroups_IsGroupLookupFailed()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithWhoAmI("dn:CN=Jane,DC=x").WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI(@"u:NAM\jdoe").WithUserEntry(
|
|
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
|
|
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
|
|
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal("jdoe", result.Username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal("JDOE", result.Username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Search_RequestsTheUserNameAttribute()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
|
|
|
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(Jane());
|
|
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
|
|
await Login(svc, "jdoe");
|
|
|
|
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
|
|
}
|
|
|
|
// ---- Auth-007 (scadaproj#40): the entry whose groups are returned must be the account that bound ----
|
|
|
|
/// <summary>
|
|
/// The review's scenario: a UPN bind for <c>jsmith</c> is resolved by AD to Mallory (explicit UPN
|
|
/// <c>jsmith@corp.test</c>, sAMAccountName <c>jsmith2</c>), while <c>(sAMAccountName=jsmith)</c>
|
|
/// finds the admin. Before the fix this returned the admin's groups to Mallory.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UpnBind_ResolvedToAnotherAccount_DoesNotReturnTheSearchedEntrysGroups()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI(@"u:CORP\jsmith2")
|
|
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith",
|
|
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jsmith" } });
|
|
var factory = new SequenceLdapConnectionFactory(fake);
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), factory), "jsmith", "mallorys-own-password");
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Empty(result.Groups);
|
|
Assert.Equal(new[] { "jsmith@corp.test" }, fake.BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WhoAmIAccountName_MatchingTheEntry_Succeeds_AndRequestsSamAccountName()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI(@"u:CORP\JSmith")
|
|
.WithUserEntry("CN=John Smith,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "John Smith",
|
|
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jsmith" } });
|
|
var factory = new SequenceLdapConnectionFactory(fake);
|
|
var svc = new LdapAuthService(Opts() with { UserNameAttribute = "cn" }, factory);
|
|
|
|
var result = await Login(svc, "John Smith");
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Contains("sAMAccountName", fake.SearchAttributes!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WhoAmIAccountName_WithoutDomain_IsAccepted()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI("u:jdoe")
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
|
|
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } });
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WhoAmIAccountName_EntryWithoutSamAccountName_FailsClosed()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI(@"u:CORP\jdoe")
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WhoAmIDn_NamingAnotherEntry_FailsClosed()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI("dn:CN=Mallory,OU=u,DC=x")
|
|
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith");
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Empty(result.Groups);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WhoAmIDn_IsComparedAsADn_NotAsAString()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithWhoAmI("dn:cn=jane doe, ou=U, dc=X")
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)] // server does not support Who-am-I (GLAuth: protocol error 2)
|
|
[InlineData("")] // anonymous / empty answer
|
|
[InlineData("x:garbage")]
|
|
public async Task UpnTemplate_WithoutAWhoAmIAnswer_FailsClosed_WithoutSearching(string? whoAmI)
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
|
|
if (whoAmI is not null)
|
|
fake.WithWhoAmI(whoAmI);
|
|
|
|
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.DoesNotContain(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DownLevelTemplate_WithoutAWhoAmIAnswer_FailsClosed()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
|
|
var svc = new LdapAuthService(Opts() with { UserBindTemplate = @"CORP\{0}" }, new SequenceLdapConnectionFactory(fake));
|
|
|
|
var result = await Login(svc, "jdoe");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DnTemplate_WithoutWhoAmI_AcceptsTheEntryAtTheBoundDn()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=jdoe,ou=Users,dc=x", new[] { "cn=UG_A,ou=g,dc=x" }, "Jane Doe");
|
|
var svc = new LdapAuthService(
|
|
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
|
|
new SequenceLdapConnectionFactory(fake));
|
|
|
|
var result = await Login(svc, "jdoe");
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DnTemplate_WithoutWhoAmI_RefusesAnEntryAtAnotherDn()
|
|
{
|
|
// Mallory binds as cn=mallory with her own password; (mail=mallory) finds the admin.
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=admin,ou=Users,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
|
|
var svc = new LdapAuthService(
|
|
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "mail" },
|
|
new SequenceLdapConnectionFactory(fake));
|
|
|
|
var result = await Login(svc, "mallory");
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Equal(new[] { "cn=mallory,ou=users,dc=x" }, fake.BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DnTemplate_EscapesTheName_SoItCannotRewriteTheBindDn()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=admin,ou=Admins,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
|
|
var svc = new LdapAuthService(
|
|
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
|
|
new SequenceLdapConnectionFactory(fake));
|
|
|
|
var result = await Login(svc, "admin,ou=Admins");
|
|
|
|
Assert.Equal(new[] { @"cn=admin\,ou=Admins,ou=users,dc=x" }, fake.BoundDns);
|
|
Assert.False(result.Succeeded);
|
|
}
|
|
}
|