From c3b466e13d4d7faa79c18296b05e9308a0a57091 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:51:10 -0400 Subject: [PATCH] feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4) --- .../DashboardLdapLiveTests.cs | 84 ++++-- .../EffectiveLdapConfiguration.cs | 4 +- .../GatewayConfigurationProvider.cs | 4 +- .../Configuration/GatewayOptionsValidator.cs | 5 +- .../Configuration/LdapOptions.cs | 17 +- .../Components/Pages/SettingsPage.razor | 2 +- .../Dashboard/DashboardAuthenticator.cs | 231 +++------------ .../DashboardServiceCollectionExtensions.cs | 17 +- .../GatewayApplication.cs | 2 +- .../ZB.MOM.WW.MxGateway.Server.csproj | 3 +- .../appsettings.json | 4 +- .../Dashboard/DashboardAuthenticatorTests.cs | 269 +++++++++++++----- .../Gateway/GatewayApplicationTests.cs | 4 +- 13 files changed, 344 insertions(+), 302 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs index f802660..0d2d39a 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -1,8 +1,11 @@ using System.Security.Claims; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Ldap; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; +using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions; namespace ZB.MOM.WW.MxGateway.IntegrationTests; @@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); - // IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a - // ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded - // GroupToRole map (GwAdmin -> Admin) means the admin principal must - // carry Role=Admin alongside the raw LDAP-group claim. A regression in - // MapGroupsToRoles (returning an empty list, missing the RDN fallback) - // would silently pass without this assertion. + // IntegrationTests-023: DashboardAuthenticator builds the principal with a + // ClaimTypes.Role claim resolved from the LDAP groups via the + // DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin) + // means the admin principal must carry Role=Admin alongside the raw LDAP-group + // claim. A regression in the group→role mapping would fail this assertion. Assert.Contains(result.Principal.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == DashboardRoles.Admin); @@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests [LiveLdapFact] public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() { - // Exercises the LdapException branch: the user exists and the service + // Exercises the user-bind-failure branch: the user exists and the service // account search succeeds, but the candidate bind is rejected. const string wrongPassword = "definitely-not-the-admin-password"; DashboardAuthenticator authenticator = CreateAuthenticator(); @@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests [LiveLdapFact] public async Task AuthenticateAsync_UnknownUsername_Fails() { - // Exercises the `candidate is null` branch: the service-account search - // returns no entry, so no candidate bind is attempted. + // Exercises the user-not-found branch: the service-account search returns no + // entry, so no candidate bind is attempted. DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( @@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() { // Exercises the connect-failure path: a closed loopback port produces a - // connection error that DashboardAuthenticator must absorb into a Fail + // connection error that the shared LdapAuthService must absorb into a Fail // result rather than propagating an exception to the dashboard. - DashboardAuthenticator authenticator = new( - Options.Create(new GatewayOptions - { - Ldap = new LdapOptions - { - // 1 is a reserved port number that no LDAP server listens on. - Port = 1, - }, - }), - NullLogger.Instance); + DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with + { + // 1 is a reserved port number that no LDAP server listens on. + Port = 1, + }); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "admin", @@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests Assert.Null(result.Principal); } - private static DashboardAuthenticator CreateAuthenticator() + private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions()); + + private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) { - return new DashboardAuthenticator( - Options.Create(new GatewayOptions + GatewayOptions gatewayOptions = new() + { + Dashboard = new DashboardOptions { - Dashboard = new DashboardOptions + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { - GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["GwAdmin"] = DashboardRoles.Admin, - }, + ["GwAdmin"] = DashboardRoles.Admin, }, - }), + }, + }; + + return new DashboardAuthenticator( + new LdapAuthService(ldapOptions), + new DashboardGroupRoleMapper(Options.Create(gatewayOptions)), NullLogger.Instance); } + + /// + /// Builds the shared library from the gateway's + /// default LDAP settings so the live tests exercise the same seeded directory the + /// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev). + /// + private static LibraryLdapOptions LibraryOptions() + { + ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new(); + return new LibraryLdapOptions + { + Enabled = gateway.Enabled, + Server = gateway.Server, + Port = gateway.Port, + Transport = gateway.Transport, + AllowInsecure = gateway.AllowInsecure, + SearchBase = gateway.SearchBase, + ServiceAccountDn = gateway.ServiceAccountDn, + ServiceAccountPassword = gateway.ServiceAccountPassword, + UserNameAttribute = gateway.UserNameAttribute, + DisplayNameAttribute = gateway.DisplayNameAttribute, + GroupAttribute = gateway.GroupAttribute, + }; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs index 6a19c66..e1e82a4 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs @@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration( bool Enabled, string Server, int Port, - bool UseTls, - bool AllowInsecureLdap, + string Transport, + bool AllowInsecure, string SearchBase, string ServiceAccountDn, string ServiceAccountPassword, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index fb4e850..b492e60 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions option Enabled: value.Ldap.Enabled, Server: value.Ldap.Server, Port: value.Ldap.Port, - UseTls: value.Ldap.UseTls, - AllowInsecureLdap: value.Ldap.AllowInsecureLdap, + Transport: value.Ldap.Transport.ToString(), + AllowInsecure: value.Ldap.AllowInsecure, SearchBase: value.Ldap.SearchBase, ServiceAccountDn: value.Ldap.ServiceAccountDn, ServiceAccountPassword: RedactedValue, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index fdc104f..c228a5c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.MxGateway.Contracts; @@ -82,9 +83,9 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBaseGets the LDAP server port. public int Port { get; init; } = 3893; - /// Gets a value indicating whether TLS is required for the connection. - public bool UseTls { get; init; } + /// + /// Gets the transport/TLS mode for the LDAP connection. Replaces the former + /// boolean UseTls (true ≈ , false = + /// ). upgrades + /// a plaintext connection to TLS. Matches the shared + /// field so the + /// MxGateway:Ldap section binds straight onto the shared options. + /// + public LdapTransport Transport { get; init; } = LdapTransport.None; - /// Gets a value indicating whether insecure LDAP connections are allowed. - public bool AllowInsecureLdap { get; init; } = true; + /// Gets a value indicating whether insecure (plaintext) LDAP connections are allowed. + public bool AllowInsecure { get; init; } = true; /// Gets the LDAP search base distinguished name. public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor index 600d022..628be01 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor @@ -26,7 +26,7 @@ else Run migrations@Snapshot.Configuration.Authentication.RunMigrationsOnStartup LDAP enabled@Snapshot.Configuration.Ldap.Enabled LDAP server@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port - LDAP TLS@Snapshot.Configuration.Ldap.UseTls + LDAP transport@Snapshot.Configuration.Ldap.Transport LDAP search base@Snapshot.Configuration.Ldap.SearchBase LDAP service account@Snapshot.Configuration.Ldap.ServiceAccountDn LDAP service password@Snapshot.Configuration.Ldap.ServiceAccountPassword diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs index ff0b11b..93efe70 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -1,14 +1,25 @@ using System.Security.Claims; -using System.Text; -using Microsoft.Extensions.Options; -using ZB.MOM.WW.MxGateway.Server.Configuration; -using ZB.MOM.WW.MxGateway.Server.Security.Authorization; -using Novell.Directory.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Roles; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; +/// +/// Authenticates interactive dashboard logins against LDAP. The bind/search +/// mechanics are delegated to the shared +/// (ZB.MOM.WW.Auth.Ldap), which performs bind-then-search, fails closed, +/// and never throws — returning the user's display name and LDAP groups on +/// success. This class keeps the dashboard-specific policy: groups are resolved +/// to dashboard roles via , a login with no +/// matching role is denied, and the resulting is +/// shaped exactly as before (see ). +/// +/// Shared LDAP bind-then-search provider. +/// Maps LDAP groups to dashboard roles (Task 1.1 seam). +/// Logger for diagnostic, credential-free login outcomes. public sealed class DashboardAuthenticator( - IOptions options, + ILdapAuthService ldapAuthService, + IGroupRoleMapper roleMapper, ILogger logger) : IDashboardAuthenticator { private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized."; @@ -19,202 +30,46 @@ public sealed class DashboardAuthenticator( string? password, CancellationToken cancellationToken) { - LdapOptions ldapOptions = options.Value.Ldap; - DashboardOptions dashboardOptions = options.Value.Dashboard; - if (!ldapOptions.Enabled - || string.IsNullOrWhiteSpace(username) - || string.IsNullOrWhiteSpace(password)) - { - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - - if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap) + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return DashboardAuthenticationResult.Fail(GenericFailureMessage); } string normalizedUsername = username.Trim(); - try - { - using LdapConnection connection = new(); - connection.SecureSocketLayer = ldapOptions.UseTls; - - await Task.Run( - () => connection.Connect(ldapOptions.Server, ldapOptions.Port), - cancellationToken) - .ConfigureAwait(false); - - await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false); - LdapEntry? candidate = await SearchUserAsync( - connection, - ldapOptions, - normalizedUsername, - cancellationToken) - .ConfigureAwait(false); - - if (candidate is null) - { - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - - await Task.Run( - () => connection.Bind(candidate.Dn, password), - cancellationToken) - .ConfigureAwait(false); - - await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false); - LdapEntry? authenticatedEntry = await SearchUserAsync( - connection, - ldapOptions, - normalizedUsername, - cancellationToken) - .ConfigureAwait(false); - - if (authenticatedEntry is null) - { - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - - string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute) - ?? normalizedUsername; - IReadOnlyList groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute); - - IReadOnlyList roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole); - if (roles.Count == 0) - { - logger.LogInformation( - "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.", - normalizedUsername); - - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - - return DashboardAuthenticationResult.Success(CreatePrincipal( - normalizedUsername, - displayName, - groups, - roles)); - } - catch (OperationCanceledException) - { - throw; - } - catch (LdapException ex) - { - logger.LogInformation( - "LDAP dashboard login rejected for user {User}: result code {ResultCode}.", - normalizedUsername, - ex.ResultCode); - - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername); - - return DashboardAuthenticationResult.Fail(GenericFailureMessage); - } - } - - /// Escapes special characters in LDAP filter strings. - /// The string value to escape. - internal static string EscapeLdapFilter(string value) - { - StringBuilder builder = new(value.Length); - foreach (char character in value) - { - builder.Append(character switch - { - '\\' => @"\5c", - '*' => @"\2a", - '(' => @"\28", - ')' => @"\29", - '\0' => @"\00", - _ => character.ToString() - }); - } - - return builder.ToString(); - } - - /// - /// Maps the user's LDAP groups to dashboard roles. A user can pick up - /// multiple roles; Admin and Viewer are the only legal values. Returns - /// an empty list when no group matches (caller rejects the login). - /// Delegates to , the single source - /// of truth shared with . - /// - /// The collection of LDAP groups the user belongs to. - /// The mapping from group names to dashboard role names. - internal static IReadOnlyList MapGroupsToRoles( - IEnumerable groups, - IReadOnlyDictionary groupToRole) - => DashboardGroupRoleMapping.MapGroupsToRoles(groups, groupToRole); - - /// Extracts the first RDN value from a distinguished name. - /// The LDAP distinguished name. - internal static string ExtractFirstRdnValue(string distinguishedName) - => DashboardGroupRoleMapping.ExtractFirstRdnValue(distinguishedName); - - private static Task BindServiceAccountAsync( - LdapConnection connection, - LdapOptions ldapOptions, - CancellationToken cancellationToken) - { - return Task.Run( - () => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword), - cancellationToken); - } - - private static async Task SearchUserAsync( - LdapConnection connection, - LdapOptions ldapOptions, - string username, - CancellationToken cancellationToken) - { - string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})"; - ILdapSearchResults results = await Task.Run( - () => connection.Search( - ldapOptions.SearchBase, - LdapConnection.ScopeSub, - filter, - attrs: null, - typesOnly: false), - cancellationToken) + // The shared service owns connect/bind/search and the fail-closed contract: + // it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via + // its startup validator, and never throws. We only translate its outcome into a + // dashboard principal here. + LdapAuthResult ldapResult = await ldapAuthService + .AuthenticateAsync(normalizedUsername, password, cancellationToken) .ConfigureAwait(false); - LdapEntry? entry = null; - while (results.HasMore()) + if (!ldapResult.Succeeded) { - LdapEntry next = results.Next(); - if (entry is not null) - { - return null; - } - - entry = next; + return DashboardAuthenticationResult.Fail(GenericFailureMessage); } - return entry; - } + GroupRoleMapping mapping = await roleMapper + .MapAsync(ldapResult.Groups, cancellationToken) + .ConfigureAwait(false); - private static string? ReadAttribute(LdapEntry entry, string attributeName) - { - return ReadLdapAttribute(entry, attributeName)?.StringValue; - } + IReadOnlyList roles = mapping.Roles; + if (roles.Count == 0) + { + // Preserve the long-standing "no roles matched -> login denied" rule. + logger.LogInformation( + "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.", + ldapResult.Username); - private static IReadOnlyList ReadAttributeValues(LdapEntry entry, string attributeName) - { - LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName); - return attribute?.StringValueArray ?? []; - } + return DashboardAuthenticationResult.Fail(GenericFailureMessage); + } - private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName) - { - return entry.GetAttribute(attributeName) - ?? entry.GetAttribute(attributeName.ToLowerInvariant()) - ?? entry.GetAttribute(attributeName.ToUpperInvariant()); + return DashboardAuthenticationResult.Success(CreatePrincipal( + ldapResult.Username, + ldapResult.DisplayName, + ldapResult.Groups, + roles)); } private static ClaimsPrincipal CreatePrincipal( diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 3be4d57..98bf16e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -16,8 +18,21 @@ public static class DashboardServiceCollectionExtensions /// Registers all dashboard services, authentication, and Razor components. /// /// Service collection to register services. - public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) + /// + /// Application configuration, used to bind the shared LDAP provider's options + /// from the MxGateway:Ldap section. + /// + public static IServiceCollection AddGatewayDashboard( + this IServiceCollection services, + IConfiguration configuration) { + // Dashboard logins delegate bind/search to the shared ZB.MOM.WW.Auth.Ldap + // provider. Its LdapOptions bind straight from MxGateway:Ldap (the gateway's + // LdapOptions field names match the shared options: Transport / AllowInsecure / + // SearchBase / ServiceAccount* / *Attribute). AddZbLdapAuth also adds a + // ValidateOnStart() so an insecure-transport misconfiguration fails fast at boot. + services.AddZbLdapAuth(configuration, "MxGateway:Ldap"); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 60abdc2..62eb5cc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -91,7 +91,7 @@ public static class GatewayApplication builder.Services.AddWorkerProcessLauncher(); builder.Services.AddGatewaySessions(); builder.Services.AddGatewayAlarms(); - builder.Services.AddGatewayDashboard(); + builder.Services.AddGatewayDashboard(builder.Configuration); builder.Services.AddGalaxyRepository(); return builder; diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index 2c156b7..fdeaacb 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -7,6 +7,8 @@ + + @@ -17,7 +19,6 @@ - diff --git a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json index bc565ee..a5fa095 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json +++ b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json @@ -22,8 +22,8 @@ "Enabled": true, "Server": "localhost", "Port": 3893, - "UseTls": false, - "AllowInsecureLdap": true, + "Transport": "None", + "AllowInsecure": true, "SearchBase": "dc=lmxopcua,dc=local", "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local", "ServiceAccountPassword": "serviceaccount123", diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index 7cb977d..a7598fe 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -1,90 +1,74 @@ +using System.Security.Claims; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; +/// +/// Parity tests for after the cutover to the +/// shared . The library now owns connect/bind/search +/// (covered by its own tests); these tests fake the shared service to return known +/// groups and assert the dashboard-specific policy is unchanged: groups → roles via +/// , no-roles-matched is denied, and the +/// resulting principal/claims keep their exact shape. +/// public sealed class DashboardAuthenticatorTests { - /// Verifies that LDAP filter special characters are escaped correctly. - [Fact] - public void EscapeLdapFilter_EscapesSpecialCharacters() - { - string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f"); - - Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped); - } - - /// Verifies that group-to-role mapping resolves by short name and distinguished name. - /// The LDAP group name or distinguished name. - /// The expected role or null if no match. + /// A blank username is rejected without touching the LDAP provider. [Theory] - [InlineData("GwAdmin", DashboardRoles.Admin)] - [InlineData("gwadmin", DashboardRoles.Admin)] - [InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)] - [InlineData("OtherGroup", null)] - public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName( - string ldapGroup, - string? expectedRole) + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username) { - Dictionary mapping = new(StringComparer.OrdinalIgnoreCase) - { - ["GwAdmin"] = DashboardRoles.Admin, - ["GwReader"] = DashboardRoles.Viewer, - }; + FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); - IReadOnlyList roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping); + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + username, + "password", + CancellationToken.None); - if (expectedRole is null) - { - Assert.Empty(roles); - } - else - { - Assert.Equal(expectedRole, Assert.Single(roles)); - } + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.False(ldap.WasCalled); } - /// Verifies that admin and viewer roles are both emitted when groups are present. + /// A blank password is rejected without touching the LDAP provider. [Fact] - public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted() + public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap() { - Dictionary mapping = new(StringComparer.OrdinalIgnoreCase) - { - ["GwAdmin"] = DashboardRoles.Admin, - ["GwReader"] = DashboardRoles.Viewer, - }; + FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); - IReadOnlyList roles = DashboardAuthenticator.MapGroupsToRoles( - ["GwAdmin", "GwReader"], - mapping); + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + " ", + CancellationToken.None); - Assert.Contains(DashboardRoles.Admin, roles); - Assert.Contains(DashboardRoles.Viewer, roles); + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + Assert.False(ldap.WasCalled); } - /// Verifies that extraction returns the leading RDN value from a distinguished name. - [Fact] - public void ExtractFirstRdnValue_ReturnsLeadingRdnValue() + /// + /// A failed LDAP outcome (any failure bucket, including ) + /// maps to the generic dashboard failure without leaking the raw password. + /// + [Theory] + [InlineData(LdapAuthFailure.Disabled)] + [InlineData(LdapAuthFailure.BadCredentials)] + [InlineData(LdapAuthFailure.UserNotFound)] + [InlineData(LdapAuthFailure.ServiceAccountBindFailed)] + public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials( + LdapAuthFailure failure) { - string result = DashboardAuthenticator.ExtractFirstRdnValue( - "CN=Gateway Admins,OU=Groups,DC=example,DC=com"); - - Assert.Equal("Gateway Admins", result); - } - - /// Verifies that authentication fails when LDAP is disabled without exposing raw credentials. - [Fact] - public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials() - { - DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions - { - Ldap = new LdapOptions - { - Enabled = false, - }, - }); + FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure)); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "admin", @@ -96,10 +80,161 @@ public sealed class DashboardAuthenticatorTests Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal); } - private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options) + /// + /// On success the principal carries the resolved roles, the raw LDAP-group claims, + /// the display name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), + /// under the dashboard authentication scheme — the exact shape produced before the cutover. + /// + [Fact] + public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims() { + FakeLdapAuthService ldap = new(LdapAuthResult.Success( + username: "admin", + displayName: "Administrator", + groups: ["GwAdmin", "GwReader"])); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + "admin123", + CancellationToken.None); + + Assert.True(result.Succeeded); + ClaimsPrincipal principal = Assert.IsType(result.Principal); + + Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value); + + // Identity is built with the dashboard scheme and Name/Role claim types so + // IsInRole and Identity.Name keep working. + ClaimsIdentity identity = Assert.IsType(principal.Identity); + Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType); + Assert.Equal("Administrator", identity.Name); + + // Both groups mapped → both roles present. + Assert.True(principal.IsInRole(DashboardRoles.Admin)); + Assert.True(principal.IsInRole(DashboardRoles.Viewer)); + + // Raw LDAP groups are surfaced under the dedicated group claim type. + IReadOnlyList groupClaims = principal.FindAll( + DashboardAuthenticationDefaults.LdapGroupClaimType) + .Select(claim => claim.Value) + .ToList(); + Assert.Equal(["GwAdmin", "GwReader"], groupClaims); + } + + /// + /// When the user authenticates but none of their groups map to a dashboard role, + /// the login is denied (the long-standing "no roles matched → denied" rule). + /// + [Fact] + public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin() + { + FakeLdapAuthService ldap = new(LdapAuthResult.Success( + username: "nobody", + displayName: "No Body", + groups: ["SomeUnmappedGroup"])); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "nobody", + "pw", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Null(result.Principal); + } + + /// + /// A group supplied as a full distinguished name still resolves to its role via the + /// mapper's leading-RDN fallback, and the original DN is preserved on the group claim. + /// + [Fact] + public async Task AuthenticateAsync_GroupAsDistinguishedName_ResolvesRoleAndPreservesGroupClaim() + { + const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"; + FakeLdapAuthService ldap = new(LdapAuthResult.Success( + username: "admin", + displayName: "admin", + groups: [groupDn])); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + "admin", + "admin123", + CancellationToken.None); + + Assert.True(result.Succeeded); + ClaimsPrincipal principal = Assert.IsType(result.Principal); + Assert.True(principal.IsInRole(DashboardRoles.Admin)); + Assert.Contains( + principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType), + claim => claim.Value == groupDn); + } + + /// The (already-trimmed) username from the LDAP result flows onto the principal. + [Fact] + public async Task AuthenticateAsync_UsesUsernameFromLdapResult() + { + FakeLdapAuthService ldap = new(LdapAuthResult.Success( + username: "canonical", + displayName: "Canonical User", + groups: ["GwReader"])); + DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping()); + + DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( + " canonical ", + "pw", + CancellationToken.None); + + Assert.True(result.Succeeded); + ClaimsPrincipal principal = Assert.IsType(result.Principal); + Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); + // The authenticator trims before delegating, so the provider sees the canonical value. + Assert.Equal("canonical", ldap.LastUsername); + } + + private static DashboardAuthenticator CreateAuthenticator( + ILdapAuthService ldapAuthService, + Dictionary groupToRole) + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToRole = groupToRole, + }, + }; + + IGroupRoleMapper roleMapper = new DashboardGroupRoleMapper(Options.Create(options)); + return new DashboardAuthenticator( - Options.Create(options), + ldapAuthService, + roleMapper, NullLogger.Instance); } + + private static Dictionary StandardMapping() => new(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + ["GwReader"] = DashboardRoles.Viewer, + }; + + /// + /// Fake returning a fixed result, recording whether it was + /// invoked and with what username so the authenticator's pre-checks and trimming can be asserted. + /// + private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService + { + public bool WasCalled { get; private set; } + + public string? LastUsername { get; private set; } + + public Task AuthenticateAsync(string username, string password, CancellationToken ct) + { + WasCalled = true; + LastUsername = username; + return Task.FromResult(result); + } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index e5d5e91..22aeed2 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -199,9 +199,9 @@ public sealed class GatewayApplicationTests "BogusRole", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")] [InlineData( - "MxGateway:Ldap:AllowInsecureLdap", + "MxGateway:Ldap:AllowInsecure", "false", - "MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")] + "MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")] public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup( string key, string value,