dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.01k stars 4.67k forks source link

System.DirectoryServices.Protocols - Night build preview 6 - Compatibility between wldap32.dll and libldap #36947

Open brunobritodev opened 4 years ago

brunobritodev commented 4 years ago

Description

I'm running the following code snippet both for Windows and Linux Environment.

var credential = new NetworkCredential("<username>", "<password>");

var connection = new LdapConnection(new LdapDirectoryIdentifier("<ip(13.0.0.0)>", 389, false, false), credential)
{
    AuthType = AuthType.Ntlm
};

connection.Bind();

Under Windows environment it's not necessary to concat domain at username. But when running under Linux environment it's necessary, of course a Invalid Credential will be thrown.

Configuration

Other information

I was looking at libldap docs. bind_s and simple_bind_s actually are the same and just support LDAP_AUTH_SIMPLE.

So, why we don't concat domain name in every case (when it's available) for Linux?

My suggestions is, at BindHelper() on LdapConnection.cs line 1099, remove this if and put it into Linux / Windows specific logic. And also at Linux do the same domain treatment for every scenarios that Domain name is available.

joperezr commented 4 years ago

Marking as an enhancement instead since in reality what we want here is to not require domain name on user, which might not be possible given the limitations of the underlying native library.

menees commented 3 years ago

I've just spent a couple of days getting my LDAP logic to work from both Windows and Linux when talking to my Windows 2016 Active Directory using the S.DS.P 5.0.0 package. The original thread by brunohbrito was extremely helpful. Since that original thread is locked, I thought I'd post my experience here in case it helps someone else or the .NET 6.0 planning.

The internal Linux implementation is much more picky about inputs than the Windows implementation. To get things to work from Linux I had to make several !OperatingSystem.IsWindows() checks.

As @brunohbrito mentioned, on Linux I had to put a "MyDomain\" prefix on the userName that I passed to NetworkCredential. It would be nice to see his PR get pulled in. To find that domain component in my Linux implementation I anonymously queried the server's base attributes to get defaultNamingContext and supportedCapabilities. Then if supportedCapabilities contains 1.2.840.113556.1.4.800 (i.e., an Active Directory server), I prefix the userName with the first DC part from the defaultNamingContext.

To do user searches from Linux I also had to prefix CN=Users, on the distinguishedName passed to SearchRequest. From Windows I was able to do user searches with SearchScope.Subtree from the defaultNamingContext. But Linux requires the search to start from "CN=Users," + defaultNamingContext. It almost seems like on Linux that SearchScope.Subtree is behaving like SearchScope.OneLevel. Without the CN=Users prefix on Linux the code would throw "DirectoryOperationException: An operation error occurred.".

On Linux I can't set LdapConnection.SessionOptions.VerifyServerCertificate (even to a simple "=>true" handler). I assume this is because LDAP_OPT_SSL isn't supported as @joperezr mentions here. Trying to add a handler caused an "LdapException: The LDAP server returned an unknown error." with ErrorCode = -1.

A final problem I had on Linux was that it does not allow extra parentheses in search filters (but Windows does). For example, I was building a filter like (&(objectCategory=Person)(objectClass=user)((sAMAccountName=abc))). The extra parentheses around sAMAccountName=abc caused the Linux code to throw "LdapException: The LDAP server returned an unknown error." with ErrorCode = -7. Changing my code to build the filter without unnecessary parentheses made the code work on Linux and Windows.

It's also worth mentioning that passing in custom attributeList values for SearchRequest attribute filtering works as expected, and it works even when requesting attributes that don't exist. So the concern expressed by joperezr wasn't a problem.

menees commented 3 years ago

In the interest of helping others, here's a fairly complete example of code that works on both Windows and Linux for me.

Program.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Text;

namespace LdapTest
{
    internal static class Program
    {
        // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/assigning-domain-names
        private const string _PrimaryDC = "MyPDC";
        private const string _SecondaryDC = "MySDC";
        private const string _DnsPrefix = "MyPrefix";
        private const string _DnsSuffix = "example";
        private const string _DnsTopLevel = "com";
        private const string _UserName = "KnownUser";
        private const string _Password = "<password>";

        private static readonly string _DefaultNamingContext = $"DC={_DnsPrefix},DC={_DnsSuffix},DC={_DnsTopLevel}";
        private static readonly string _DnsName = $"{_DnsPrefix}.{_DnsSuffix}.{_DnsTopLevel}";
        private static readonly string _PrimaryServer = $"{_PrimaryDC}.{_DnsName}";
        private static readonly string _SecondaryServer = $"{_SecondaryDC}.{_DnsName}";

        private static void Main()
        {
            try
            {
                // Search the base OU to get attributes like defaultNamingContext and supportedCapabilities.
                // If supportedCapabilities contains 1.2.840.113556.1.4.800 then we know it's an Active Directory per
                // https://www.alvestrand.no/objectid/1.2.840.113556.1.4.800.html.
                Search(string.Empty, "objectClass=*", SearchScope.Base, "defaultNamingContext", "dnsHostName", "supportedCapabilities");

                // On Windows we can do user searches from the defaultNamingContext's container.
                // On Linux we have to target the "CN=Users" container, or the search throws: DirectoryOperationException: An operation error occurred.
                // Maybe on Linux SearchScope.Subtree is behaving like SearchScope.OneLevel?
                Search(
                    (OperatingSystem.IsWindows() ? string.Empty : "CN=Users,") + _DefaultNamingContext,
                    "(&(objectCategory=Person)(objectClass=user)(displayName=*Robert*))",
                    SearchScope.Subtree,
                    "cn",
                    "sAMAccountName",
                    "objectSid");
            }
            catch (LdapException ex)
            {
                Console.WriteLine(ex.ErrorCode);
                Console.WriteLine(ex.ServerErrorMessage);
                Console.WriteLine(ex.ToString());
            }
            catch (DirectoryOperationException ex)
            {
                Console.WriteLine(ex.Response);
                Console.WriteLine(ex.ToString());
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
            }

            if (Debugger.IsAttached)
            {
                Console.WriteLine("Press any key to close...");
                Console.ReadKey(true);
            }
        }

        private static void Search(string targetOu, string query, SearchScope scope, params string[] attributeList)
        {
            // On Linux we have to give it a FQDN. On Windows we can give it an empty string, and it will locate a DC.
            string serverName = DateTime.UtcNow.Second % 2 == 0 ? _PrimaryServer : _SecondaryServer;
            Console.WriteLine($"Using server {serverName}");

            using LdapConnection connection = new LdapConnection(serverName);

            if (!string.IsNullOrEmpty(targetOu))
            {
                // On Linux we have to set connection.Credential, or user search requests throw DirectoryOperationException: An operation error occurred.
                // On Windows, setting Credential is optional (probably because of implicit NTLM authentication).
                // On Linux we have to prefix "_DnsPrefix\" on the user name too, based on the tip from brunohbrito here:
                // https://github.com/dotnet/runtime/issues/36888#issuecomment-633220620
                string userName = OperatingSystem.IsWindows() ? _UserName : $"{_DnsPrefix}\\{_UserName}";
                connection.Credential = new(userName, _Password);
            }

            connection.Bind();
            Console.WriteLine("Bind() was successful.");

            const string SeparatorLine = "-------------------------------------------------------";
            Console.WriteLine($"Search {scope} of '{targetOu}' for '{query}'");
            SearchRequest request = new(targetOu, query, scope, attributeList);

            var response = (SearchResponse)connection.SendRequest(request);
            int resultCount = response.Entries.Count;
            Console.WriteLine($"Found {resultCount} entr{(resultCount == 1 ? "y" : "ies")}.");
            Console.WriteLine(SeparatorLine);

            const int EntryDisplayLimit = 2;
            const int AttributeDisplayLimit = 5;
            foreach (SearchResultEntry searchEntry in response.Entries.Cast<SearchResultEntry>().Take(EntryDisplayLimit))
            {
                foreach (DictionaryEntry attributeEntry in searchEntry.Attributes.Cast<DictionaryEntry>().OrderBy(e => e.Key).Take(AttributeDisplayLimit))
                {
                    DirectoryAttribute attribute = (DirectoryAttribute)attributeEntry.Value!;

                    // Using IList.this[int] gives us access to the raw value, whereas DirectoryAttribute.this[int] tries to
                    // blindly convert every byte[] to a string. That returns garbage for values like objectSid. This logic
                    // is better, but it still converts objectGUID to a garbage string.
                    IList attributeValues = attribute;
                    IReadOnlyList<object> values = attributeValues.Cast<object>()
                        .Select(value => value is byte[] bytes
                            ? (bytes.Length > 0 && !bytes.Any(b => b == 0)
                                ? Encoding.UTF8.GetString(bytes)
                                : ("0x" + string.Concat(bytes.Select(b => b.ToString("X2")))))
                            : value)
                        .Distinct()
                        .ToList();
                    Console.WriteLine($"\t{attribute.Name} = {string.Join(" | ", values)}");
                }

                Console.WriteLine(SeparatorLine);
            }
        }
    }
}

LdapTest.csproj

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="System.DirectoryServices.Protocols" Version="5.0.0" />
    </ItemGroup>
</Project>
danmoseley commented 3 years ago

Thank you @menees. We should also resolve the error code - https://github.com/dotnet/runtime/issues/43621#issuecomment-712513986 - LDAP_FILTER_ERROR at least gives a bit more clue.

menees commented 3 years ago

Yes, that would be great! Seeing the error name would have clued me in to my filter problem much faster. An LdapErrorCode enum with those values would be nice too, with a property or extension to cast/convert LdapException.ErrorCode to LdapErrorCode.

danmoseley commented 3 years ago

@menees I went through all the codes, and created an issue describing the necessary change here: https://github.com/dotnet/runtime/issues/46021

Do you have any interest in offering the change, @menees ?

mrybson commented 3 years ago

@danmosemsft - thank you for posting your solution. I have now the same setup, but this after call: var response = (SearchResponse)_connection.SendRequest(searchRequest); I cannot debug and the application stops. Have you experienced that issue? I am running this on .NET Core 3.1 in a Linux environment.

danmoseley commented 3 years ago

@mrybson I actually am not familiar with the directory services code. @joperezr would have to help you.

I cannot debug and the application stops.

No console output at all? What debugger did you use -- did you start under the debugger? Did you try a native debugger?

For managed code debugging, you can try lldb if you are willing to try SOS commands (there is a learning curve): https://github.com/dotnet/runtime/blob/master/docs/workflow/debugging/libraries/unix-instructions.md and https://github.com/dotnet/runtime/blob/master/docs/workflow/debugging/coreclr/debugging.md#debugging-coreclr-on-linux-and-macos

For native debugging, apparently gdb works as an alternative.

There may also be something in syslog: https://stackoverflow.com/questions/6074362/how-to-check-syslog-in-bash-on-linux

menees commented 3 years ago

Do you have any interest in offering the change, @menees ?

@danmosemsft, I'm sorry I don't have more time to work on this right now.

mrybson commented 3 years ago

@danmosemsft I just found what was the root cause of the issue. So when running from Linux, you can't use TimeLimit property in the SearchRequest object. When you use it calling SendRequest, app stops.

danmoseley commented 3 years ago

@mrybson would you mind opening a new issue for that, with repro code? That would be less confusing.

joperezr commented 3 years ago

Just dropping a note, I closed the linked PR #36949 given it was incomplete and stale. There is a note on that PR on what is missing to get it all the way through in case anybody else is interested in picking it up, just feel free to reopen in that case.