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:
Joseph Doherty
2026-03-17 10:03:06 -04:00
parent 6fa4c101ab
commit 4879c4e01e
21 changed files with 265 additions and 92 deletions

View File

@@ -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..];
}
}

View File

@@ -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>