4879c4e01e
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.
185 lines
7.1 KiB
C#
185 lines
7.1 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace ScadaLink.Security;
|
|
|
|
public class LdapAuthService
|
|
{
|
|
private readonly SecurityOptions _options;
|
|
private readonly ILogger<LdapAuthService> _logger;
|
|
|
|
public LdapAuthService(IOptions<SecurityOptions> options, ILogger<LdapAuthService> logger)
|
|
{
|
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(username))
|
|
return new LdapAuthResult(false, null, null, null, "Username is required.");
|
|
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
return new LdapAuthResult(false, null, null, null, "Password is required.");
|
|
|
|
// Enforce TLS unless explicitly allowed for dev/test
|
|
if (!_options.LdapUseTls && !_options.AllowInsecureLdap)
|
|
{
|
|
return new LdapAuthResult(false, null, null, null,
|
|
"Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test.");
|
|
}
|
|
|
|
try
|
|
{
|
|
using var connection = new LdapConnection();
|
|
|
|
if (_options.LdapUseTls)
|
|
{
|
|
connection.SecureSocketLayer = true;
|
|
}
|
|
|
|
await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct);
|
|
|
|
if (_options.LdapUseTls && !connection.SecureSocketLayer)
|
|
{
|
|
await Task.Run(() => connection.StartTls(), ct);
|
|
}
|
|
|
|
// 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>();
|
|
|
|
try
|
|
{
|
|
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
|
var searchResults = await Task.Run(() =>
|
|
connection.Search(
|
|
_options.LdapSearchBase,
|
|
LdapConnection.ScopeSub,
|
|
searchFilter,
|
|
new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
|
|
false), ct);
|
|
|
|
while (searchResults.HasMore())
|
|
{
|
|
try
|
|
{
|
|
var entry = searchResults.Next();
|
|
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
|
|
if (dnAttr != null)
|
|
displayName = dnAttr.StringValue;
|
|
|
|
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
|
|
if (groupAttr != null)
|
|
{
|
|
foreach (var groupDn in groupAttr.StringValueArray)
|
|
{
|
|
groups.Add(ExtractFirstRdnValue(groupDn));
|
|
}
|
|
}
|
|
}
|
|
catch (LdapException)
|
|
{
|
|
// No more results
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username);
|
|
// Auth succeeded even if attribute lookup failed
|
|
}
|
|
|
|
connection.Disconnect();
|
|
|
|
return new LdapAuthResult(true, displayName, username, groups, null);
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
|
|
return new LdapAuthResult(false, null, username, null, "Invalid username or password.");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username);
|
|
return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication.");
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
|
|
// 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}";
|
|
}
|
|
|
|
private static string EscapeLdapFilter(string input)
|
|
{
|
|
return input
|
|
.Replace("\\", "\\5c")
|
|
.Replace("*", "\\2a")
|
|
.Replace("(", "\\28")
|
|
.Replace(")", "\\29")
|
|
.Replace("\0", "\\00");
|
|
}
|
|
|
|
private static string ExtractFirstRdnValue(string 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..];
|
|
}
|
|
}
|