Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Hosted-services / Azure

Angular and Azure AD part 3 - adding role based security

5.00/5 (1 vote)
17 Apr 2019CPOL6 min read 13.2K   144  
Adding role based security to our Azure AD/Angular website

Download Angular_Azure_AD.zip

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.

Image 1

I'm not sure what Office 365 groups are for, but you want to create security groups.

Image 2

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.

Image 3

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:

Image 4

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)
          {
              // Invalid value
              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)
{
    // Invalid value
    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)
           {
               // This could mean the user has more than 5 groups and we need to ask Azure AD for them.
               var options = Configuration.GetSection("AzureAD").Get<AzureAdOptions>();

               var app = ConfidentialClientApplicationBuilder.Create(options.ClientId)
               .WithClientSecret(options.ClientSecret)
               .WithAuthority(new Uri(options.Authority))
               .Build();

               // With client credentials flows the scopes is ALWAYS of the shape "resource/.default", as the
               // application permissions need to be set statically (in the portal or by PowerShell), and then granted by
               // a tenant administrator
               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"))
               {
                   // Invalid scope. The scope has to be of the form "https://resourceurl/.default"
                   // Mitigation: change the scope to be as expected
                   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>())
                           {
                               // It worked so return;
                               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

Image 5

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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)