Recommended best practice for role claims as permissions

后端 未结 1 1814
清歌不尽
清歌不尽 2021-02-01 06:53

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 Iden

相关标签:
1条回答
  • 2021-02-01 07:50

    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.

    0 讨论(0)
提交回复
热议问题