Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthUserBindIdentityTests.cs
T
Joseph Doherty d549a782fd fix(auth): user bind proves the bound account is the entry whose groups it returns (scadaproj#40)
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
2026-11-09 15:02:16 -05:00

86 lines
3.8 KiB
C#

// Live user-bind identity test (Auth-007, scadaproj#40) — opt-in only.
//
// Start the throwaway fixture (NOT the shared rig — it needs users that can search):
// cd tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind && docker compose up -d
// Run:
// ZB_LDAP_USERBIND_IT=1 dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
// --filter "FullyQualifiedName~GLAuthUserBindIdentityTests"
// Optional: ZB_LDAP_USERBIND_SERVER (default localhost), ZB_LDAP_USERBIND_PORT (default 3897).
//
// GLAuth does not implement RFC 4532 Who-am-I (it answers protocol error 2), so these tests also
// prove the DN-template fallback path against a real directory.
using System.Net.Sockets;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
public sealed class GLAuthUserBindIdentityTests
{
private static LdapOptions Options(string template, string userNameAttribute)
{
var server = Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_SERVER") ?? "localhost";
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_PORT"), out var p) ? p : 3897;
try
{
using var tcp = new TcpClient();
if (!tcp.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3)))
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}.");
}
catch (Exception ex)
{
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}: {ex.Message}");
}
return new LdapOptions
{
Server = server,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
BindMode = LdapBindMode.User,
UserBindTemplate = template,
UserNameAttribute = userNameAttribute,
GroupAttribute = "memberOf",
ConnectionTimeoutMs = 3000,
};
}
/// <summary>
/// mallory binds with her own password; <c>(mail=mallory)</c> finds the <c>boss</c> entry, which
/// is in Admins. Before the fix this signed mallory in with boss's groups.
/// </summary>
[SkippableFact]
public async Task BoundAccount_DifferentFromTheSearchedEntry_IsRefused()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
var svc = new LdapAuthService(Options("cn={0},ou=Users,dc=zb,dc=local", "mail"));
var result = await svc.AuthenticateAsync("mallory", "password", CancellationToken.None);
Assert.False(result.Succeeded, $"signed in as {result.Username} with [{string.Join(",", result.Groups)}]");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.DoesNotContain("Admins", result.Groups);
}
/// <summary>Positive control: the same mechanism admits a user whose bind DN is the entry's DN.</summary>
[SkippableFact]
public async Task BoundAccount_SameAsTheSearchedEntry_Succeeds()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
// GLAuth reports search DNs as cn=<user>,ou=<group>,ou=users,<base>; it also accepts that
// form as a bind DN, so this template binds the exact DN the search returns.
var svc = new LdapAuthService(Options("cn={0},ou=Users,ou=users,dc=zb,dc=local", "cn"));
var result = await svc.AuthenticateAsync("boss", "password", CancellationToken.None);
Assert.True(result.Succeeded, $"failed: {result.Failure}");
Assert.Equal("boss", result.Username);
Assert.Contains("Admins", result.Groups);
}
}