Fix auth, Bootstrap, Blazor nav, LDAP, and deployment pipeline for working Central UI
Bootstrap served locally with absolute paths and <base href="/">. LDAP auth uses search-then-bind with service account for GLAuth compatibility. CookieAuthenticationStateProvider reads HttpContext.User instead of parsing JWT. Login/logout forms opt out of Blazor enhanced nav (data-enhance="false"). Nav links use absolute paths; seed data includes Design/Deployment group mappings. DataConnections page loads all connections (not just site-assigned). Site appsettings configured for Test Plant A; Site registers with Central on startup. DeploymentService resolves string site identifier for Akka routing. Instances page gains Create Instance form.
This commit is contained in:
@@ -46,10 +46,17 @@ public class LdapAuthService
|
||||
await Task.Run(() => connection.StartTls(), ct);
|
||||
}
|
||||
|
||||
// Direct bind with user credentials
|
||||
var bindDn = BuildBindDn(username);
|
||||
// Resolve the user's actual DN, then bind with their credentials
|
||||
var bindDn = await ResolveUserDnAsync(connection, username, ct);
|
||||
await Task.Run(() => connection.Bind(bindDn, password), ct);
|
||||
|
||||
// Re-bind as service account for attribute/group lookup (user may lack search rights)
|
||||
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
||||
}
|
||||
|
||||
// Query for user attributes and group memberships
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
@@ -79,7 +86,7 @@ public class LdapAuthService
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
{
|
||||
groups.Add(ExtractCn(groupDn));
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,13 +119,41 @@ public class LdapAuthService
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildBindDn(string username)
|
||||
/// <summary>
|
||||
/// Resolves the user's full DN. When a service account is configured, performs a
|
||||
/// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
|
||||
/// </summary>
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
|
||||
{
|
||||
// If username already looks like a DN, use it as-is
|
||||
if (username.Contains('='))
|
||||
return username;
|
||||
|
||||
// Build DN from username and search base
|
||||
// If a service account is configured, search for the user's actual DN
|
||||
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
||||
|
||||
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var searchResults = await Task.Run(() =>
|
||||
connection.Search(
|
||||
_options.LdapSearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
searchFilter,
|
||||
new[] { "dn" },
|
||||
false), ct);
|
||||
|
||||
if (searchResults.HasMore())
|
||||
{
|
||||
var entry = searchResults.Next();
|
||||
return entry.Dn;
|
||||
}
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
|
||||
}
|
||||
|
||||
// Fallback: construct DN directly
|
||||
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.LdapSearchBase}";
|
||||
@@ -134,15 +169,16 @@ public class LdapAuthService
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
|
||||
private static string ExtractCn(string dn)
|
||||
private static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
// Extract CN from a DN like "cn=GroupName,dc=example,dc=com"
|
||||
if (dn.StartsWith("cn=", StringComparison.OrdinalIgnoreCase) ||
|
||||
dn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var commaIndex = dn.IndexOf(',');
|
||||
return commaIndex > 3 ? dn[3..commaIndex] : dn[3..];
|
||||
}
|
||||
return dn;
|
||||
// Extract the value of the first RDN from a DN.
|
||||
// Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins"
|
||||
var equalsIndex = dn.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
return dn;
|
||||
|
||||
var valueStart = equalsIndex + 1;
|
||||
var commaIndex = dn.IndexOf(',', valueStart);
|
||||
return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ public class SecurityOptions
|
||||
/// </summary>
|
||||
public string LdapSearchBase { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
|
||||
/// Required for search-then-bind authentication. If empty, direct bind with
|
||||
/// cn={username},{LdapSearchBase} is attempted instead.
|
||||
/// </summary>
|
||||
public string LdapServiceAccountDn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Service account password for LDAP user searches.
|
||||
/// </summary>
|
||||
public string LdapServiceAccountPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP attribute that contains the user's display name.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user