I recently wrote about implementing Windows Authentication with React and .NET Core. Given the length of that post, I found it necessary to keep it bare bones. Today we’re going to talk about expanding our Windows Authentication in NET Core by adding role-based security.
All code for today’s post is found on GitHub (expanding-role-based-auth branch). I’m including pertinent code snippets and screenshots with the text to help guide the conversation.
Framing the (potential) problem
Implementing Windows Authentication in NET Core with default handlers is easy. One thing I thought was a problem when I first dug in was that the user’s security groups are represented solely by their sid
(what is a SID?). I incorrectly assumed that due to that, the [Authorize(Roles = "some-role")]
would fail to match. I was wrong.
One problem here, however, is that if you are logging the user and their groups/roles, you won’t actually be able to translate that sid to the group. Unless you’re really crazy smart. I’m not.
If you want to know the name of the group instead of just the sid, what can you do?
While I’m sure there are plenty of other options, here are a couple I can see:
- Handle only the authentication portion using Windows Authentication; make authorization (roles) the application’s concern.
- Have AD security groups dictate authorization, get the name from AD based on/matching the
sid
or by some other means.
If you ask my opinion I personally feel like authorization should belong to the application and not the AD domain. For completeness sake, however, I’ll go over both approaches in this post.
Retrieving the logged-in user’s roles
Let’s start by looking at how to transform the claims from using sid
to using the displayName
instead.
Using IdentityReference.Translate
For the first pass let’s go ahead and look at the Translate
method on our WindowsIdentity.Groups
. You might recall in our previous post that we introduced a custom ClaimsTransformer. Its only task was injecting a custom fake role claim. We’re going to expand that class a little more today.
Before we do, however, we need to create a class to retrieve roles for the user. How about we call the interface IUserRoleManager
with string[] GetRoles()
. For this one, we’ll create a concrete implementation called TranslatedUserRoleManager
. The sole purpose of this class is calling Translate
on each IdentityReference (see below):
public IEnumerable<string> GetRoles()
{
var wi = HttpContext.User?.Identity as WindowsIdentity;
if (wi == null)
return Array.Empty<string>();
return wi.Groups
.Select(group => group.Translate(typeof(NTAccount)).Value)
.ToArray();
}
I haven’t looked at the code to see but I suspect something similar to this is how the Authorize
attribute resolves the sid
to the display name.
Don’t forget to hook IUserRoleManager
up in your DI container and then inject it into ClaimsTransformer
.
Running this on your local machine without Active Directory still works. It picks up all your local user roles. What I don’t like about this method, however, is that it picks up everything. In AD, for example, it will grab your distribution list memberships as well. Using it this way doesn’t hurt but like me, perhaps you’re wondering if there is another way?
Before we look at another approach, I do want to point out that you can also make use of System.DirectoryServices.AccountManagement.GroupPrincipal.FindByIdentity
to retrieve the full group. This would allow you to determine the type of group here but it also loads a lot of “fluff” information we simply don’t need right now.
Querying Active Directory
If we want a little more control over how we interact with and retrieve the roles, we can make use of Active Directory or LDAP. Getting the roles in this way greatly improves Windows Authentication in NET Core.
We have two approaches: 1) System.Windows.Compatibility
, 2) Novell.Directory.Ldap.NETStandard2_0
. If you are deploying only to a Windows host then option #1 is a good one. If you are deploying to something other than Windows, however, you will need to use option #2. Given our previous example post was set up to work with Windows and IIS I’m choosing option #1 for this post.
For obvious reasons, you’ll need to be on an Active Directory enabled network for this portion to work. You can simulate it in a development environment in a variety of ways. One such way (which I am completely unfamiliar with) is using Active Directory Lightweight Directory Services.
Without further ado, let’s introduce ActiveDirectoryUserRoleManager
. I’ll paste the GetRoles
method below:
public IEnumerable<string> GetRoles()
{
string userName = HttpContext.User?.Identity?.Name;
string ldapUrl = "127.0.0.1";
if (string.IsNullOrWhiteSpace(userName))
return Array.Empty<string>();
string sanitizedUser = userName.Contains(@"\") ? userName.Substring(userName.IndexOf(@"\") + 1) : userName;
try
{
using (var entry = new DirectoryEntry($"LDAP://{ldapUrl}"))
{
using (var searcher = new DirectorySearcher(entry))
{
searcher.Filter = $"(sAMAccountName={sanitizedUser})";
searcher.PropertiesToLoad.Add(MemberOfAttribute);
var searchResult = searcher.FindOne();
var memberOf = (searchResult != null && searchResult.Properties.Contains(MemberOfAttribute)) ? searchResult.Properties[MemberOfAttribute] : null;
List<string> roles = new List<string>();
foreach (var role in memberOf)
{
var match = System.Text.RegularExpressions.Regex.Match(role.ToString(), CanonicalName_Regex, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success)
roles.Add(match.Groups[CanonicalName_GroupName].Value);
}
return roles;
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Unable to retrieve LDAP roles");
return Array.Empty<string>();
}
}
Ok, a lot is going on here. Let’s try to break it down. We’re grabbing the user name off of the HttpContext.User.Identity
. We’re sanitizing (stripping domain name) off of it. Next, we make a lookup to the LDAP server and ask for just the memberOf
attribute. Once found we use regular expressions to extract the CN (canonical name) of *only* those that are marked as “Security Group”. Yeah, yeah, I could have used regex to sanitize the username as well.
Please note that in the code above I arbitrarily put a fake IP address to an LDAP server. Normally you’d want to pull this from a configuration file.
Testing it out
Testing these role transformations is pretty simple. Decorate an action or controller with [Authorize(Roles = "MyRoleFromActiveDirectory")]
where the name matches one or more roles you require. Since we added the named roles back to the identity using identity.RoleClaimType
it will automatically map up.
Handling roles locally
This part is a lot like underwear. Some people like them tight and white. Others like them more like boxers. Still, others prefer to go commando and not wear any. I guess what I’m saying is that there are a lot of different ways you can approach handling roles locally to your application. The approach I suggest today is by no means the only way. I’m not even suggesting it is “The Right Way™”. All I’m suggesting is that this is one approach you can take.
As I previously mentioned, I feel that authorization is a function of the application. I only want AD to tell me who the person is and that they are authenticated.
Now, that said, the way I’m approaching this below is not using the new policy design which is the recommended approach. Instead, I’m working with IAuthorizationFilter
and a custom TypeFilterAttribute
.
I took inspiration for this approach via this Stackoverflow answer.
Defining Roles
For this section, I’m defining Role
as an enum
. The role is the most basic form of access within the system. Therefore, it is not dynamic. You might also call this a “Permission” or “Grant”. I’m less concerned about the name of this so much as the purpose.
public enum Role
{
None = 0,
Admin = 1,
ReadOnly = 2,
Write = 3
}
Let’s also define something called RoleGroup
. This represents a collection of roles that might have access to a portion of your system. Depending on how you design your system, RoleGroup
could be defined dynamically. In this case, however, it is also compiled.
public enum RoleGroup
{
Any,
Write
}
For simplicity I’m also including some constants and extension methods that help work with the Role
and RoleGroup
.
Next, we need to introduce a couple new classes. We’ll define RoleRequirementFilter
and RoleRequirementAttribute
. I don’t want to go into great detail here. The RoleRequirementFilter
implements IAuthorizationFilter
whilst RoleRequirementAttribute
extends TypeFilterAttribute
.
We’ll decorate our WebControllerBase from last post with [RoleRequirement(RoleGroup.Any)]
. This will ensure that the user is authenticated and possesses at least one role.
UserRoleManager
Normally you’d have this stored in the database. This example is ultra-simple and just returns a static set of roles: Role.ReadOnly
and Role.Reports
. This user doesn’t get to do much apparently.
Testing it out
In order to test this out, I’ve added a couple contrived actions to the WeatherForecastController
. These actions are Admin and Reports which require Role.Admin
and Role.Reports
respectively. Running the application and attempting to navigate to those endpoints will now yield a 403 and “Congrats, you can run reports!” respectively.
Conclusion
Today we looked at how to expand role-based security in two ways. Our first approach was retrieving the role names from Active Directory. The second approach was using a custom role engine and IAuthorizeFilter
. These approaches both allow us to use make better use of Windows Authentication in NET Core. All code from today’s post is located on GitHub (expanding-role-based-auth branch).
Credits
Photo by Dayne Topkin on Unsplash