feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4)
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
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.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||||
|
|
||||||
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
|
|||||||
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
||||||
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a
|
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
|
||||||
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded
|
// ClaimTypes.Role claim resolved from the LDAP groups via the
|
||||||
// GroupToRole map (GwAdmin -> Admin) means the admin principal must
|
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
|
||||||
// carry Role=Admin alongside the raw LDAP-group claim. A regression in
|
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
|
||||||
// MapGroupsToRoles (returning an empty list, missing the RDN fallback)
|
// claim. A regression in the group→role mapping would fail this assertion.
|
||||||
// would silently pass without this assertion.
|
|
||||||
Assert.Contains(result.Principal.Claims, claim =>
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
claim.Type == ClaimTypes.Role
|
claim.Type == ClaimTypes.Role
|
||||||
&& claim.Value == DashboardRoles.Admin);
|
&& claim.Value == DashboardRoles.Admin);
|
||||||
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
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.
|
// account search succeeds, but the candidate bind is rejected.
|
||||||
const string wrongPassword = "definitely-not-the-admin-password";
|
const string wrongPassword = "definitely-not-the-admin-password";
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||||
{
|
{
|
||||||
// Exercises the `candidate is null` branch: the service-account search
|
// Exercises the user-not-found branch: the service-account search returns no
|
||||||
// returns no entry, so no candidate bind is attempted.
|
// entry, so no candidate bind is attempted.
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
|
|||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
// Exercises the connect-failure path: a closed loopback port produces a
|
// 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.
|
// result rather than propagating an exception to the dashboard.
|
||||||
DashboardAuthenticator authenticator = new(
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
{
|
// 1 is a reserved port number that no LDAP server listens on.
|
||||||
Ldap = new LdapOptions
|
Port = 1,
|
||||||
{
|
});
|
||||||
// 1 is a reserved port number that no LDAP server listens on.
|
|
||||||
Port = 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
|
|||||||
Assert.Null(result.Principal);
|
Assert.Null(result.Principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator()
|
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticator(
|
GatewayOptions gatewayOptions = new()
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
{
|
{
|
||||||
Dashboard = new DashboardOptions
|
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
{
|
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
new LdapAuthService(ldapOptions),
|
||||||
|
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the shared library <see cref="LibraryLdapOptions"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
string Server,
|
string Server,
|
||||||
int Port,
|
int Port,
|
||||||
bool UseTls,
|
string Transport,
|
||||||
bool AllowInsecureLdap,
|
bool AllowInsecure,
|
||||||
string SearchBase,
|
string SearchBase,
|
||||||
string ServiceAccountDn,
|
string ServiceAccountDn,
|
||||||
string ServiceAccountPassword,
|
string ServiceAccountPassword,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
|||||||
Enabled: value.Ldap.Enabled,
|
Enabled: value.Ldap.Enabled,
|
||||||
Server: value.Ldap.Server,
|
Server: value.Ldap.Server,
|
||||||
Port: value.Ldap.Port,
|
Port: value.Ldap.Port,
|
||||||
UseTls: value.Ldap.UseTls,
|
Transport: value.Ldap.Transport.ToString(),
|
||||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
AllowInsecure: value.Ldap.AllowInsecure,
|
||||||
SearchBase: value.Ldap.SearchBase,
|
SearchBase: value.Ldap.SearchBase,
|
||||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||||
ServiceAccountPassword: RedactedValue,
|
ServiceAccountPassword: RedactedValue,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
@@ -82,9 +83,9 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder);
|
builder);
|
||||||
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||||
|
|
||||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||||
{
|
{
|
||||||
builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
@@ -11,11 +13,18 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Gets the LDAP server port.</summary>
|
/// <summary>Gets the LDAP server port.</summary>
|
||||||
public int Port { get; init; } = 3893;
|
public int Port { get; init; } = 3893;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
|
/// <summary>
|
||||||
public bool UseTls { get; init; }
|
/// Gets the transport/TLS mode for the LDAP connection. Replaces the former
|
||||||
|
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
|
||||||
|
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
|
||||||
|
/// a plaintext connection to TLS. Matches the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
|
||||||
|
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
|
||||||
|
/// </summary>
|
||||||
|
public LdapTransport Transport { get; init; } = LdapTransport.None;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
|
/// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
|
||||||
public bool AllowInsecureLdap { get; init; } = true;
|
public bool AllowInsecure { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
||||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ else
|
|||||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
<tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
|
||||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
||||||
using Novell.Directory.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
||||||
|
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
||||||
|
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), 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 <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
||||||
|
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
||||||
|
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
||||||
|
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
|
||||||
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||||
public sealed class DashboardAuthenticator(
|
public sealed class DashboardAuthenticator(
|
||||||
IOptions<GatewayOptions> options,
|
ILdapAuthService ldapAuthService,
|
||||||
|
IGroupRoleMapper<string> roleMapper,
|
||||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||||
{
|
{
|
||||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
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,
|
string? password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LdapOptions ldapOptions = options.Value.Ldap;
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
|
||||||
if (!ldapOptions.Enabled
|
|
||||||
|| string.IsNullOrWhiteSpace(username)
|
|
||||||
|| string.IsNullOrWhiteSpace(password))
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
|
||||||
{
|
{
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
string normalizedUsername = username.Trim();
|
string normalizedUsername = username.Trim();
|
||||||
|
|
||||||
try
|
// 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
|
||||||
using LdapConnection connection = new();
|
// its startup validator, and never throws. We only translate its outcome into a
|
||||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
// dashboard principal here.
|
||||||
|
LdapAuthResult ldapResult = await ldapAuthService
|
||||||
await Task.Run(
|
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
|
||||||
() => 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<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
|
||||||
|
|
||||||
IReadOnlyList<string> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
|
||||||
/// <param name="value">The string value to escape.</param>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="DashboardGroupRoleMapping"/>, the single source
|
|
||||||
/// of truth shared with <see cref="DashboardGroupRoleMapper"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
|
||||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
|
||||||
IEnumerable<string> groups,
|
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
|
||||||
=> DashboardGroupRoleMapping.MapGroupsToRoles(groups, groupToRole);
|
|
||||||
|
|
||||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
|
||||||
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<LdapEntry?> 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)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
LdapEntry? entry = null;
|
if (!ldapResult.Succeeded)
|
||||||
while (results.HasMore())
|
|
||||||
{
|
{
|
||||||
LdapEntry next = results.Next();
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
if (entry is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
GroupRoleMapping<string> mapping = await roleMapper
|
||||||
}
|
.MapAsync(ldapResult.Groups, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
IReadOnlyList<string> roles = mapping.Roles;
|
||||||
{
|
if (roles.Count == 0)
|
||||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
{
|
||||||
}
|
// 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<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
{
|
}
|
||||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
|
||||||
return attribute?.StringValueArray ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||||
{
|
ldapResult.Username,
|
||||||
return entry.GetAttribute(attributeName)
|
ldapResult.DisplayName,
|
||||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
ldapResult.Groups,
|
||||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal CreatePrincipal(
|
private static ClaimsPrincipal CreatePrincipal(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -16,8 +18,21 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
/// Registers all dashboard services, authentication, and Razor components.
|
/// Registers all dashboard services, authentication, and Razor components.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">Service collection to register services.</param>
|
/// <param name="services">Service collection to register services.</param>
|
||||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
/// <param name="configuration">
|
||||||
|
/// Application configuration, used to bind the shared LDAP provider's options
|
||||||
|
/// from the <c>MxGateway:Ldap</c> section.
|
||||||
|
/// </param>
|
||||||
|
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<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public static class GatewayApplication
|
|||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
builder.Services.AddGatewaySessions();
|
builder.Services.AddGatewaySessions();
|
||||||
builder.Services.AddGatewayAlarms();
|
builder.Services.AddGatewayAlarms();
|
||||||
builder.Services.AddGatewayDashboard();
|
builder.Services.AddGatewayDashboard(builder.Configuration);
|
||||||
builder.Services.AddGalaxyRepository();
|
builder.Services.AddGalaxyRepository();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
@@ -17,7 +19,6 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
|
||||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"Server": "localhost",
|
"Server": "localhost",
|
||||||
"Port": 3893,
|
"Port": 3893,
|
||||||
"UseTls": false,
|
"Transport": "None",
|
||||||
"AllowInsecureLdap": true,
|
"AllowInsecure": true,
|
||||||
"SearchBase": "dc=lmxopcua,dc=local",
|
"SearchBase": "dc=lmxopcua,dc=local",
|
||||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||||
"ServiceAccountPassword": "serviceaccount123",
|
"ServiceAccountPassword": "serviceaccount123",
|
||||||
|
|||||||
@@ -1,90 +1,74 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
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.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
|
||||||
|
/// shared <see cref="ILdapAuthService"/>. 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
|
||||||
|
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
|
||||||
|
/// resulting principal/claims keep their exact shape.
|
||||||
|
/// </summary>
|
||||||
public sealed class DashboardAuthenticatorTests
|
public sealed class DashboardAuthenticatorTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
|
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
|
||||||
[Fact]
|
|
||||||
public void EscapeLdapFilter_EscapesSpecialCharacters()
|
|
||||||
{
|
|
||||||
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
|
|
||||||
|
|
||||||
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
|
|
||||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
|
||||||
/// <param name="expectedRole">The expected role or null if no match.</param>
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
[InlineData(null)]
|
||||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
[InlineData("")]
|
||||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
[InlineData(" ")]
|
||||||
[InlineData("OtherGroup", null)]
|
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
|
||||||
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
|
|
||||||
string ldapGroup,
|
|
||||||
string? expectedRole)
|
|
||||||
{
|
{
|
||||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
{
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
["GwReader"] = DashboardRoles.Viewer,
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
username,
|
||||||
|
"password",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
if (expectedRole is null)
|
Assert.False(result.Succeeded);
|
||||||
{
|
Assert.Null(result.Principal);
|
||||||
Assert.Empty(roles);
|
Assert.False(ldap.WasCalled);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.Equal(expectedRole, Assert.Single(roles));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
|
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
|
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
|
||||||
{
|
{
|
||||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
{
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
["GwReader"] = DashboardRoles.Viewer,
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
["GwAdmin", "GwReader"],
|
"admin",
|
||||||
mapping);
|
" ",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.Contains(DashboardRoles.Admin, roles);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Contains(DashboardRoles.Viewer, roles);
|
Assert.Null(result.Principal);
|
||||||
|
Assert.False(ldap.WasCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
|
/// <summary>
|
||||||
[Fact]
|
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
|
||||||
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
|
/// maps to the generic dashboard failure without leaking the raw password.
|
||||||
|
/// </summary>
|
||||||
|
[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(
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
|
||||||
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||||
|
|
||||||
Assert.Equal("Gateway Admins", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
|
|
||||||
{
|
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
|
|
||||||
{
|
|
||||||
Ldap = new LdapOptions
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -96,10 +80,161 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<ClaimsPrincipal>(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<ClaimsIdentity>(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<string> groupClaims = principal.FindAll(
|
||||||
|
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||||
|
.Select(claim => claim.Value)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<ClaimsPrincipal>(result.Principal);
|
||||||
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||||
|
Assert.Contains(
|
||||||
|
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
||||||
|
claim => claim.Value == groupDn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
|
||||||
|
[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<ClaimsPrincipal>(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<string, string> groupToRole)
|
||||||
|
{
|
||||||
|
GatewayOptions options = new()
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
GroupToRole = groupToRole,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
|
||||||
|
|
||||||
return new DashboardAuthenticator(
|
return new DashboardAuthenticator(
|
||||||
Options.Create(options),
|
ldapAuthService,
|
||||||
|
roleMapper,
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
|
["GwReader"] = DashboardRoles.Viewer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
|
||||||
|
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
|
||||||
|
{
|
||||||
|
public bool WasCalled { get; private set; }
|
||||||
|
|
||||||
|
public string? LastUsername { get; private set; }
|
||||||
|
|
||||||
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||||
|
{
|
||||||
|
WasCalled = true;
|
||||||
|
LastUsername = username;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,9 +199,9 @@ public sealed class GatewayApplicationTests
|
|||||||
"BogusRole",
|
"BogusRole",
|
||||||
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"MxGateway:Ldap:AllowInsecureLdap",
|
"MxGateway:Ldap:AllowInsecure",
|
||||||
"false",
|
"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(
|
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
||||||
string key,
|
string key,
|
||||||
string value,
|
string value,
|
||||||
|
|||||||
Reference in New Issue
Block a user