简体   繁体   中英

ASP.NET Core 3.1 Web Api authorization on model property level

I have a web api with basic jwt authentication and role based authorization. Now I want to restrict certain fields from being edited by users that are in the role user, because the route based authorization is not enough.

class Account {
    public int Id {get; set;}
    public string Email {get; set;}
    public string Password {get; set;}
    public bool Enabled {get; set;} // <- this field should only be editable by an admin or manager
    public int RoleId {get; set;} // <- this field should only be editable by an admin
}

When the user is in the role user he is only allowed to change his email address and his password, but only for his account. When he is in the role manager he should be able to edit the fields email, password and enabled but only for accounts that are in the user role. An admin can edit every field from every user.

Is there anything that would solve my problem, for example something like this:

class Account {
    public int Id {get; set;}
    public string Email {get; set;}
    public string Password {get; set;}

    [Authorize(Roles = "Admin,Manager")]
    public bool Enabled {get; set;} // <- this field should only be editable by an admin or manager

    [Authorize(Roles = "Admin")]
    public int RoleId {get; set;} // <- this field should only be editable by an admin
}

More infos about my project: - ASP.NET Core 3.1 - I use Entity Framework Core with a Postgres database - For authentication I use basic jwt bearer authentication

So, I think you has incorrect understanding of Authtorize working.

This attribute uses for Controllers. You can create multiple controllers and set for each method different ROLES to specify what Roles can call this method.

It's not correct to specify it on Dto (Data Transfer Objects) classes.

But you can make some interesting solution with 2 controllers and inheritance.

//Account dto for edit
class AccountEditDto {
    public int Id {get; set;}
    public string Email {get; set;}
    public string Password {get; set;}
}

//Controller to edit account
[Route("all/account_controller")]
public class AccountController : Controller
{
    
    public ActionResult EditAccount(AccountEditDto accountDto)
    {
        //do something
    }
}

Then for create manager roles setup something like this:

//Account dto for edit
class AccountManagerEditDto : AccountEditDto {
    public bool Enabled {get; set;} 
}

//Controller admin to edit account
[Area("Manager")]
[Route("manager/account_controller")]
public class AccountManagerController : AccountController
{
    [Authorize(Roles = "Manager")]
    public ActionResult EditAccount(AccountManagerEditDto accountDto)
    {
        //Do something
    }
}

Then for create admin roles setup something like this:

//Account dto for edit
class AccountAdminEditDto : AccountManagerEditDto {
    public int RoleId {get; set;} 
}

//Controller admin to edit account
[Area("Admin")]
[Route("admin/account_controller")]
public class AccountAdminController : AccountController
{
    [Authorize(Roles = "Admin")]
    public ActionResult EditAccount(AccountAdminEditDto accountDtp)
    {
        //Do something
    }
}

Then you can use than pattern of URL for call controller methods:

http://localhost/{role}/accont_controller/edit

jwt is token based, meaning that every user has it's unique token which he uses in order to access your system.

The easiest way for you to achieve this is to decode the token and check for the roles, thus enabling you to set role specific action for users.

You will not be able to do this via attributes because jwt does not support them. There is a good guide to walk you through it as it will be too long for an answer here:

Role based JWT tokens

It requires basic understanding of how tokens work and the guide provides brief explanation.

Although I'm not really Fan of this answer but you might you need this answer duo to a internal policy on your workplace.

Disclaimer:: this question is treated as business rule validation because there is many ways technically to sperate them in a way that make since.

For me I like the First answer which made a very good segregation of the Apis....

Although this is the think you are looking for you MUST keep them in one api: :

    [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)]
public class AuthorizeEdit : ValidationAttribute
{
    private readonly string _role;
    private IHttpContextAccessor _httpContextAccessor;
    public AuthorizeEdit(string role) : base()
    {
        _role = role;
    }
    public override bool IsValid(object value)
    {
        return _httpContextAccessor.HttpContext.User?.IsInRole(_role) ?? false;
    }
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {

        _httpContextAccessor = (IHttpContextAccessor)validationContext
                   .GetService(typeof(IHttpContextAccessor));

        return base.IsValid(value, validationContext);

    }

}

and you can use it like this:

class Account {
public int Id {get; set;}
public string Email {get; set;}
public string Password {get; set;}


public bool Enabled {get; set;} // <- this field should only be editable by an admin or manager

[Authorize(Roles = "Admin")]
public int RoleId {get; set;} // <- this field should only be editable by an admin

}

THE DOWNSIDE: Any one whos not in role in the validator will not be able to change any failed what so ever

My recommendation: separate your Apis like the first answer suggest then add this filter to the admin api

Note you can change the filter to take as roles as u want

Unfortunately, this is not possible as it isn't something built-in to ASP.NET Core 3.1.

However, why not carry out your logic in the handler?

You can either create multiple endpoints for users (which I wouldn't recommend) or as per common convention, use 1 route and just validate the data based on the user's role before processing the data.

Get the current account, check to see what has changed and if the user has changed a property which they should have no permission to change, return HTTP 403 Forbidden without further processing their request.

If they have the right role for the action, continue as normal.

You can inherit AuthorizeAttribute and write your own like so:

public class myAuthorizationAttribute : AuthorizeAttribute
{
    protected override bool IsAuthorized(HttpActionContext actionContext)
    {
        // do any stuff here
        // it will be invoked when the decorated method is called
        if (CheckAuthorization(actionContext)) 
           return true; // authorized
        else
           return false; // not authorized
    }

    private bool CheckAuthorization(HttpActionContext actionContext)
    {
        bool isAuthorized = ... // determine here if user is authorized
        if (!isAuthorized) return false;

        var controller = (myController)actionContext.ControllerContext;

        // add those boolean properties to myController
        controller.isEnabledReadOnly = ... // determine here if user has the role
        controller.isRoleIdReadOnly  = ... // determine here if user has the role
        return true;
    }

}

In the function CheckAuthorization you can check roles available and then set flags in your code to decide whether the related fields should be allowed written to or not.

It can be simply used on any method like:

[myAuthorization]
public HttpResponseMessage Post(string id)
{
    // ... your code goes here
    response = new HttpResponseMessage(HttpStatusCode.OK); // return OK status
    return response;
}

Since you've added the properties isEnabledReadOnly and isRoleIdReadOnly to your controller, you can directly access them in any (Get, Post, ...) method.

Note: You can use the actionContext to access Request, Response, ControllerContext etc (see here ).

More information about the internals (how this works) can be found here.

You can do in an action. You sould code like this.

public class AccountController : Controller
{
    [HttpPost("EditProfile")]
    public async Task<IActionResult> EditProfile(User user)
    {
        var fields = new List<string>();
        fields.Add("Id");
        fields.Add("Email");
        fields.Add("Password");

        if (User.IsInRole("Admin")){
           fields.Add("RoleId");
           fields.Add("Enabled ");
        }else if(User.IsInRole("Manager"))){
           fields.Add("Enabled ");
        }

         var updateUser = context.Entry(user);
            
            foreach (var field in fields)
            {
                updateUser.Property(field).IsModified = true;
            }

            await context.SaveChangesAsync();
        
    }

    
}

Well, I would personally suggest using @TemaTre's answer but since you ask. I could mention another possibility other than using a custom serializer. Which is to use a custom model binder.

You need a few basic steps

  1. Define a custom attribute
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
public sealed class AuthorizePropertyAttribute : Attribute
{
  readonly string roles;

   public AuthorizePropertyAttribute(string roles)
   {
      this.roles = roles;
   }

   public string Roles
   {
       get { return roles; }
   }
}
  1. Annotate your model with that attribute
public class Account
{
    public int Id { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }

    [AuthorizeProperty("Admin,Manager")]
    public bool Enabled { get; set; } // <- this field should only be editable by an admin or manager

    [AuthorizeProperty("Admin")]
    public int RoleId { get; set; } // <- this field should only be editable by an admin
}
  1. Define a custom ModelBinder
public class AuthorizedModelBinder : IModelBinder
{
   public async Task BindModelAsync(ModelBindingContext bindingContext)
   {
      using var reader = new StreamReader(bindingContext.HttpContext.Request.Body); 
      var body = await reader.ReadToEndAsync();
      var jObject = JsonConvert.DeserializeObject(body, bindingContext.ModelType); // get the posted object
      var modelType = bindingContext.ModelType;
      var newObject = Activator.CreateInstance(modelType); // this is for demo purpose you can get it in a way you want (like reading it from db)
      var properties = modelType.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
      var user = bindingContext.HttpContext.User; // this is also for demo purpose only

       foreach (var prop in properties)
       {
          var auth = prop
                    .GetCustomAttributes(typeof(AuthorizePropertyAttribute), true)
                    .OfType<AuthorizePropertyAttribute>().FirstOrDefault(); // check the property has that auth attribute
           if (auth == null)
           {
               prop.SetValue(newObject, prop.GetValue(jObject)); // if not assign that property
           }
           else
           {
               var isInRole = auth.Roles.Split(",", StringSplitOptions.RemoveEmptyEntries).Any(user.IsInRole);
               if (isInRole) // this guy has access
               {
                   prop.SetValue(newObject, prop.GetValue(jObject));
               }
            }
        }
    }
}

And finally, if you want every Account object to go through this model binder you can annotate your class like this:

[ModelBinder(typeof(AuthorizedModelBinder))]
public class Account ...

Or you can specify it on the Action you want to use like this:

public IActionResult Sup([ModelBinder(typeof(AuthorizedModelBinder))]Account ACC)...

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM