feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection)
This commit is contained in:
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
|||||||
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
|
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
|
||||||
/// is one login starting at a stale endpoint and walking on.
|
/// is one login starting at a stale endpoint and walking on.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
|
||||||
|
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class LdapAuthService : ILdapAuthService
|
public sealed class LdapAuthService : ILdapAuthService
|
||||||
{
|
{
|
||||||
@@ -141,6 +145,18 @@ public sealed class LdapAuthService : ILdapAuthService
|
|||||||
// filter and the returned result (avoids two identities for one person).
|
// filter and the returned result (avoids two identities for one person).
|
||||||
username = username.Trim();
|
username = username.Trim();
|
||||||
|
|
||||||
|
// 3b. User-bind mode guards, before any network call. An empty password must never reach
|
||||||
|
// the directory: AD answers a simple bind with an empty password as an UNAUTHENTICATED
|
||||||
|
// bind and reports success, which would admit anyone. The name is reduced to the bare
|
||||||
|
// account name so the bind template, the search filter and the result share one spelling.
|
||||||
|
if (_options.BindMode == LdapBindMode.User)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||||
|
if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
||||||
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
|
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
|
||||||
var preferred = Volatile.Read(ref _preferredIndex);
|
var preferred = Volatile.Read(ref _preferredIndex);
|
||||||
@@ -235,74 +251,9 @@ public sealed class LdapAuthService : ILdapAuthService
|
|||||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
|
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Service-account bind so we can search for the user's DN. A bind failure
|
return _options.BindMode == LdapBindMode.User
|
||||||
// here is a service-account misconfiguration — DISTINCT from a user-credential
|
? CompleteAsUser(conn, username, password)
|
||||||
// failure — so it maps to ServiceAccountBindFailed.
|
: CompleteWithServiceAccount(conn, username, password);
|
||||||
try
|
|
||||||
{
|
|
||||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
|
||||||
}
|
|
||||||
catch (LdapException)
|
|
||||||
{
|
|
||||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Search for the user entry by the configured username attribute. A search
|
|
||||||
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
|
||||||
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
|
|
||||||
// closest system-side bucket until a DirectoryUnavailable value exists.
|
|
||||||
IReadOnlyList<LdapSearchEntry> entries;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
|
||||||
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
|
||||||
}
|
|
||||||
catch (LdapException)
|
|
||||||
{
|
|
||||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
|
||||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
|
||||||
// We never attempt a user bind against an ambiguous DN.
|
|
||||||
if (entries.Count == 0)
|
|
||||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
|
||||||
if (entries.Count >= 2)
|
|
||||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
|
||||||
|
|
||||||
var entry = entries[0];
|
|
||||||
|
|
||||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
|
||||||
// here is the end user's bad credentials — the directory answered, so this is
|
|
||||||
// returned directly and never retried against another endpoint.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
conn.Bind(entry.Dn, password);
|
|
||||||
}
|
|
||||||
catch (LdapException)
|
|
||||||
{
|
|
||||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
|
|
||||||
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
|
|
||||||
// too is an answer about the user, not about the endpoint, so it ends the walk.
|
|
||||||
IReadOnlyList<string> groups;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
groups = ExtractGroups(entry);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groups.Count == 0)
|
|
||||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
|
||||||
|
|
||||||
// 11. Success — and only here, with a verified password and >= 1 group.
|
|
||||||
var displayName = ExtractDisplayName(entry, username);
|
|
||||||
return LdapAuthResult.Success(username, displayName, groups);
|
|
||||||
}
|
}
|
||||||
catch (StageFailure stage)
|
catch (StageFailure stage)
|
||||||
{
|
{
|
||||||
@@ -323,6 +274,106 @@ public sealed class LdapAuthService : ILdapAuthService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
|
||||||
|
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||||
|
{
|
||||||
|
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a
|
||||||
|
// service-account misconfiguration — DISTINCT from a user-credential failure.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||||
|
}
|
||||||
|
catch (LdapException)
|
||||||
|
{
|
||||||
|
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||||
|
var entries = SearchUser(conn, username);
|
||||||
|
if (entries.Count == 0)
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||||
|
if (entries.Count >= 2)
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||||
|
|
||||||
|
var entry = entries[0];
|
||||||
|
|
||||||
|
// 9. User bind verifies the password; a failure is the user's bad credentials and final.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
conn.Bind(entry.Dn, password);
|
||||||
|
}
|
||||||
|
catch (LdapException)
|
||||||
|
{
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SucceedFromEntry(entry, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="LdapBindMode.User"/>: the user's own bind is the authentication; the same
|
||||||
|
/// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
|
||||||
|
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
|
||||||
|
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
|
||||||
|
/// </summary>
|
||||||
|
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||||
|
{
|
||||||
|
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
conn.Bind(bindName, password);
|
||||||
|
}
|
||||||
|
catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
|
||||||
|
{
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||||
|
}
|
||||||
|
catch (LdapException)
|
||||||
|
{
|
||||||
|
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a good bind, anything but exactly one readable entry is a failure to establish the
|
||||||
|
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
|
||||||
|
var entries = SearchUser(conn, username);
|
||||||
|
if (entries.Count != 1)
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||||
|
|
||||||
|
return SucceedFromEntry(entries[0], username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
|
||||||
|
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||||
|
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||||
|
}
|
||||||
|
catch (LdapException)
|
||||||
|
{
|
||||||
|
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Steps 10–11: fail-closed group extraction, then success.</summary>
|
||||||
|
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> groups;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
groups = ExtractGroups(entry);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
|
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||||
|
|
||||||
|
return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
|
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
|
||||||
/// out of a stage to the single fail-closed catch site. Never escapes this type.
|
/// out of a stage to the single fail-closed catch site. Never escapes this type.
|
||||||
@@ -337,7 +388,7 @@ public sealed class LdapAuthService : ILdapAuthService
|
|||||||
|
|
||||||
public LdapAuthFailure Failure { get; }
|
public LdapAuthFailure Failure { get; }
|
||||||
|
|
||||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
|
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||||
public string Stage { get; }
|
public string Stage { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
private bool _throwOnConnect;
|
private bool _throwOnConnect;
|
||||||
private bool _throwOnServiceBind;
|
private bool _throwOnServiceBind;
|
||||||
private bool _throwOnUserBind;
|
private bool _throwOnUserBind;
|
||||||
|
private int? _firstBindResultCode;
|
||||||
|
private bool _throwOnSearch;
|
||||||
|
|
||||||
// ---- observation -----
|
// ---- observation -----
|
||||||
|
|
||||||
@@ -32,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int BindAttempts { get; private set; }
|
public int BindAttempts { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Every call in order: <c>connect</c>, <c>bind:<dn></c>, <c>search:<filter></c>.</summary>
|
||||||
|
public List<string> Calls { get; } = new();
|
||||||
|
|
||||||
// ---- builder methods -----
|
// ---- builder methods -----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -83,6 +88,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
|
||||||
|
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
|
||||||
|
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
|
||||||
|
/// </summary>
|
||||||
|
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
|
||||||
|
{
|
||||||
|
_firstBindResultCode = resultCode;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
|
||||||
|
public FakeLdapConnection ThrowOnSearch()
|
||||||
|
{
|
||||||
|
_throwOnSearch = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
||||||
/// simulate an unreachable directory (infrastructure failure).
|
/// simulate an unreachable directory (infrastructure failure).
|
||||||
@@ -120,6 +143,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
int timeoutMs,
|
int timeoutMs,
|
||||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||||
{
|
{
|
||||||
|
Calls.Add("connect");
|
||||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||||
ConnectCertCallback = serverCertificateValidationCallback;
|
ConnectCertCallback = serverCertificateValidationCallback;
|
||||||
if (_throwOnConnect)
|
if (_throwOnConnect)
|
||||||
@@ -130,8 +154,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
public void Bind(string dn, string password)
|
public void Bind(string dn, string password)
|
||||||
{
|
{
|
||||||
BindAttempts++;
|
BindAttempts++;
|
||||||
|
Calls.Add($"bind:{dn}");
|
||||||
var isServiceBind = BindAttempts == 1;
|
var isServiceBind = BindAttempts == 1;
|
||||||
|
|
||||||
|
if (isServiceBind && _firstBindResultCode is { } code)
|
||||||
|
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
|
||||||
|
|
||||||
if ((_throwOnServiceBind && isServiceBind)
|
if ((_throwOnServiceBind && isServiceBind)
|
||||||
|| (_throwOnUserBind && !isServiceBind)
|
|| (_throwOnUserBind && !isServiceBind)
|
||||||
|| _throwBindDns.Contains(dn))
|
|| _throwBindDns.Contains(dn))
|
||||||
@@ -147,7 +175,13 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
|||||||
string searchBase,
|
string searchBase,
|
||||||
string filter,
|
string filter,
|
||||||
IReadOnlyList<string> attributes)
|
IReadOnlyList<string> attributes)
|
||||||
=> _scriptedEntries.AsReadOnly();
|
{
|
||||||
|
Calls.Add($"search:{filter}");
|
||||||
|
if (_throwOnSearch)
|
||||||
|
throw new Novell.Directory.Ldap.LdapException(
|
||||||
|
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
|
||||||
|
return _scriptedEntries.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() { /* nothing to clean up */ }
|
public void Dispose() { /* nothing to clean up */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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 static FakeLdapConnection Jane() => new FakeLdapConnection()
|
||||||
|
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", 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", "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().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().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().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
|
||||||
|
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||||
|
|
||||||
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user