Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
brunobritodev opened this issue May 24, 2020 · 12 comments
Assignees
Labels
area-System.DirectoryServices enhancement Product code improvement that does NOT require public API changes/additions
Milestone

Comments

@brunobritodev
Copy link

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

  • Which version of .NET is the code running on?
    .NET 5
  • What OS and version, and what distro if applicable?
    Linux Debian 10 - Container runtime:5.0-buster-slim

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.

@Dotnet-GitSync-Bot Dotnet-GitSync-Bot added area-System.DirectoryServices untriaged New issue has not been triaged by the area owner labels May 24, 2020
@ericstj ericstj added bug and removed untriaged New issue has not been triaged by the area owner labels Jul 9, 2020
@ericstj ericstj added this to the 5.0.0 milestone Jul 9, 2020
@joperezr joperezr added enhancement Product code improvement that does NOT require public API changes/additions and removed bug labels Jul 9, 2020
@joperezr
Copy link
Member

joperezr commented Jul 9, 2020

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.

@joperezr joperezr modified the milestones: 5.0.0, 6.0.0 Jul 22, 2020
@menees
Copy link

menees commented Dec 13, 2020

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
Copy link

menees commented Dec 13, 2020

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
Copy link
Member

Thank you @menees. We should also resolve the error code - #43621 (comment) - LDAP_FILTER_ERROR at least gives a bit more clue.

@menees
Copy link

menees commented Dec 13, 2020

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
Copy link
Member

@menees I went through all the codes, and created an issue describing the necessary change here: #46021

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

@mrybson
Copy link

mrybson commented Dec 14, 2020

@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
Copy link
Member

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

menees commented Dec 14, 2020

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
Copy link

mrybson commented Dec 15, 2020

@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
Copy link
Member

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

@joperezr
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-System.DirectoryServices enhancement Product code improvement that does NOT require public API changes/additions
Projects
No open projects
Development

No branches or pull requests

8 participants