Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use ASP.NET Core resource-based authorization without duplicating if/else code everywhere

I have a dotnet core 2.2 api with some controllers and action methods that needs to be authorized based on a user claim and the resource being accessed. Basically, each user can have 0 or many "roles" for each resource. This is all done using ASP.NET Identity Claims.

So, my understanding is that I need to make use of Resource-based authorization. But both examples there are mostly identical and require the explicit imperative if/else logic on each action method, which is what I'm trying to avoid.

I want to be able to do something like

[Authorize("Admin")] // or something similar
public async Task<IActionResult> GetSomething(int resourceId)
{
   var resource = await SomeRepository.Get(resourceId);

   return Json(resource);
}

And somewhere else define the authorization logic as a policy/filter/requirement/whatever and have access to both the current user claims and the resourceId parameter received by the endpoint. So there I can see if the user has a claim that denotes that he has the "Admin" role for that specific resourceId.

like image 482
empz Avatar asked Jun 27 '19 18:06

empz


People also ask

Can ASP.NET Core handle many requests?

ASP.NET Core apps should be designed to process many requests simultaneously. Asynchronous APIs allow a small pool of threads to handle thousands of concurrent requests by not waiting on blocking calls. Rather than waiting on a long-running synchronous task to complete, the thread can work on another request.

How many types of authorization are there in ASP.NET Core?

ASP.NET allows four types of authentications: Windows Authentication. Forms Authentication. Passport Authentication.

What is an advantage of using a policy based authorization instead of a role based one?

By using Policy-based & Role-based Authorization process, we can provide access to particular area of application to the user based on the Role/Policy of the user.

What is the difference between authorization and authentication in ASP.NET Core?

Authentication is the process of determining a user's identity. Authorization is the process of determining whether a user has access to a resource.


4 Answers

Edit: Based on feedback to make it dynamic

The key thing with RBAC and claims in .NET, is to create your ClaimsIdentity and then let the framework do it's job. Below is an example middleware that will look at the query parameter "user" and then generate the ClaimsPrincipal based on a dictionary.

To avoid the need to actually wire up to an identity provider, I created a Middleware that sets up the ClaimsPrincipal:

// **THIS CLASS IS ONLY TO DEMONSTRATE HOW THE ROLES NEED TO BE SETUP **
public class CreateFakeIdentityMiddleware
{
    private readonly RequestDelegate _next;

    public CreateFakeIdentityMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    private readonly Dictionary<string, string[]> _tenantRoles = new Dictionary<string, string[]>
    {
        ["tenant1"] = new string[] { "Admin", "Reader" },
        ["tenant2"] = new string[] { "Reader" },
    };

    public async Task InvokeAsync(HttpContext context)
    {
        // Assume this is the roles
        List<Claim> claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, "John"),
            new Claim(ClaimTypes.Email, "[email protected]")
        };

        foreach (KeyValuePair<string, string[]> tenantRole in _tenantRoles)
        {
            claims.AddRange(tenantRole.Value.Select(x => new Claim(ClaimTypes.Role, $"{tenantRole.Key}:{x}".ToLower())));
        }
        
        // Note: You need these for the AuthorizeAttribute.Roles    
        claims.AddRange(_tenantRoles.SelectMany(x => x.Value)
            .Select(x => new Claim(ClaimTypes.Role, x.ToLower())));

        context.User = new System.Security.Claims.ClaimsPrincipal(new ClaimsIdentity(claims,
            "Bearer"));

        await _next(context);
    }
}

To wire this up, just use the UseMiddleware extension method for IApplicationBuilder in your startup class.

app.UseMiddleware<RBACExampleMiddleware>();

I create an AuthorizationHandler which will look for the query parameter "tenant" and either succeed or fail based on the roles.

public class SetTenantIdentityHandler : AuthorizationHandler<TenantRoleRequirement>
{
    public const string TENANT_KEY_QUERY_NAME = "tenant";

    private static readonly ConcurrentDictionary<string, string[]> _methodRoles = new ConcurrentDictionary<string, string[]>();

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TenantRoleRequirement requirement)
    {
        if (HasRoleInTenant(context))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }

    private bool HasRoleInTenant(AuthorizationHandlerContext context)
    {
        if (context.Resource is AuthorizationFilterContext authorizationFilterContext)
        {
            if (authorizationFilterContext.HttpContext
                .Request
                .Query
                .TryGetValue(TENANT_KEY_QUERY_NAME, out StringValues tenant)
                && !string.IsNullOrWhiteSpace(tenant))
            {
                if (TryGetRoles(authorizationFilterContext, tenant.ToString().ToLower(), out string[] roles))
                {
                    if (context.User.HasClaim(x => roles.Any(r => x.Value == r)))
                    {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    private bool TryGetRoles(AuthorizationFilterContext authorizationFilterContext,
        string tenantId,
        out string[] roles)
    {
        string actionId = authorizationFilterContext.ActionDescriptor.Id;
        roles = null;

        if (!_methodRoles.TryGetValue(actionId, out roles))
        {
            roles = authorizationFilterContext.Filters
                .Where(x => x.GetType() == typeof(AuthorizeFilter))
                .Select(x => x as AuthorizeFilter)
                .Where(x => x != null)
                .Select(x => x.Policy)
                .SelectMany(x => x.Requirements)
                .Where(x => x.GetType() == typeof(RolesAuthorizationRequirement))
                .Select(x => x as RolesAuthorizationRequirement)
                .SelectMany(x => x.AllowedRoles)
                .ToArray();

            _methodRoles.TryAdd(actionId, roles);
        }

        roles = roles?.Select(x => $"{tenantId}:{x}".ToLower())
            .ToArray();

        return roles != null;
    }
}

The TenantRoleRequirement is a very simple class:

public class TenantRoleRequirement : IAuthorizationRequirement { }

Then you wire everything up in the startup.cs file like this:

services.AddTransient<IAuthorizationHandler, SetTenantIdentityHandler>();

// Although this isn't used to generate the identity, it is needed
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Audience = "https://localhost:5000/";
    options.Authority = "https://localhost:5000/identity/";
});

services.AddAuthorization(authConfig =>
{
    authConfig.AddPolicy(Policies.HasRoleInTenant, policyBuilder => {
        policyBuilder.RequireAuthenticatedUser();
        policyBuilder.AddRequirements(new TenantRoleRequirement());
    });
});

The method looks like this:

// TOOD: Move roles to a constants/globals
[Authorize(Policy = Policies.HasRoleInTenant, Roles = "admin")]
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
    return new string[] { "value1", "value2" };
}

Below are the test scenarios:

  1. Positive: https://localhost:44337/api/values?tenant=tenant1

  2. Negative: https://localhost:44337/api/values?tenant=tenant2

  3. Negative: https://localhost:44337/api/values

The key thing with this approach is that I never actually return a 403. The code setups the identity and then lets the framework handle the result. This ensures authentication is separate from authorization.

like image 181
Rogala Avatar answered Nov 12 '22 14:11

Rogala


Edited based on comments

According to my understanding, you want to access current user (all information related to it), the role(s) you want to specify for a controller (or action) and parameters received by endpoint. Haven't tried for web api, but for asp.net core MVC, You can achieve this by using AuthorizationHandler in a policy-based authorization and combine with an injected service specifically created to determine the Roles-Resources access.

To do it, first setup the policy in Startup.ConfigureServices :

services.AddAuthorization(options =>
{
    options.AddPolicy("UserResource", policy => policy.Requirements.Add( new UserResourceRequirement() ));
});
services.AddScoped<IAuthorizationHandler, UserResourceHandler>();
services.AddScoped<IRoleResourceService, RoleResourceService>();

next create the UserResourceHandler :

public class UserResourceHandler : AuthorizationHandler<UserResourceRequirement>
{
    readonly IRoleResourceService _roleResourceService;

    public UserResourceHandler (IRoleResourceService r)
    {
        _roleResourceService = r;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext authHandlerContext, UserResourceRequirement requirement)
    {
        if (context.Resource is AuthorizationFilterContext filterContext)
        {
            var area = (filterContext.RouteData.Values["area"] as string)?.ToLower();
            var controller = (filterContext.RouteData.Values["controller"] as string)?.ToLower();
            var action = (filterContext.RouteData.Values["action"] as string)?.ToLower();
            var id = (filterContext.RouteData.Values["id"] as string)?.ToLower();
            if (_roleResourceService.IsAuthorize(area, controller, action, id))
            {
                context.Succeed(requirement);
            }               
        }            
    }
}

Accessing the parameters received by endpoint is achieved by casting context.Resource to AuthorizationFilterContext, so that we could access RouteData from it. As for UserResourceRequirement, we can leave it empty.

public class UserResourceRequirement : IAuthorizationRequirement { }

As for the IRoleResourceService, it's a plain service class so that we can inject anything to it. This service is the substitute of pairing a Role to an action in code so that we don't need to specify it in action's attribute. That way, we can have a freedom to choose the implementation, ex: from database, from config file, or hard-coded.

Accessing user in RoleResourceService is achieved by injecting IHttpContextAccessor. Please note that to make IHttpContextAccessor injectable, add services.AddHttpContextAccessor() in Startup.ConfigurationServices method body.

Here's an example getting the info from config file:

public class RoleResourceService : IRoleResourceService
{
    readonly IConfiguration _config;
    readonly IHttpContextAccessor _accessor;
    readonly UserManager<AppUser> _userManager;

    public class RoleResourceService(IConfiguration c, IHttpContextAccessor a, UserManager<AppUser> u) 
    {
        _config = c;
        _accessor = a;
        _userManager = u;
    }

    public bool IsAuthorize(string area, string controller, string action, string id)
    {
        var roleConfig = _config.GetValue<string>($"RoleSetting:{area}:{controller}:{action}"); //assuming we have the setting in appsettings.json
        var appUser = await _userManager.GetUserAsync(_accessor.HttpContext.User);
        var userRoles = await _userManager.GetRolesAsync(appUser);
        // all of needed data are available now, do the logic of authorization
        return result;
    } 
}

Get the setting from database surely is a bit more complex, but it can be done since we can inject AppDbContext. For the hardcoded approach, exist plenty ways to do it.

After all is done, use the policy on an action:

[Authorize(Policy = "UserResource")] //dont need Role name because of the RoleResourceService
public ActionResult<IActionResult> GetSomething(int resourceId)
{
    //existing code
}

In fact, we can use "UserResource" policy for any action that we want to apply.

like image 37
Riza Avatar answered Nov 12 '22 12:11

Riza


You could create your own attribute which will check the user's role. I have done this in one of my applications:

public sealed class RoleValidator : Attribute, IAuthorizationFilter
{
    private readonly IEnumerable<string> _roles;

    public RoleValidator(params string[] roles) => _roles = roles;

    public RoleValidator(string role) => _roles = new List<string> { role };

    public void OnAuthorization(AuthorizationFilterContext filterContext)
    {
        if (filterContext.HttpContext.User.Claims == null || filterContext.HttpContext.User.Claims?.Count() <= 0)
        {
            filterContext.Result = new UnauthorizedResult();
            return;
        }

        if (CheckUserRoles(filterContext.HttpContext.User.Claims))
            return;

        filterContext.Result = new ForbidResult();
    }

    private bool CheckUserRoles(IEnumerable<Claim> claims) =>
        JsonConvert.DeserializeObject<List<RoleDto>>(claims.FirstOrDefault(x => x.Type.Equals(ClaimType.Roles.ToString()))?.Value)
            .Any(x => _roles.Contains(x.Name));
}

It gets user role from claims and check is user have proper role to get this resouce. You can use it like this:

[RoleValidator("Admin")]

or better approach with enum:

[RoleValidator(RoleType.Admin)]

or you can pass a multiple roles:

[RoleValidator(RoleType.User, RoleType.Admin)]

With this solution you must also use the standard Authorize attribute.

like image 3
Beniamin Makal Avatar answered Nov 12 '22 12:11

Beniamin Makal


You can use Roles if you are using identity. Simply call authorize and provide it with the Role name on a resource or a whole controller or even add more roles to the authorize like below:

[Authorize(Roles ="Clerk")]

I am authorizing a User Role with a name of Clerk on a certain resource. To add more roles simply add a comma after the clerk and add the other role name

like image 1
pistone sanjama Avatar answered Nov 12 '22 12:11

pistone sanjama