We needed to make a (yet another) internal portal/website for employees, but this time, as that would be an internal resource, we decided to utilize users accounts data provided via LDAP by our office’s Active Directory, instead of (yet again) implementing “local” user identities like we did before with MySQL and PostgreSQL.

.NET Core LDAP

(Of course) we chose ASP.NET Core MVC for making the portal. And as both ASP.NET and Active Directory have been around for a while, and given the fact that both come from the same vendor, one would expect that implementing Active Directory users authentication via LDAP in such a setup to be a well-known topic with detailed documentation, examples and a lot of tutorials available. But as fucking usual, it’s not quite like that.

Environment

To be fair, if we needed that to work only on Windows, things most likely would have been easier, but actually Windows target was almost of no interest, because our deployment target is GNU/Linux server, and development target is Mac OS, so everything should work on all these platforms.

To be specific, here are the OS versions that we have tested:

  • GNU/Linux Ubuntu 22.04
  • Mac OS 12.4 (Intel-based)
  • Windows:
    • 10.0.19043 21H1
    • 11.0.22000 21H2

Here’s also .NET environment on the GNU/Linux target:

.NET SDK (reflecting any global.json):
 Version:   6.0.300
 Commit:    8473146e7d

Runtime Environment:
 OS Name:     ubuntu
 OS Version:  22.04
 OS Platform: Linux
 RID:         ubuntu.22.04-x64
 Base Path:   /usr/share/dotnet/sdk/6.0.300/

Host (useful for support):
  Version: 6.0.5
  Commit:  70ae3df4a6

.NET SDKs installed:
  6.0.300 [/usr/share/dotnet/sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.App 6.0.5 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 6.0.5 [/usr/share/dotnet/shared/Microsoft.NETCore.App]

As for LDAP server, then in our case it’s an Active Directory on Windows Server 2019. But thanks to LDAP being standardized, most of the code will also work with other LDAP servers (after some adjustments), and in fact I’ve successfully tested the sample project with a LDAP server provided by Synology DSM (v2.4.59).

What’s the point of using an LDAP server

Here’s what Captain Obvious has to say on the matter.

If you are using Active Directory (or some other LDAP server) in your office/organization, then often (always?) that server is the source of truth for the information about all the employees, teams (groups) they are members of and other useful information about your company’s IT infrastructure.

When you need to add authentication/authorization to some resource such as a website, then instead of implementing (potentially, yet another) “local” user accounts for that resource (usually, in the attached database), you can just rely on the users accounts from your LDAP server.

The main benefit of doing so is that you’ll always have the latest information about users, originating from one place, which greatly simplifies users management.

For example, when an employee leaves the company, his account gets disabled on the LDAP server (hopefully, your administrators do communicate with HR department), and so his user account will lose ability to sign-in into all your company resources at once (do keep in mind though, he will likely remain still logged-in where he already is, until the session expires, which is why you might want to set a shorter expiration period for it).

Another example is that if some employee gets promoted and becomes eligible to an extended access on some resources. Then (in a simplified scenario) you will only need to add him to the corresponding group on the LDAP server, and his access rights will also get updated at once everywhere (once he signs-off and signs-in again).

In addition, relying on users accounts data from LDAP server makes it easier to add authentication on 3rd-party resources which support that (a lot of them do). To name a few: YouTrack, TeamCity, GitLab, Gitea, JFrog Artifactory, etc.

Now imagine if you were using “local” user accounts in every website/resource of yours. In that case you’d need to perform the corresponding changes manually in each resource’s database, which at the very least is tedious and an error-prone task.

Checking access to LDAP server

Before creating a project, make sure that you can actually access your Active Directory / LDAP server.

CLI tools

You can do it with system CLI tools, such as ldp.exe on Windows or ldapsearch on Mac OS and GNU/Linux. For example, here’s how you can query some basic information about your Active Directory with ldapsearch:

$ ldapsearch -h ad.our-company.com \
    -D SERVICE-USER-NAME \
    -s base \
    -x -W \
    "objectClass=*" \
    defaultNamingContext dnsHostName supportedCapabilities

Or here’s how you can get all users of some group:

$ ldapsearch -h ad.our-company.com \
    -b CN=Users,DC=ad,DC=our-company,DC=com \
    -D SERVICE-USER-NAME \
    -x -W \
    "(&(objectCategory=person)(objectClass=user)(memberOf=CN=crew,CN=Users,DC=ad,DC=our-company,DC=com))" \
    sAMAccountName displayName mail

As you’ll see later, different LDAP servers have different attributes and other query specifics, and for example to query the same things from a Synology DSM LDAP server you’ll need to modify these requests in the following manner:

$ ldapsearch -h ad.our-company.com \
    -D uid=SERVICE-USER-NAME,CN=Users,DC=ad,DC=our-company,DC=com \
    -s base \
    -x -W \
    "objectClass=*" \
    namingContexts supportedControl supportedExtension

and:

$ ldapsearch -h ad.our-company.com \
    -b CN=Users,DC=ad,DC=our-company,DC=com \
    -D uid=SERVICE-USER-NAME,CN=Users,DC=ad,DC=our-company,DC=com \
    -x -W \
    "(&(objectClass=person)(memberOf=CN=crew,CN=Groups,DC=ad,DC=our-company,DC=com))" \
    uid displayName

Apache Directory Studio

There is also Apache Directory Studio application. It is Java-based, so UI and performance won’t be the greatest, but it is a GUI application, so it is more convenient for browsing the directory as a tree of nodes.

Just in case, here’s how the connection properties are set:

Apache Directory Studio, connection network parameter

Apache Directory Studio, connection authentication

Apache Directory Studio, connection browser options

And here’s how the tree of nodes looks like:

Apache Directory Studio, tree of nodes

ASP.NET Core MVC project

Creating a project is nothing special:

$ mkdir dotnet-ldap-authentication && cd $_
$ dotnet new mvc

Required NuGet packages

The cross-platform package from Microsoft for working with LDAP is System.DirectoryServices.Protocols:

<PackageReference Include="System.DirectoryServices.Protocols" Version="6.0.1" />

Don’t confuse it with System.DirectoryServices package. Even though things are done easier there, that package seems to work only on Windows (at least, at the moment), while System.DirectoryServices.Protocols does work on other platforms too.

There is also another cross-platform package - Novell.Directory.Ldap.NETStandard. Before System.DirectoryServices.Protocols has been created, the Novell.Directory.Ldap.NETStandard package was the only (cross-platform) one available, as I understood. And apparently it still has some advantages and functionality that is still missing in the Microsoft’s package, so you might consider using that one instead.

Platform-specific dependencies

Missing libldap on GNU/Linux

Trying to run your project on Ubuntu 22.04 might fail with missing libldap dependency:

Unable to load shared library 'libldap-2.4.so.2' or one of its dependencies. In order to help diagnose loading problems, consider setting the LD_DEBUG environment variable: liblibldap-2.4.so.2: cannot open shared object file: No such file or directory

This is because Microsoft has hardcoded an exact version of libldap, which would work on Ubuntu 20.04, but obviously won’t work where this library has a different version.

So while we are waiting for Microsoft to fix that, one workaround would be to install the current version and “fake” the version 2.4 with a symlink:

$ sudo apt install libldap-2.5-0
$ sudo ln -s /usr/lib/x86_64-linux-gnu/{libldap-2.5.so.0,libldap-2.4.so.2}

or (preferably) download and install exactly version 2.4 from Ubuntu packages:

$ sudo apt install libgssapi3-heimdal
$ wget http://se.archive.ubuntu.com/ubuntu/pool/main/o/openldap/libldap-2.4-2_2.4.49+dfsg-2ubuntu1.9_amd64.deb
$ sudo dpkg -i ./libldap-2.4-2_2.4.49+dfsg-2ubuntu1.9_amd64.deb

LDAP settings

LDAP connection settings are stored in appsettings.json:

{
    "...": "...",
    "AD": {
        "port": 389,
        "zone": "com",
        "domain": "our-company",
        "subdomain": "ad",
        "crew": "crew",
        "managers": "managers",
        "username": "SERVICE-USER-NAME",
        "password": "SERVICE-USER-PASSWORD"
    },
    "...": "..."
}

Naturally, all these values (except maybe port) need to be replaced with yours.

Here we provide LDAP connection string components (port and FQDN), names of certain users groups and credentials of a service user, which will be used for querying the server.

Just in case, some clarification about the users groups:

  • crew - group with all team members, it will be used as a part of authentication query;
  • managers - group for managers/administrators (those who should have extended functionality), it will be used as a part of authorization.

This is just how we have it in our Active Directory, so you’ll either need to rename them to your groups or simply not use them at all.

Now these settings need to be made available in controllers. This can be done with IOptions.

First you declare a class with all the required properties:

public class ConfigurationAD
{
    public int Port { get; set; } = 389;
    public string Zone { get; set; } = string.Empty;
    public string Domain { get; set; } = string.Empty;
    public string Subdomain { get; set; } = string.Empty;

    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;

    public string LDAPserver { get; set; } = string.Empty;
    public string LDAPQueryBase { get; set; } = string.Empty;

    public string Crew { get; set; } = string.Empty;
    public string Managers { get; set; } = string.Empty;
}

then instantiate and register it in services in Program.cs:

var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ConfigurationAD>
(
    c =>
    {
        c.Port = configuration.GetSection("AD:port").Get<int>();

        c.Zone = configuration.GetSection("AD:zone").Value;
        c.Domain = configuration.GetSection("AD:domain").Value;
        c.Subdomain = configuration.GetSection("AD:subdomain").Value;

        c.Username = configuration.GetSection("AD:username").Value;
        c.Password = configuration.GetSection("AD:password").Value;

        // connection string with port doesn't work on GNU/Linux and Mac OS
        //c.LDAPserver = $"{c.Subdomain}.{c.Domain}.{c.Zone}:{c.Port}";
        c.LDAPserver = $"{c.Subdomain}.{c.Domain}.{c.Zone}";
        // that depends on how it is in your LDAP server
        //c.LDAPQueryBase = $"DC={c.Subdomain},DC={c.Domain},DC={c.Zone}";
        c.LDAPQueryBase = $"DC={c.Domain},DC={c.Zone}";

        c.Crew = new StringBuilder()
            .Append($"CN={configuration.GetSection("AD:crew").Value},")
            // check which CN (Users or Groups) your LDAP server has the groups in
            .Append($"CN=Users,{c.LDAPQueryBase}")
            .ToString();
        c.Managers = new StringBuilder()
            .Append($"CN={configuration.GetSection("AD:managers").Value},")
            // check which CN (Users or Groups) your LDAP server has the groups in
            .Append($"CN=Users,{c.LDAPQueryBase}")
            .ToString();
    }
);

// ...

and then here’s how it can be used in controllers:

public class HomeController : Controller
{
    private readonly ConfigurationAD _configurationAD;

    public HomeController(
        IOptions<ConfigurationAD> configurationAD
        )
    {
        _configurationAD = configurationAD.Value;
    }

    public IActionResult Index()
    {
        Console.WriteLine(_configurationAD.Zone);
        Console.WriteLine(_configurationAD.Domain);
        Console.WriteLine(_configurationAD.Subdomain);

        return View();
    }

    // ...
}

LDAP queries

Sending search requests

It is assumed that you are already familiar LDAP queries, but just in case here’s one tutorial and here’s another.

To send LDAP queries, let’s create a common method (based on this example):

public static SearchResponse SearchInAD(
    string ldapServer,
    int ldapPort,
    string domainForAD,
    string username,
    string password,
    string targetOU,
    string query,
    SearchScope scope,
    params string[] attributeList
    )
{
    // on Windows the authentication type is Negotiate, so there is no need to prepend
    // AD user login with domain. On other platforms at the moment only
    // Basic authentication is supported
    var authType = AuthType.Negotiate;
    // also can fail on non AD servers, so you might prefer
    // to just use AuthType.Basic everywhere
    if (!OperatingSystem.IsWindows())
    {
        authType = AuthType.Basic;
        username = OperatingSystem.IsWindows()
            ? username
            // this might need to be changed to your actual AD domain value
            : $"{domainForAD}\\{username}";
    }

    // depending on LDAP server, username might require some proper wrapping
    // instead(!) of prepending username with domain
    //username = $"uid={username},CN=Users,DC=subdomain,DC=domain,DC=zone";

    //var connection = new LdapConnection(ldapServer)
    var connection = new LdapConnection(
        new LdapDirectoryIdentifier(ldapServer, ldapPort)
        )
    {
        AuthType = authType,
        Credential = new(username, password)
    };
    // the default one is v2 (at least in that version), and it is unknown if v3
    // is actually needed, but at least Synology LDAP works only with v3,
    // and since our Exchange doesn't complain, let it be v3
    connection.SessionOptions.ProtocolVersion = 3;

    // this is for connecting via LDAPS (636 port). It should be working,
    // according to https://github.com/dotnet/runtime/issues/43890,
    // but it doesn't (at least with Synology DSM LDAP), although perhaps
    // for a different reason
    //connection.SessionOptions.SecureSocketLayer = true;

    connection.Bind();

    var request = new SearchRequest(targetOU, query, scope, attributeList);

    return (SearchResponse)connection.SendRequest(request);
}

The method takes LDAP server connection string and port, user credentials and search query parameters. Speaking about credentials, in case of authentication query those of course will be username and password of the user that is trying to authenticate, but for all the other requests those are supposed to be credentials of some dedicated service user that has required read access to query your LDAP server.

As you can see in the method code and comments, there are several platform-specific (and LDAP-server-specific) differences.

Platforms specifics

Depending on which host your application/website will be running on, there are certain things that are different between platforms.

Setting the port in connection string works only on Windows hosts

As you might have noticed in the LDAP settings section, the LDAPserver property is set like $"{c.Subdomain}.{c.Domain}.{c.Zone}" (so it becomes ad.our-company.com), while $"{c.Subdomain}.{c.Domain}.{c.Zone}:{c.Port}" (ad.our-company.com:389) is commented out.

This is because on GNU/Linux and Mac OS the LDAP connection string containing inline port fails with the following error:

A bad parameter was passed to a routine.

A bugreport for this has been submitted last year, and until it’s fixed you’d have to compose the connection string without specifying the port or construct it via LdapDirectoryIdentifier().

Authentication type Negotiate is implemented only on Windows hosts

On Windows you can leave authType with default value, which is AuthType.Negotiate, but that will fail on GNU/Linux and Mac OS with the following error:

The feature is not supported.

Because on those platforms only AuthType.Basic is supported.

Moreover, your LDAP server might not support AuthType.Negotiate either (does any server support it at all, aside from Active Directory?), so perhaps it should be just set to AuthType.Basic for all platforms.

Username needs to be prepended with domain on non-Windows hosts

On Windows the username can be provided as is, so in case of vasya@our-company.com that would be just vasya. But on other platforms it needs to be prepended with domain, so it becomes our-company\vasya, otherwise you’ll get this error:

The supplied credential is invalid.

LDAP servers specifics

Depending on which LDAP server your application/website will be connecting to, there are certain adjustments that need to be made, in order to account for the LDAP implementation differences.

Like I said in the beginning, my goal was to make it work with Active Directory, but with some adjustments it will (should) work with other LDAP servers too. I’ve tested the one provided by Synology DSM, and below I’ll list the differences that I’ve noticed.

You can also take a look at this patch (supposed to be applied on the 55ca0f19 commit) which makes the required adjustments for the code to work with Synology DSM LDAP server.

LDAP protocol version

In SearchInAD() method we set ProtocolVersion to 3. The default value is 2, and that works with our Active Directory, but LDAP server provided by Synology DSM refuses to work with it, failing with this error:

A protocol error occurred.

So to work with that particular server you’d need to set it to 3. And since Active Directory also can work with that version, it won’t hurt to just set it to 3 by default.

Root DSE attributes

Active Directory has the following attributes in the Root DSE - the very top object of the directory (you could see it on the Apache Directory Studio tree screenshot):

  • defaultNamingContext
  • dnsHostName
  • supportedCapabilities

But Synology DSM LDAP doesn’t have these attributes, so querying them won’t return anything. Although, it does have namingContexts, which perhaps could be used as an alternative for defaultNamingContext.

User attributes

A correct search query for getting users in Active Directory might not find anything in Synology DSM LDAP, because in the latter the objectClass attribute in my case contains apple-user value (I wonder why) instead of just user as it is in Active Directory, and also instead of objectCategory=person Synology DSM LDAP has objectClass=person.

So you will need to be careful composing your search queries, as they are pretty much server-specific.

userAccountControl

Speaking about user attributes, there is an unusual (and somewhat “hidden”, because I don’t see it in query results using either of LDAP clients) attribute called userAccountControl. It can have many different values, and out of those specifically the value 2 lets us to filter out disabled accounts, like this

(!userAccountControl:1.2.840.113556.1.4.803:=2)

On Windows hosts it’s more forgiving, apparently, because there it works fine, but on GNU/Linux and Mac OS trying to send such a query will fail with the following error:

The search filter is invalid.

This is because when negation with exclamation mark is used, then it needs to wrap the condition with one more pair of brackets, like this:

(!(userAccountControl:1.2.840.113556.1.4.803:=2))

So Windows accepting it “unwrapped” is not entirely correct.

This is quite a useful attribute, but adding it to the search query for Synology DSM LDAP does something unknown, because it doesn’t find any users at all, no matter what value is provided, even though Synology’s documentation claims that this attribute is supported.

Groups CN

In Active Directory (at least in our instance) checking for membership in the crew group would look like this:

(memberOf=CN=crew,CN=Users,DC=ad,DC=our-company,DC=com)

But in Synology DSM LDAP groups live not in CN=Users but in CN=Groups, so the query should be this:

(memberOf=CN=crew,CN=Groups,DC=ad,DC=our-company,DC=com)

Username

As you saw earlier, for Active Directory authentication credentials it is enough to provide the username as it is (or prepended with domain on non-Windows hosts), but Synology DSM LDAP will not accept that, as it requires username to be in the following form:

uid=USER-NAME,CN=Users,DC=ad,DC=our-company,DC=com

So this is how you’d establish LDAP connection to Active Directory:

var connection = new LdapConnection("ad.our-company.com")
{
    Credential = new(
        "SOME-USER-NAME", // or "our-company\\SOME-USER-NAME" on non-Windows hosts
        "SOME-USER-PASSWORD"
    )
};

and this is how you’d need to do that for connecting to Synology DSM LDAP:

var connection = new LdapConnection("ad.our-company.com")
{
    Credential = new(
        "uid=SOME-USER-NAME,CN=Users,DC=ad,DC=our-company,DC=com",
        "SOME-USER-PASSWORD"
    )
};

Processing results

The SearchInAD method returns a SearchResponse object, which can be processed like this:

var attributesToQuery = new string[]
{
    "objectGUID",
    "sAMAccountName",
    "displayName",
    "mail",
    "whenCreated"
};
var searchResults = General.SearchInAD(
    _configurationAD.LDAPserver,
    _configurationAD.Port,
    _configurationAD.Domain,
    _configurationAD.Username,
    _configurationAD.Password,
    $"CN=Users,{_configurationAD.LDAPQueryBase}",
    new StringBuilder("(&")
        .Append("(objectCategory=person)")
        .Append("(objectClass=user)")
        .Append($"(memberOf={_configurationAD.Crew})")
        .Append("(!(userAccountControl:1.2.840.113556.1.4.803:=2))")
        .Append(")")
        .ToString(),
    SearchScope.Subtree,
    attributesToQuery
);

foreach (var searchEntry in searchResults.Entries.Cast<SearchResultEntry>())
{
    foreach (var attr in attributesToQuery)
    {
        if (searchEntry.Attributes[attr][0].GetType() != typeof(System.Byte[]))
        {
            var attrValue = searchEntry.Attributes[attr][0].ToString();
            Console.WriteLine(attrValue);
        }
        else // must be bytes then
        {
            var attrValue = searchEntry.Attributes[attr][0] as byte[];
            if (attrValue != null)
            {
                // can't get a normal string out of it like this
                var gdString = Encoding.Default.GetString(attrValue);
                // only can compose a bare string representation of those bytes
                var gdStringBytes = string.Concat(attrValue!.Select(b => b.ToString("X2")));
                Console.WriteLine(gdStringBytes);
            }
        }
    }
}

Here we query Active Directory for the non-disabled users from a certain group and request specific attributes. Then we simply iterate through the SearchResponse entries.

Most of the attributes a strings, so you can get them as searchEntry.Attributes[attr][0].ToString(), but some require special treatment.

Attributes with several values

If an attribute contains more than one value, such as memberOf, then getting only the first value won’t be correct, and so you should do it like this:

var groups = resultsEntry.Attributes["memberOf"];
foreach(var g in groups)
{
    var groupNameBytes = g as byte[];
    if (groupNameBytes != null)
    {
        var groupNameString = Encoding.Default.GetString(groupNameBytes));
        Console.WriteLine(groupNameString);
    }
}

objectGUID and objectSid

Some attributes are stored not as strings but as bytes, and not just bytes but special ones, which you cannot just read with Encoding.Default.GetString(). I mean, you certainly can, but the result won’t be very useful.

In particular, it’s these two special attributes: objectGUID and objectSid. To get the meaningful values of those you need to use specific constructors: new Guid() and new SecurityIdentifier().

So for objectGUID that would be:

var bytes = searchEntry.Attributes["objectGUID"][0] as byte[];
if (bytes != null)
{
    var guid = new Guid(bytes);
    Console.WriteLine(guid);
}

and for objectSid (given that you requested it in the attributesToQuery list, and that your LDAP server actually has it) that would be:

var bytes = searchEntry.Attributes["objectSid"][0] as byte[];
if (bytes != null)
{
    var sid = new SecurityIdentifier(bytes, 0);
    Console.WriteLine(sid);
}

Note, however, that SecurityIdentifier is implemented only on Windows hosts, and on other platforms you’ll get the following error:

Windows Principal functionality is not supported on this platform.

DateTime values

Date and time attributes, such as whenCreated, are returned as yyyyMMddHHmmss.0Z strings, so you need to use ParseExact() to get a proper DateTime object:

var whenCreated = DateTime.ParseExact(
    searchEntry.Attributes["whenCreated"][0].ToString()!,
    "yyyyMMddHHmmss.0Z",
    System.Globalization.CultureInfo.InvariantCulture
)

Authentication

Now, when we can connect to Active Directory / LDAP server and query data from it, we can finally proceed with the authentication.

To simplify things a bit (a lot), the ultimate goal of the authentication process is to call HttpContext.SignInAsync() method. But doing that for every visitor would defeat the purpose of authentication, wouldn’t it, so first you need to check somehow whether this user can actually be authenticated.

Since we are simplifying things, here’s how we the whole process can be illustrated:

ASP.NET Core Authentication process

The big red ? here is what we need to implement using Active Directory / LDAP server, in order to determine whether we can call HttpContext.SignInAsync() for the current user or not.

SignInManager

The code will live in a SignInManager (can be called whatever) class. Its purpose will be to query Active Directory / LDAP server and:

  1. Check that this user really does exist;
  2. Verify provided password;
  3. Make sure that this is an active (non-disabled) user;
  4. Check that this user is a member of a particular group (crew);
  5. Fetch various information about this user:
    • full name;
    • e-mail;
    • registration date;
    • what other groups he belongs to (such as managers group);
    • etc;
  6. If it’s all good, then call HttpContext.SignInAsync().

Here’s the code:

// declare the interface first
public interface ISignInManager
{
    Task<bool> SignIn(string username, string password);
    Task SignOut();
}

// and then implement it
public class SignInManager : ISignInManager
{
    private readonly ConfigurationAD _configurationAD;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public SignInManager(
        IOptions<ConfigurationAD> configurationAD,
        IHttpContextAccessor httpContextAccessor
        )
    {
        _configurationAD = configurationAD.Value;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task<bool> SignIn(string username, string password)
    {
        var adUser = new ADUser();

        var searchResults = General.SearchInAD(
            _configurationAD.LDAPserver,
            _configurationAD.Port,
            _configurationAD.Domain,
            username,
            password,
            $"CN=Users,{_configurationAD.LDAPQueryBase}",
            new StringBuilder("(&")
                .Append("(objectCategory=person)")
                .Append("(objectClass=user)")
                .Append($"(memberOf={_configurationAD.Crew})")
                .Append("(!(userAccountControl:1.2.840.113556.1.4.803:=2))")
                .Append($"(sAMAccountName={username})")
                .Append(")")
                .ToString(),
            SearchScope.Subtree,
            new string[]
            {
                "objectGUID",
                "sAMAccountName",
                "displayName",
                "mail",
                "whenCreated",
                "memberOf"
            }
        );

        var results = searchResults.Entries.Cast<SearchResultEntry>();
        if (results.Any())
        {
            var resultsEntry = results.First();
            adUser = new ADUser()
            {
                objectGUID = new Guid((resultsEntry.Attributes["objectGUID"][0] as byte[])!),
                sAMAccountName = resultsEntry.Attributes["sAMAccountName"][0].ToString()!,
                displayName = resultsEntry.Attributes["displayName"][0].ToString()!,
                mail = resultsEntry.Attributes["mail"][0].ToString()!,
                whenCreated = DateTime.ParseExact(
                    resultsEntry.Attributes["whenCreated"][0].ToString()!,
                    "yyyyMMddHHmmss.0Z",
                    System.Globalization.CultureInfo.InvariantCulture
                )
            };
            var groups = resultsEntry.Attributes["memberOf"];
            foreach(var g in groups)
            {
                var groupNameBytes = g as byte[];
                if (groupNameBytes != null)
                {
                    adUser.memberOf.Add(Encoding.Default.GetString(groupNameBytes).ToLower());
                }
            }
        }
        else
        {
            Console.WriteLine(
                $"There is no such user in the [crew] group: {username}"
            );
            return false;
        }

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, adUser.objectGUID.ToString()),
            new Claim(ClaimTypes.WindowsAccountName, adUser.sAMAccountName),
            new Claim(ClaimTypes.Name, adUser.displayName),
            new Claim(ClaimTypes.Email, adUser.mail),
            new Claim("whenCreated", adUser.whenCreated.ToString("yyyy-MM-dd"))
        };
        // perhaps it should add a role for every group, but we only need one for now
        if (adUser.memberOf.Contains(_configurationAD.Managers.ToLower()))
        {
            claims.Add(new Claim(ClaimTypes.Role, "managers"));
        }

        var identity = new ClaimsIdentity(
            claims,
            "LDAP", // what goes to User.Identity.AuthenticationType
            ClaimTypes.Name, // which claim is for storing user name in User.Identity.Name
            ClaimTypes.Role // which claim is for storing user roles, needed for User.IsInRole()
        );
        var principal = new ClaimsPrincipal(identity);

        if (_httpContextAccessor.HttpContext != null)
        {
            try
            {
                await _httpContextAccessor.HttpContext.SignInAsync(
                    CookieAuthenticationDefaults.AuthenticationScheme,
                    principal
                );
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Signing in has failed. {ex.Message}");
            }
        }

        return false;
    }

    public async Task SignOut()
    {
        if (_httpContextAccessor.HttpContext != null)
        {
            await _httpContextAccessor.HttpContext.SignOutAsync(
                CookieAuthenticationDefaults.AuthenticationScheme
            );
        }
        else
        {
            throw new Exception(
                "For some reasons, HTTP context is null, signing out cannot be performed"
            );
        }
    }
}

As you can see, it creates an object of ADUser class. That is not really required, at least here, but it will be useful to have it later as a model, so here it is:

public class ADUser
{
    public Guid objectGUID { get; set; }

    public string sAMAccountName { get; set; } = string.Empty;
    public string displayName { get; set; } = string.Empty;
    public string mail { get; set; } = string.Empty;

    public DateTime whenCreated { get; set; }

    public List<string> memberOf { get; set; } = new List<string>();
}

But that’s minor detail, really. What’s important is the list of Claim objects and the ClaimsIdentity.

Claims

I’d say, a claim is basically a key-value pair, and while the key can certainly be an arbitrary string of your choice (such as what I did with whenCreated claim), I think it’s better to re-use standard/reserved names from the ClaimTypes class (where applicable). The claims are used to store user information that we got from Active Directory / LDAP.

Then, using the list of claims, we create the ClaimsIdentity object. Aside from the claims list it also contains the following meta-information:

  • authenticationType: might be not affecting anything, but it is available in User.Identity, so I’d set it to some meaningful value, such as LDAP;
  • nameType - what claim from the list is responsible for the user name (it will become available as User.Identity.Name);
  • roleType - what claim from the list is responsible for user roles (it will be used for calling User.IsInRole()).

Finally, we create the ClaimsPrincipal object based on our ClaimsIdentity and pass it to HttpContext.SignInAsync().

Application services

For the following to work in SignInManager:

await _httpContextAccessor.HttpContext.SignInAsync(
    CookieAuthenticationDefaults.AuthenticationScheme,
    principal
);

there should be a HttpContextAccessor available, so it needs to be injected in Program.cs:

builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

In turn, SignInManager itself also needs to be added to services:

builder.Services.AddScoped<ISignInManager, SignInManager>();

And after that comes the actual authentication (and authorization) setup (here’s a more detailed documentation on the matter):

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(
        options =>
        {
            options.ExpireTimeSpan = TimeSpan.FromDays(11);

            options.LoginPath = "/account/login";
            options.AccessDeniedPath = "/account/access-denied";
        }
    );

builder.Services.AddAuthorization(options =>
{
    options.DefaultPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

builder.Services.AddControllersWithViews(
    options =>
    {
        options.Filters.Add(new AuthorizeFilter());
    }
);

// ...

app.UseRouting();

// these two come between UseRouting() and MapControllerRoute()
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
);

Having added AuthorizeFilter() we made all the controllers to have [Authorize] attribute, which applies default authorization policy, which in our case is simply RequireAuthenticatedUser() - only those who entered correct username and password can open website pages. Of course, you need to add [AllowAnonymous] attribute to Account/Login (and probably to Home/Error) actions, otherwise no-one will ever be able to sign-in, as they won’t have access even to the login page.

Authorization

Hopefully, you already know the difference between authentication and authorization.

A bare [Authorize] attribute is not exactly an authorization in our case, as the default policy that we’ve set above is just to require all users to be authenticated.

Suppose, you’d like to have an Admin controller that should be available only to users who belong to managers group. Thanks to the way our SignInManager handles users identities, you get this functionality almost for free, you only need to add authorization attribute with required roles, like this:

[Authorize(Roles = "managers")]
public class AdminController : Controller
{
    // ...
}

Now the users who are not in managers group will be getting /account/access-denied page, trying to open views that belong to this controller. Now that’s more like an actual authorization.

Also, here’s how you can show certain parts of UI in Razor views based on whether current user belongs to a certain group or not:

@if (User.IsInRole("managers"))
{
    <a href='@(Url.Action("Index", "Admin"))'>
        Portal administration panel
    </a>
}

An example project

A sample project with Active Directory / LDAP authentication (which this article is based on) is available here.

The login page looks like this:

Login page

Note that to the right from the website name in the header there should be a link to the administration page, which should be visible only to administrators, but it is not there now, because user isn’t even authenticated yet.

After user is authenticated, he can open his account page:

Account page

There he can see some of the attributes that his account has in Active Directory. Also, now the lock icon link in the header is visible (because this user belongs to managers group), and it leads to the administration page:

Administration page

And there it displays some of the Root DSE attributes.