LDAP authentication in ASP.NET Core MVC
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.
(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:
And here’s how the tree of nodes looks like:
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.
memberOf
As it was pointed out in the comments, some servers, namely OpenLDAP, do not have memberOf
attribute, or at least they do not have it set in default settings/configuration.
Moreover, the OpenLDAP manual page says the following:
memberof - This overlay maintains automatic reverse group membership values, typically stored in an attribute called memberOf. This overlay is deprecated and should be replaced with dynlist.
That I don’t really get. Is memberOf
a part of the LDAP specification, or is it a vendor-specific extension (coming from Microsoft and its Active Directory)? How can one not support or deprecate it?
Either way, the possible workarounds would be to:
- either configure your OpenLDAP server and add
memberOf
support to it; - or modify your project sources and use whatever attribute OpenLDAP has instead of
memberOf
(inSignIn()
method and all other places).
I don’t have an OpenLDAP server running and sadly I don’t have time to launch one, so I haven’t tested either of the options myself.
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:
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:
- Check that this user really does exist;
- Verify provided password;
- Make sure that this is an active (non-disabled) user;
- Check that this user is a member of a particular group (
crew
); - Fetch various information about this user:
- full name;
- e-mail;
- registration date;
- what other groups he belongs to (such as
managers
group); - etc;
- 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 inUser.Identity
, so I’d set it to some meaningful value, such asLDAP
;nameType
- what claim from the list is responsible for the user name (it will become available asUser.Identity.Name
);roleType
- what claim from the list is responsible for user roles (it will be used for callingUser.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:
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:
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:
And there it displays some of the Root DSE attributes.
Social networks
Zuck: Just ask
Zuck: I have over 4,000 emails, pictures, addresses, SNS
smb: What? How'd you manage that one?
Zuck: People just submitted it.
Zuck: I don't know why.
Zuck: They "trust me"
Zuck: Dumb fucks