using System.Security.Claims; using System.Text; using Microsoft.Extensions.Options; using MxGateway.Server.Configuration; using MxGateway.Server.Security.Authorization; using Novell.Directory.Ldap; namespace MxGateway.Server.Dashboard; public sealed class DashboardAuthenticator( IOptions options, ILogger logger) : IDashboardAuthenticator { private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized."; /// public async Task AuthenticateAsync( string? username, string? password, CancellationToken cancellationToken) { LdapOptions ldapOptions = options.Value.Ldap; if (!ldapOptions.Enabled || string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return DashboardAuthenticationResult.Fail(GenericFailureMessage); } if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap) { 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); if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup)) { logger.LogInformation( "LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.", normalizedUsername, ldapOptions.RequiredGroup); return DashboardAuthenticationResult.Fail(GenericFailureMessage); } return DashboardAuthenticationResult.Success(CreatePrincipal( normalizedUsername, displayName, groups)); } 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); } } 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(); } internal static bool IsMemberOfRequiredGroup(IEnumerable groups, string requiredGroup) { string normalizedRequiredGroup = requiredGroup.Trim(); if (string.IsNullOrWhiteSpace(normalizedRequiredGroup)) { return false; } foreach (string group in groups) { string normalizedGroup = group.Trim(); if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase) || string.Equals( ExtractFirstRdnValue(normalizedGroup), normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } internal static string ExtractFirstRdnValue(string distinguishedName) { int equalsIndex = distinguishedName.IndexOf('='); if (equalsIndex < 0) { return distinguishedName; } int valueStart = equalsIndex + 1; int commaIndex = distinguishedName.IndexOf(',', valueStart); return commaIndex > valueStart ? distinguishedName[valueStart..commaIndex] : distinguishedName[valueStart..]; } 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) .ConfigureAwait(false); LdapEntry? entry = null; while (results.HasMore()) { LdapEntry next = results.Next(); if (entry is not null) { return null; } entry = next; } return entry; } private static string? ReadAttribute(LdapEntry entry, string attributeName) { return ReadLdapAttribute(entry, attributeName)?.StringValue; } private static IReadOnlyList ReadAttributeValues(LdapEntry entry, string attributeName) { LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName); return attribute?.StringValueArray ?? []; } private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName) { return entry.GetAttribute(attributeName) ?? entry.GetAttribute(attributeName.ToLowerInvariant()) ?? entry.GetAttribute(attributeName.ToUpperInvariant()); } private static ClaimsPrincipal CreatePrincipal( string username, string displayName, IEnumerable groups) { // CreatePrincipal is reached only after IsMemberOfRequiredGroup passed, // so the authenticated user is authorized for the dashboard. Emit the // admin scope claim that DashboardAuthorizationHandler checks when // Dashboard:RequireAdminScope is enabled — without it, every LDAP login // would be denied once route-level authorization is enforced. List claims = [ new Claim(ClaimTypes.NameIdentifier, username), new Claim(ClaimTypes.Name, displayName), new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin) ]; claims.AddRange(groups.Select(group => new Claim( DashboardAuthenticationDefaults.LdapGroupClaimType, group))); ClaimsIdentity claimsIdentity = new( claims, DashboardAuthenticationDefaults.AuthenticationScheme, ClaimTypes.Name, DashboardAuthenticationDefaults.LdapGroupClaimType); return new ClaimsPrincipal(claimsIdentity); } }