feat(auth): LdapBindMode.User options and validation
This commit is contained in:
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
|
||||
/// <summary>How <see cref="LdapOptions"/> authenticates a sign-in.</summary>
|
||||
public enum LdapBindMode
|
||||
{
|
||||
/// <summary>Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).</summary>
|
||||
ServiceAccount,
|
||||
|
||||
/// <summary>
|
||||
/// Bind as the signing-in user (identity built from <see cref="LdapOptions.UserBindTemplate"/>);
|
||||
/// a successful bind authenticates them, and the same connection reads their own entry for groups.
|
||||
/// No service account is used.
|
||||
/// </summary>
|
||||
User,
|
||||
}
|
||||
|
||||
public sealed record LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
@@ -19,6 +33,27 @@ public sealed record LdapOptions
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the authentication flow. <see cref="LdapBindMode.ServiceAccount"/> (the default) is the
|
||||
/// 0.3.0 bind-then-search-then-bind flow; <see cref="LdapBindMode.User"/> binds as the user and needs
|
||||
/// no <see cref="ServiceAccountDn"/> / <see cref="ServiceAccountPassword"/>.
|
||||
/// </summary>
|
||||
public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
|
||||
/// normalised login name — e.g. <c>{0}@example.com</c> (UPN) or <c>EXAMPLE\{0}</c>. Must contain
|
||||
/// <c>{0}</c> exactly once.
|
||||
/// </summary>
|
||||
public string UserBindTemplate { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: when <see langword="true"/> (default) a login typed as
|
||||
/// <c>DOMAIN\name</c> or <c>name@suffix</c> is reduced to <c>name</c>; when <see langword="false"/>
|
||||
/// such input is refused as bad credentials without contacting the directory.
|
||||
/// </summary>
|
||||
public bool AcceptDomainQualifiedNames { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
|
||||
/// fails with a system-side error (connect/TLS, service-account bind, or search).
|
||||
|
||||
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
|
||||
/// <item>in <see cref="LdapBindMode.ServiceAccount"/> mode <see cref="LdapOptions.ServiceAccountDn"/>
|
||||
/// must be specified — an empty value would bind anonymously, defeating the search-then-bind
|
||||
/// authentication flow; in <see cref="LdapBindMode.User"/> mode <see cref="LdapOptions.UserBindTemplate"/>
|
||||
/// must contain <c>{0}</c> exactly once instead;</item>
|
||||
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
|
||||
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
|
||||
/// until the primary outage it exists to survive.</item>
|
||||
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
if (options.BindMode == LdapBindMode.User)
|
||||
{
|
||||
// User mode binds as the signing-in user, so there is no service account to require.
|
||||
// The template is the bind identity: without exactly one {0} every login would bind as
|
||||
// the same fixed name (or as a name with the user spliced in twice).
|
||||
if (CountPlaceholders(options.UserBindTemplate) != 1)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
|
||||
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
|
||||
"or \"EXAMPLE\\{0}\".");
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
|
||||
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static int CountPlaceholders(string? template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
return 0;
|
||||
|
||||
var count = 0;
|
||||
for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
|
||||
i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
|
||||
await host.StartAsync();
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_BindsUserBindModeKeys()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
|
||||
[$"{LdapSection}:BindMode"] = "User",
|
||||
[$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
|
||||
[$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<LdapOptions>>().Value;
|
||||
|
||||
Assert.Equal(LdapBindMode.User, options.BindMode);
|
||||
Assert.Equal("{0}@corp.test", options.UserBindTemplate);
|
||||
Assert.False(options.AcceptDomainQualifiedNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
|
||||
.Failed);
|
||||
|
||||
private static LdapOptions UserOpts() => Opts() with
|
||||
{
|
||||
BindMode = LdapBindMode.User,
|
||||
UserBindTemplate = "{0}@corp.test",
|
||||
ServiceAccountDn = "",
|
||||
ServiceAccountPassword = "",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Defaults_KeepServiceAccountMode()
|
||||
{
|
||||
var o = new LdapOptions();
|
||||
Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
|
||||
Assert.Equal("", o.UserBindTemplate);
|
||||
Assert.True(o.AcceptDomainQualifiedNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_UserMode_DoesNotRequireServiceAccount() =>
|
||||
Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
[InlineData("{0}{0}@corp.test")]
|
||||
public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
|
||||
{
|
||||
var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user