Search code examples
permissionsoauth-2.0asp.net-identityjwt.net-core

Recommended best practice for role claims as permissions


The app I am working on is a SPA and we are using JWT Bearer authentication and OpenIdConnect/OAuth2 when communicating with our backend API which uses .NETCore and ASP.NET Identity. Our API endpoints are secured using Custom Policy based authentication as shown here:

Custom Policy Based Authentication

We decided to use the out of the box AspNetRoleClaims table to store claims for our users as permissions. Each user is assigned 1 primary role although the potential is there to have multiple roles. Each role will have many claims - which are stored in the AspNetRoleClaims table.

Role claims would look like this:

ClaimType: Permission

ClaimValue(s):

MyModule1.Create

MyModule1.Read

MyModule1.Edit

MyModule1.Delete

MyModule1.SomeOtherPermission

MyModule2.Read

MyModule3.Read

MyModule3.Edit

etc.

The more permissions or role claims that a user has, the larger the access_token will be, thereby increasing the HTTP header size. Also the ASP.NET Identity Authorization cookie - as there are more and more role claims it gets chunked out into multiple cookies.

I have experimented with adding in a lot of role claims and eventually the request fails because the header gets too big.

I am looking for some advice on what is considered "best practice" when it comes to bearer authentication with role claims. Microsoft gives you AspNetRoleClaims out of the box that work for my scenario and from what I understand the advantage of storing these role claims in the access_token is that we don't have to hit the database on each API endpoint that is secured with the custom policy.

The way I see it, I can try to make the claim values smaller, and in the case of where a user has multiple roles that may share common role claims that are duplicated, I can try to intercept when these get written into the cookie and remove the duplicates.

However, since the app is still in development, I can foresee more and more roles claims being added and there is always the possibility that the HTTP header will become too large with the cookies and the access_token. Not sure if this is the best approach.

The only alternative I see is to hit the database each time we hit our protected API. I could inject a DbContext in each custom claim policy requirement handler and talk to the AspNetRoleClaims table on each request.

I haven't seen too many examples out there of how people accomplish a more finely grained permissions scheme with ASP.NET Identity and .NET Core API. This must be a fairly common requirement I would think...

Anyways, just looking for some feedback and advice on recommended best practice for a scenario like this.

****UPDATE - See answer below ****


Solution

  • I never did find a recommended "best practice" on how to accomplish this but thanks to some helpful blog posts I was able to architect a nice solution for the project I was working on. I decided to exclude the identity claims from the id token and the Identity cookie and do the work of checking the users permissions (role claims) server side with each request.

    I ended up using the architecture are described above, using the built in AspNetRoleClaims table and populating it with permissions for a given role.

    For example:

    ClaimType: Permission

    ClaimValue(s):

    MyModule1.Create

    MyModule1.Read

    MyModule1.Edit

    MyModule1.Delete

    I use Custom policy based authentication as described in the Microsoft article in the link above. Then I lock down each of my API endpoints with the Role based policy.

    I also have an enum class that has all the permissions stored as enums. This enum just lets me refer to the permission in code without having to use magic strings.

    public enum Permission
    {
        [Description("MyModule1.Create")]
        MyModule1Create,
        [Description("MyModule1.Read")]
        MyModule1Read,
        [Description("MyModule1.Update")]
        MyModule1Update,
        [Description("MyModule1.Delete")]
        MyModule1Delete
    }
    

    I register the permissions in Startup.cs like so:

    services.AddAuthorization(options =>
            {
                options.AddPolicy("MyModule1Create",
                    p => p.Requirements.Add(new PermissionRequirement(Permission.MyModule1Create)));
                options.AddPolicy("MyModule1Read",
                    p => p.Requirements.Add(new PermissionRequirement(Permission.MyModule1Read)));
                options.AddPolicy("MyModule1Update",
                    p => p.Requirements.Add(new PermissionRequirement(Permission.MyModule1Update)));
                options.AddPolicy("MyModule1Delete",
                    p => p.Requirements.Add(new PermissionRequirement(Permission.MyModule1Delete)));
            }
    

    So there is a matching Permission and a PermissionRequirement like so:

    public class PermissionRequirement : IAuthorizationRequirement
    {
        public PermissionRequirement(Permission permission)
        {
            Permission = permission;
        }
    
        public Permission Permission { get; set; }
    }
    
    public class PermissionRequirementHandler : AuthorizationHandler<PermissionRequirement>,
        IAuthorizationRequirement
    
    {
        private readonly UserManager<User> _userManager;
        private readonly IPermissionsBuilder _permissionsBuilder;
    
        public PermissionRequirementHandler(UserManager<User> userManager,
            IPermissionsBuilder permissionsBuilder)
        {
            _userManager = userManager;
            _permissionsBuilder = permissionsBuilder;
        }
    
        protected override async Task HandleRequirementAsync(
            AuthorizationHandlerContext context,
            PermissionRequirement requirement)
        {
            if (context.User == null)
            {
                return;
            }
    
            var user = await _userManager.GetUserAsync(context.User);
            if (user == null)
            {
                return;
            }
    
            var roleClaims = await _permissionsBuilder.BuildRoleClaims(user);
    
            if (roleClaims.FirstOrDefault(c => c.Value == requirement.Permission.GetEnumDescription()) != null)
            {
                context.Succeed(requirement);
            }
    
        }
    }
    

    The extension method on the permission GetEnumDescription just takes the enum that I have in the code for each permission and translates it to the same string name as it is stored in the database.

    public static string GetEnumDescription(this Enum value)
    {
        FieldInfo fi = value.GetType().GetField(value.ToString());
    
        DescriptionAttribute[] attributes =
            (DescriptionAttribute[])fi.GetCustomAttributes(
            typeof(DescriptionAttribute),
            false);
    
        if (attributes != null &&
            attributes.Length > 0)
            return attributes[0].Description;
        else
            return value.ToString();
    }
    

    My PermissionHandler has a PermissionsBuilder object. This is a class I wrote that will hit the database and check if the logged in user has a particular role claim.

    public class PermissionsBuilder : IPermissionsBuilder
    {
        private readonly RoleManager<Role> _roleManager;
    
        public PermissionsBuilder(UserManager<User> userManager, RoleManager<Role> roleManager)
        {
            UserManager = userManager;
            _roleManager = roleManager;
    
        }
    
        public UserManager<User> UserManager { get; }
    
        public async Task<List<Claim>> BuildRoleClaims(User user)
        {
            var roleClaims = new List<Claim>();
            if (UserManager.SupportsUserRole)
            {
                var roles = await UserManager.GetRolesAsync(user);
                foreach (var roleName in roles)
                {
                    if (_roleManager.SupportsRoleClaims)
                    {
                        var role = await _roleManager.FindByNameAsync(roleName);
                        if (role != null)
                        {
                            var rc = await _roleManager.GetClaimsAsync(role);
                            roleClaims.AddRange(rc.ToList());
                        }
                    }
                    roleClaims = roleClaims.Distinct(new ClaimsComparer()).ToList();
                }
            }
            return roleClaims;
        }
    }
    

    I build up a list of distinct role claims for a user - I use a ClaimsComparer class to help do this.

    public class ClaimsComparer : IEqualityComparer<Claim>
    {
        public bool Equals(Claim x, Claim y)
        {
            return x.Value == y.Value;
        }
        public int GetHashCode(Claim claim)
        {
            var claimValue = claim.Value?.GetHashCode() ?? 0;
            return claimValue;
        }
    }
    

    The controllers are locked down with the role based custom policy:

    [HttpGet("{id}")]
    [Authorize(Policy = "MyModule1Read", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public IActionResult Get(int id){  
    

    Now here is the important part - you need to override the UserClaimsPrincipalFactory in order to prevent the role claims from being populated into the Identity cookie. This solves the problem of the cookie and the headers being too big. Thanks to Ben Foster for his helpful posts (see links below)

    Here is my custom AppClaimsPrincipalFactory:

    public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<User, Role>
    {
        public AppClaimsPrincipalFactory(UserManager<User> userManager, RoleManager<Role> roleManager, IOptions<IdentityOptions> optionsAccessor)
            : base(userManager, roleManager, optionsAccessor)
        {
        }
        public override async Task<ClaimsPrincipal> CreateAsync(User user)
        {
            if (user == null)
            {
                throw new ArgumentNullException(nameof(user));
            }
            var userId = await UserManager.GetUserIdAsync(user);
            var userName = await UserManager.GetUserNameAsync(user);
            var id = new ClaimsIdentity("Identity.Application", 
                Options.ClaimsIdentity.UserNameClaimType,
                Options.ClaimsIdentity.RoleClaimType);
            id.AddClaim(new Claim(Options.ClaimsIdentity.UserIdClaimType, userId));
            id.AddClaim(new Claim(Options.ClaimsIdentity.UserNameClaimType, userName));
            if (UserManager.SupportsUserSecurityStamp)
            {
                id.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType,
                    await UserManager.GetSecurityStampAsync(user)));
            }
    
            // code removed that adds the role claims 
    
            if (UserManager.SupportsUserClaim)
            {
                id.AddClaims(await UserManager.GetClaimsAsync(user));
            }
    
            return new ClaimsPrincipal(id);
        }
    }
    

    Register this class in Startup.cs

    services.AddIdentity<ApplicationUser, IdentityRole>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddDefaultTokenProviders();
    
        // override UserClaimsPrincipalFactory (to remove role claims from cookie )
        services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, AppClaimsPrincipalFactory>();
    

    Here are the links to Ben Foster's helpful blog posts:

    AspNet Identity Role Claims

    Customizing claims transformation in AspNet Core Identity

    This solution has worked well for the project I was working on - hope it helps someone else out.