diff --git a/CLAUDE.md b/CLAUDE.md index 19038be..75fa909 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ each project's **code-verified current state**, and the **gaps** between. See | Component | Status | Goal | Design | Implementation | |---|---|---|---|---| -| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | +| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.0`** (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | | UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | | Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) | | Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) | diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props index 7e57b98..d8cc15d 100644 --- a/ZB.MOM.WW.Auth/Directory.Build.props +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -5,12 +5,10 @@ enable enable latest - - 0.3.0 + + 0.4.0 true diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md index 1fc55f3..2be3643 100644 --- a/ZB.MOM.WW.Auth/README.md +++ b/ZB.MOM.WW.Auth/README.md @@ -30,7 +30,16 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O ## Versioning -All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. +All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. + +### 0.4.0 — LDAP sign-in by user bind (opt-in) + +- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `{0}@example.com` or `EXAMPLE\{0}`) and **`AcceptDomainQualifiedNames`** (default `true`). +- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated. +- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`. +- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling. +- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged. +- `UserBindTemplate` output is used verbatim as the bind name, so use a UPN (`{0}@example.com`) or down-level (`EXAMPLE\{0}`) template; a DN template is not escaped. ### 0.3.0 — SQL Server API-key store (ScadaBridge central pair) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs index f143205..95a2527 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs @@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap; public enum LdapTransport { Ldaps, StartTls, None } +/// How authenticates a sign-in. +public enum LdapBindMode +{ + /// Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour). + ServiceAccount, + + /// + /// Bind as the signing-in user (identity built from ); + /// a successful bind authenticates them, and the same connection reads their own entry for groups. + /// No service account is used. + /// + 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; + /// + /// Selects the authentication flow. (the default) is the + /// 0.3.0 bind-then-search-then-bind flow; binds as the user and needs + /// no / . + /// + public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount; + + /// + /// only: the simple-bind identity, with {0} replaced by the + /// normalised login name — e.g. {0}@example.com (UPN) or EXAMPLE\{0}. Must contain + /// {0} exactly once. + /// + public string UserBindTemplate { get; init; } = ""; + + /// + /// only: when (default) a login typed as + /// DOMAIN\name or name@suffix is reduced to name; when + /// such input is refused as bad credentials without contacting the directory. + /// + public bool AcceptDomainQualifiedNames { get; init; } = true; + /// /// 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). diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs new file mode 100644 index 0000000..8c500a6 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs @@ -0,0 +1,57 @@ +namespace ZB.MOM.WW.Auth.Ldap.Internal; + +/// +/// Reduces a typed login to the bare account name used by : +/// one spelling flows into the bind template, the search filter and the returned username, so audit +/// rows never carry DOMAIN\jdoe for one sign-in and jdoe for the next. +/// +internal static class LdapUserName +{ + /// + /// Trims and, when , strips one + /// leading DOMAIN\ or one trailing @suffix. Returns (with + /// empty) for blank input, for both forms at once, for a repeated + /// separator, for an empty side, or for any qualified form when + /// is . + /// + public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name) + { + name = ""; + if (string.IsNullOrWhiteSpace(input)) + return false; + + var s = input.Trim(); + var slash = s.IndexOf('\\'); + var at = s.IndexOf('@'); + + if (slash >= 0 || at >= 0) + { + if (!acceptDomainQualified || (slash >= 0 && at >= 0)) + return false; + + if (slash >= 0) + { + if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0) + return false; + s = s[(slash + 1)..]; + } + else + { + // Unlike the slash branch (whose extracted suffix is checked for emptiness by the + // final length check below), the extracted piece here is the *prefix* — so an empty + // domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly. + if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0) + return false; + s = s[..at]; + } + + s = s.Trim(); + } + + if (s.Length == 0) + return false; + + name = s; + return true; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs index bbce996..676cbd6 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs @@ -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 /// is one login starting at a stale endpoint and walking on. /// +/// +/// With BindMode = User the service-account bind and the user re-bind are replaced by a +/// single bind as the user (); see . +/// /// 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). 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 // (no FallbackServers) collapses to exactly one attempt — unchanged behaviour. var preferred = Volatile.Read(ref _preferredIndex); @@ -235,74 +251,9 @@ public sealed class LdapAuthService : ILdapAuthService throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect"); } - // 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 — so it maps to ServiceAccountBindFailed. - 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 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 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); + return _options.BindMode == LdapBindMode.User + ? CompleteAsUser(conn, username, password) + : CompleteWithServiceAccount(conn, username, password); } catch (StageFailure stage) { @@ -323,6 +274,106 @@ public sealed class LdapAuthService : ILdapAuthService } } + /// Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups. + 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); + } + + /// + /// : 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. + /// + 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); + } + + /// Step 7: search for the user entry; a search fault is system-side. + private IReadOnlyList 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"); + } + } + + /// Steps 10–11: fail-closed group extraction, then success. + private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username) + { + IReadOnlyList 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); + } + /// /// Internal control-flow exception that carries an already-mapped /// 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; } - /// Which stage faulted (connect / service-bind / search), for diagnostics. + /// Which stage faulted (connect / service-bind / user-bind / search), for diagnostics. public string Stage { get; } } diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs index c5fdf08..ed4c26f 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs @@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap; /// must be specified (no sane default host); /// must be specified (the DN root every /// search runs against); -/// must be specified — an empty value -/// would bind anonymously, defeating the search-then-bind authentication flow; +/// in mode +/// must be specified — an empty value would bind anonymously, defeating the search-then-bind +/// authentication flow; in mode +/// must contain {0} exactly once instead; /// every entry must parse as /// "host" or "host:port" — a typo'd backup DC must not lie dormant /// until the primary outage it exists to survive. @@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions "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 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; + } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs index e59fd30..cb2c958 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs @@ -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 + { + [$"{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>().Value; + + Assert.Equal(LdapBindMode.User, options.BindMode); + Assert.Equal("{0}@corp.test", options.UserBindTemplate); + Assert.False(options.AcceptDomainQualifiedNames); + } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs index 97ec79d..ddb3e23 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs @@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection private bool _throwOnConnect; private bool _throwOnServiceBind; private bool _throwOnUserBind; + private int? _firstBindResultCode; + private bool _throwOnSearch; // ---- observation ----- @@ -32,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection /// public int BindAttempts { get; private set; } + /// Every call in order: connect, bind:<dn>, search:<filter>. + public List Calls { get; } = new(); + // ---- builder methods ----- /// @@ -83,6 +88,24 @@ internal sealed class FakeLdapConnection : ILdapConnection return this; } + /// + /// Throw with 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. + /// + public FakeLdapConnection ThrowOnFirstBind(int resultCode) + { + _firstBindResultCode = resultCode; + return this; + } + + /// Throw from (unhealthy directory). + public FakeLdapConnection ThrowOnSearch() + { + _throwOnSearch = true; + return this; + } + /// /// Throw from to /// simulate an unreachable directory (infrastructure failure). @@ -120,6 +143,7 @@ internal sealed class FakeLdapConnection : ILdapConnection int timeoutMs, RemoteCertificateValidationCallback? serverCertificateValidationCallback = null) { + Calls.Add("connect"); ConnectArgs = (host, port, transport, allowInsecure, timeoutMs); ConnectCertCallback = serverCertificateValidationCallback; if (_throwOnConnect) @@ -130,8 +154,12 @@ internal sealed class FakeLdapConnection : ILdapConnection public void Bind(string dn, string password) { BindAttempts++; + Calls.Add($"bind:{dn}"); var isServiceBind = BindAttempts == 1; + if (isServiceBind && _firstBindResultCode is { } code) + throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn); + if ((_throwOnServiceBind && isServiceBind) || (_throwOnUserBind && !isServiceBind) || _throwBindDns.Contains(dn)) @@ -147,7 +175,13 @@ internal sealed class FakeLdapConnection : ILdapConnection string searchBase, string filter, IReadOnlyList 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 */ } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs new file mode 100644 index 0000000..1276c1a --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs @@ -0,0 +1,181 @@ +using Novell.Directory.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Ldap; + +namespace ZB.MOM.WW.Auth.Ldap.Tests; + +/// +/// : bind as the signing-in user, then read their own entry on the +/// same connection. The service account in is deliberately populated so the +/// tests prove it is never used. +/// +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 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(), "Jane")); + var result = await Login(new LdapAuthService(Opts(), factory), "jdoe"); + + Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs index 144a64e..5b63af7 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs @@ -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); + } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs new file mode 100644 index 0000000..8d0da1b --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.Auth.Ldap.Internal; + +namespace ZB.MOM.WW.Auth.Ldap.Tests; + +public class LdapUserNameTests +{ + [Theory] + [InlineData("jdoe")] + [InlineData(" jdoe ")] + [InlineData(@"NAM\jdoe")] + [InlineData("jdoe@corp.test")] + public void Accepting_ReducesToBareName(string input) + { + Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name)); + Assert.Equal("jdoe", name); + } + + [Theory] + [InlineData(@"NAM\jdoe")] + [InlineData("jdoe@corp.test")] + public void Strict_RefusesQualifiedNames(string input) => + Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _)); + + [Fact] + public void Strict_AcceptsBareName() + { + Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name)); + Assert.Equal("jdoe", name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("@x")] + [InlineData(@"\jdoe")] + [InlineData("jdoe@")] + [InlineData(@"NAM\")] + [InlineData(@"a\b\c")] + [InlineData("a@b@c")] + [InlineData(@"NAM\jdoe@corp.test")] + public void Unusable_IsRefused(string? input) + { + Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name)); + Assert.Equal("", name); + } +}