Introduction
It's been pretty easy up to now, we have an Angular site with APIs protected by Azure AD and it's not been much work. This last step, supporting roles, is definitely the most complex.
Background
This is the final article in this series. If you want to follow along writing the code yourself as I explain it, you should start with the source code of the previous article. When we're done, you'll be able to use role based security in your Angular website using Azure AD. While we will be able to return a user's roles and make the front end show only valid options to the user, the main protection is on the APIs, a single page app sends all it's source code to the client and it's the sensitive data that we're making sure unauthorised people cannot see more than anything.
Creating the roles
In Azure AD, roles map to what are called 'groups'. So, the first step is to create some groups in Azure, go to Azure AD, click on 'groups' and create a new one. My groups are called "WebsiteUser" and "WebsiteAdmin" if you are following along in code.
I'm not sure what Office 365 groups are for, but you want to create security groups.
Once a group is created, you need to grab the object id from the details. You can rename your groups, but your code will keep working because you always work with groups by their object id, not their name.
From here it's pretty self explanatory how to add users to groups. Create at least two groups and add them to your user.
Accessing the roles
Now, there's one more step you need to do. Go to your application in Azure AD and view the manifest.
By default it says '"groupMembershipClaims": null. Change that to "All" and now your token will contain the ids of the groups you're in. My token, when decoded (a JWT is three Base64 encoded strings, there's decoders on the web), looks like this:
The token is, of course, being passed as a header in our protected requests.
Custom Auth in the back end
.NET Core recommends instead of writing custom auth, you write policies. This is a generally sound idea, but for this, we're going to write a custom auth method all the same.
Add a folder in your project called CustomAuth.
Create a class called 'Constants'. In it, put this value:
public static class Constants
{
public const string WebsiteUser = "WebsiteUser";
public const string WebsiteAdmin = " WebsiteAdmin";
public const string Groups = "groups";
}
Where the first two values are your group names. These are used to map group names to ids, so they need to match the names exactly.
That last class was to create constants to use in mapping permissions. Now we need a class to store the GUIDs of our roles (which, being config, will come from our appsettings).
public class Groups
{
public Guid WebsiteUser { get; set; }
public Guid WebsiteAdmin { get; set; }
}
Create a class called GroupAuth. Add this code to it:
public class GroupAuthRequirementAttribute : TypeFilterAttribute
{
public GroupAuthRequirementAttribute(string claimType, string claimValue) : base(typeof(GroupAuthFilter))
{
Arguments = new object[] { new Claim(claimType, claimValue) };
}
}
This is the attribute we'll put on methods we want to protect. Providing two parameters means we can filter on any attribute we want, although we could simplify the code and always filter on 'group', this code has the potential to be used in a more generic way.
Now add your group ids to your appsettings.json. The names here need to match the names in your Groups class, we're using these settings to populate an instance of that class.
"Groups": {
"WebsiteUser": "3d134942-e73f-xxxx-xxxx-xxxxxxxx",
"WebsiteAdmin": "698c5328-73f4-xxxx-xxxx-xxxxxxxx"
},
Now let's get coding.
public class GroupAuthFilter : IAuthorizationFilter
{
readonly Claim _claim;
readonly IConfiguration Configuration;
public GroupAuthFilter(Claim claim, IConfiguration configuration)
{
_claim = claim;
Configuration = configuration;
}
}
The IAuthorizationFilter interface is used to create an auth filter. The configuration is injected, this works across .Net Core. The claim is the claim created by our Attribute class. This is the bare bones of the class, now let's make it do something.
public void OnAuthorization(AuthorizationFilterContext context)
{
Groups groups = Configuration.GetSection("Groups").Get<Groups>();
var property = groups.GetType().GetProperty(_claim.Value);
if(property == null)
{
context.Result = new ForbidResult();
return;
}
Guid value = Guid.Empty;
value = (Guid)property.GetValue(groups);
var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == Constants.Groups && c.Value == value.ToString());
if(!hasClaim)
{
context.Result = new ForbidResult();
}
}
Going through it line by line:
Groups groups = Configuration.GetSection("Groups").Get<Groups>();
Populate our Groups class with the values in the Groups object in the config.
var property = groups.GetType().GetProperty(_claim.Value);
if(property == null)
{
context.Result = new ForbidResult();
return;
}
Guid value = Guid.Empty;
value = (Guid)property.GetValue(groups);
Use reflection to get out the GUID based on the property name we passed in on the attribute.
var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == Constants.Groups && c.Value == value.ToString());
if(!hasClaim)
{
context.Result = new ForbidResult();
}
The HttpContext.User object is populated from the token we passed in. We need to search it's claims for a group claim with the id of the group. If the claim is not present, we make the request fail.
Now the bad news....
This is pretty neat, isn't it? Clean, simple..... Sadly, it doesn't work. The gotcha is that, if you are a member of more than 5 groups, your token will not contain these GUIDs anymore, it will simply contain a property alerting you to the fact that the user is in groups ( and therefore, in more than 5 ).
To get around this, you need to add a library with NuGet, called Microsoft.Identity.Client. Then you need to add this code:
if (!hasClaim)
{
var options = Configuration.GetSection("AzureAD").Get<AzureAdOptions>();
var app = ConfidentialClientApplicationBuilder.Create(options.ClientId)
.WithClientSecret(options.ClientSecret)
.WithAuthority(new Uri(options.Authority))
.Build();
string[] scopes = new string[] { "https://graph.microsoft.com/.default" };
AuthenticationResult result = null;
try
{
result = app.AcquireTokenForClient(scopes)
.ExecuteAsync().Result;
}
catch (MsalServiceException ex) when (ex.Message.Contains("AADSTS70011"))
{
context.Result = new ForbidResult();
return;
}
catch(Exception ex)
{
context.Result = new ForbidResult();
return;
}
var httpClient = new HttpClient();
var defaultRequestHeaders = httpClient.DefaultRequestHeaders;
if (defaultRequestHeaders.Accept == null || !defaultRequestHeaders.Accept.Any(m => m.MediaType == "application/json"))
{
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);
var body = "{ \"securityEnabledOnly\" : false } ";
try
{
var userId = context.HttpContext.User.Claims.Where(e => e.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier").FirstOrDefault()?.Value;
HttpResponseMessage response = httpClient.PostAsync($"https://graph.microsoft.com/v1.0/users/{userId}/getMemberGroups", new StringContent(body, Encoding.UTF8, "application/json")).Result;
if (response.IsSuccessStatusCode)
{
string json = response.Content.ReadAsStringAsync().Result;
JObject items = JsonConvert.DeserializeObject(json) as JObject;
foreach (var groupId in items["value"].Children())
{
if (value.ToString() == groupId.Value<string>())
{
return;
}
}
}
else
{
string json = response.Content.ReadAsStringAsync().Result;
}
}
catch (Exception ex)
{
}
context.Result = new ForbidResult();
}
The Microsoft Graph API allows you to ask for a user, what groups they are in. But, the token you get back when you login, is not a token you can use for the Graph API. You need to request a special token to make those requests. The first half of this code is using this new library to do that. The second half is making a standard HTTP request to the Graph API, using this new token. We pull the user id out of the new token and use that to form the required URLs.
Two gotchas:
First, if you don't have a ClientSecret in your appsettings.json for Azure, you need to add one. The prior code doesn't need this secret, but calling the Graph API does.
Second, you need to also add permissions to your Azure AD application to allow users to request a user's groups.
The documentation for this API is here:
https://docs.microsoft.com/en-us/graph/api/user-getmembergroups?view=graph-rest-1.0
and the permissions you need are:
Application | Group.Read.All, Directory.Read.All, Directory.ReadWrite.All |
Don't forget to push the 'Grant admin consent' button when you're done. These permissions can be set on both sides shown, I believe it's Application Permissions you need (I certainly had to add those for it to start working).
There's two ways you can test this. First, give yourself more than 5 groups. Second, make a breakpoint and force the secondary code to run every time.
Room for improvement
This code works great but it gets a token for the Graph API and calls the API for every request. It would be far more efficient to cache either the token or the list of groups. The downside is, someone can be removed from a group and keep their access for the length of time before the cache expires. Given that this is a complex question based on specific business rules, I've not attempted to answer it, but I certainly don't recommend this code as-is for production.
Points of Interest
The main point of interest here is the frustrating fact that Azure Applications don't allow you to specific which specific groups you care about, and then provide only those group IDs, always, with your token. Getting to the point of being able to ask the Graph API for your groups was more work than it should have been, and will be more work again when I do it in plain .NET, where the library I used does not exist.
History
V1.0