using System.DirectoryServices.Protocols; using System.Net; using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models; using JdeScoping.Core.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace JdeScoping.Infrastructure.Auth; /// /// LDAP-based authentication service implementation using System.DirectoryServices.Protocols /// public sealed class LdapAuthService : IAuthService { private const string LdapLookupFormat = "(sAMAccountName={0})"; private readonly LdapOptions _options; private readonly AuthOptions _authOptions; private readonly ILogger _logger; public LdapAuthService( IOptions options, IOptions authOptions, ILogger logger) { _options = options.Value; _authOptions = authOptions.Value; _logger = logger; } /// public async Task AuthenticateAsync( string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return new AuthResult(false, null, "Username and password are required"); } // Check if user is in admin bypass list var isAdminBypass = _authOptions.AdminBypassUsers .Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase)); // Try each configured LDAP server string? lastError = null; foreach (var serverUrl in _options.ServerUrls) { try { ct.ThrowIfCancellationRequested(); // Attempt authentication if (!await TryBindAsync(serverUrl, username, password, ct)) { return new AuthResult(false, null, "Incorrect username or password"); } // Verify group membership (unless admin bypass) if (!isAdminBypass && !string.IsNullOrEmpty(_options.GroupDn)) { if (!await IsInGroupInternalAsync(serverUrl, username, password, _options.GroupDn, ct)) { return new AuthResult(false, null, "User is not a member of the required security group"); } } // Lookup user info var userInfo = await LookupUserAsync(serverUrl, username, password, ct); if (userInfo is null) { return new AuthResult(false, null, "Failed to retrieve user information"); } _logger.LogInformation("User {Username} authenticated successfully via {Server}", username, serverUrl); return new AuthResult(true, userInfo, null); } catch (LdapException ex) { _logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl); lastError = ex.Message; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Unexpected error during LDAP authentication for server {Server}", serverUrl); lastError = ex.Message; } } return new AuthResult(false, null, lastError ?? "Unable to connect to directory server"); } /// public Task GetUserInfoAsync(string username, CancellationToken ct = default) { // Not implemented for LDAP - user info is only available during authentication throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup"); } /// public async Task IsInGroupAsync( string username, string groupName, CancellationToken ct = default) { // This method requires stored credentials or service account - not supported // Group membership is checked during authentication when credentials are available throw new NotSupportedException("IsInGroupAsync requires password for LDAP lookup. Use AuthenticateAsync instead."); } private async Task TryBindAsync( string serverUrl, string username, string password, CancellationToken ct) { using var connection = CreateConnection(serverUrl); var credential = new NetworkCredential(username, password); connection.Credential = credential; connection.AuthType = AuthType.Negotiate; try { await Task.Run(() => connection.Bind(), ct); return true; } catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials { return false; } } private async Task IsInGroupInternalAsync( string serverUrl, string username, string password, string groupDn, CancellationToken ct) { using var connection = CreateConnection(serverUrl); connection.Credential = new NetworkCredential(username, password); connection.AuthType = AuthType.Negotiate; await Task.Run(() => connection.Bind(), ct); var searchRequest = new SearchRequest( _options.SearchBase, string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)), SearchScope.Subtree, "memberOf"); var response = (SearchResponse)await Task.Run( () => connection.SendRequest(searchRequest), ct); foreach (SearchResultEntry entry in response.Entries) { var memberOf = entry.Attributes["memberOf"]; if (memberOf != null) { foreach (var group in memberOf.GetValues(typeof(string))) { if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase)) { return true; } } } } return false; } private async Task LookupUserAsync( string serverUrl, string username, string password, CancellationToken ct) { using var connection = CreateConnection(serverUrl); connection.Credential = new NetworkCredential(username, password); connection.AuthType = AuthType.Negotiate; await Task.Run(() => connection.Bind(), ct); var searchRequest = new SearchRequest( _options.SearchBase, string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)), SearchScope.Subtree, "distinguishedName", "givenName", "sn", "mail", "title"); var response = (SearchResponse)await Task.Run( () => connection.SendRequest(searchRequest), ct); if (response.Entries.Count == 0) return null; var entry = response.Entries[0]; return new UserInfo { Dn = GetAttribute(entry, "distinguishedName"), Username = username.ToLowerInvariant(), FirstName = GetAttribute(entry, "givenName"), LastName = GetAttribute(entry, "sn"), EmailAddress = GetAttribute(entry, "mail"), Title = GetAttribute(entry, "title") }; } private LdapConnection CreateConnection(string serverUrl) { var connection = new LdapConnection(serverUrl); connection.SessionOptions.ProtocolVersion = 3; connection.SessionOptions.SecureSocketLayer = false; connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds); return connection; } private static string GetAttribute(SearchResultEntry entry, string name) { var attr = entry.Attributes[name]; return attr?.Count > 0 ? (string)attr[0] : string.Empty; } /// /// Escapes special characters in LDAP search filter values /// private static string EscapeLdapSearchFilter(string value) { if (string.IsNullOrEmpty(value)) return value; return value .Replace("\\", "\\5c") .Replace("*", "\\2a") .Replace("(", "\\28") .Replace(")", "\\29") .Replace("\0", "\\00"); } }