Windows Authentication in NET Core: Expanding Role-Based Security

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