Granular permissions with certain requirements for

2019-02-03 17:10发布

问题:

I don't like the built in membership providers. I've decided to roll my own. I'm trying to come up with a good method for performing authorization at the action level. Here are the requirements that I'm trying to go by:

  1. Attribute usage - I like this since it controls at a very high level in the call stack and is a nice place to organize permissions.
  2. No magic strings - This is a reason why I'm straying away from the current role providers. I don't want to leave strings lying around that can't be easily renamed.
  3. Permissions should can be composed of one other permission. Example: ReadWrite has permission for Read. Just like or'ing with an enum.

NOTE: Some think this set of requirements is too broad (see comments). I don't think so, I think they're fairly straightforward.

The biggest showstopper is attribute usage. There can only be "constant expressions, typeof expressions or array creation expression of an attribute parameter type".

I was thinking of perhaps having something like this to make operations have static access. Inside of the attribute, it would "convert" the int to the actual Permission or something...:

public static class Operations
{
    public static class SectionA
    {
        public const int Read = 1;
        public const int ReadWrite = 2;
    }

    public static class SectionB
    {
        // ... and so on...
    }
}

But it really limits composition. I'm sure you're thinking "why don't you go the enum route?" well I want to plan for things to change and don't want to limit to 32 (int) or 64 (long) operations and have to do a massive rewrite later (also in the db that's just ugly).

Also, if there is a better alternative than attributes on actions/controllers, then I'm all ears for suggestions.

EDIT: Also from this post, I've read about the BitArray class. It seems kind of ugly, especially with the arbitrary storage in the database.

回答1:

First of all, I have to thank you for sucking me into answering this ;)

This is a long answer, and is only a starting point. You have to figure out how to assign roles to users and how to recreate them in the AuthenticateRequest.

If this does not answer your question, I hope it will be an inspiration. Enjoy!

Decorate the controller actions

I started to decorate the two actions in the default HomeController:

    [AuthorizeRoles(Role.Read)]
    public ActionResult Index()
    {
        ViewData["Message"] = "Welcome to ASP.NET MVC!";

        return View();
    }

    [AuthorizeRoles(Role.Write)]
    public ActionResult About()
    {
        return View();
    }

All users in the ReadWrite role should then be granted access. I opted here to use an enum as a type safe placeholder for the magic strings. The role of this enum is nothing else than being a placeholder. There are no composite enum values, that has to be maintained somewhere else. More on that later.

public enum Role
{
    Read,
    Write,
    ReadWrite
}

Implement a new authorization attribute

Since the strings are gone, I need a new authorize attribute:

public class AuthorizeRolesAttribute : AuthorizeAttribute
{
    private readonly RoleSet authorizedRoles;

    public AuthorizeRolesAttribute(params Role[] roles)
    {
        authorizedRoles = new RoleSet(roles);
    }

    protected override bool AuthorizeCore(HttpContextBase httpContext)
    {
        return authorizedRoles.Includes(httpContext.User);
    }
}

The RoleSet wraps a set of enum values and verifies if an IPrincipal is a member of one of them:

public class RoleSet
{
    public RoleSet(IEnumerable<Role> roles)
    {
        Names = roles.Select(role => role.ToString());
    }

    public bool Includes(IPrincipal user)
    {
        return Names.Any(user.IsInRole);
    }

    public bool Includes(string role)
    {
        return Names.Contains(role);
    }

    public IEnumerable<string> Names { get; private set; }
}

Maintain roles

The CompositeRoleSet is where composite roles are registered and handled. CreateDefault() is where all composites are registered. Resolve() will take a list of roles (enum values) and convert the composites to their single counterparts.

public class CompositeRoleSet
{
    public static CompositeRoleSet CreateDefault()
    {
        var set = new CompositeRoleSet();
        set.Register(Role.ReadWrite, Role.Read, Role.Write);
        return set;
    }

    private readonly Dictionary<Role, Role[]> compositeRoles = new Dictionary<Role, Role[]>();

    private void Register(Role composite, params Role[] contains)
    {
        compositeRoles.Add(composite, contains);
    }

    public RoleSet Resolve(params Role[] roles)
    {
        return new RoleSet(roles.SelectMany(Resolve));
    }

    private IEnumerable<Role> Resolve(Role role)
    {
        Role[] roles;
        if (compositeRoles.TryGetValue(role, out roles) == false)
        {
            roles = new[] {role};
        }

        return roles;
    }
}

Wiring it up

We need an authenticated user to work on. I cheated and hard-coded one in global.asax:

    public MvcApplication()
    {
        AuthenticateRequest += OnAuthenticateRequest;
    }

    private void OnAuthenticateRequest(object sender, EventArgs eventArgs)
    {
        var allRoles = CompositeRoleSet.CreateDefault();
        var roles = allRoles.Resolve(Role.ReadWrite);
        Context.User = new ApplicationUser(roles);
    }

Finally, we need an IPrincipal which understand all this:

public class ApplicationUser : IPrincipal
{
    private readonly RoleSet roles;

    public ApplicationUser(RoleSet roles)
    {
        this.roles = roles;
    }

    public bool IsInRole(string role)
    {
        return roles.Includes(role);
    }

    public IIdentity Identity
    {
        get { return new GenericIdentity("User"); }
    }
}


回答2:

Seems like you want something very flexible and dependless of what can be demanded for security check. So, it depends on "how far are you ready to go".

To help make this way be a right direction I strongly recommend you to look to the side of Claims-based Access Control. And take this article as a starting point and ASP.NET MVC example.

But remember that it is a complex topic. Very flexible (even allowing Federated Access Control without any code changes) but complex.

We had to go this way to make our apps completely unavailable of those "right checking" implementations. All our systems know is what "claim" they need to perform certain action and asks for it based on provided user identity (which is a "claim" too). Roles, permissions and other claims can be easily "translated" to those App-specific "claims" that make sense for our Apps. Full flexibility.

P.S. It doesn't solve the technical problems of "magic strings" and alike (you have to think that depend on your situation) but gives you very flexible access control architecture.



回答3:

So @thomas seems to have a nice answer, buts its more wrapping your requirement of using enums, taking that into Roles that IPricipal will understand. My solution is from bottom up, so you can use thomas' solution on top of mine to implement IPrincipal

I really needed something similar to what you want and was always scared with Forms Authentication, (yes you're scared too and I know it, but hear me out) So I always rolled out my own cheap authentication with forms, but a lot of things changed while I was learning mvc (in the last couple weeks) Forms auth is very dis separate and its very flexible. Essentially you're not really using forms auth, but your just plugging your own logic into the system.

So here is how I tackled this, (beware I am a learner myself).

Summary:

  1. You're going to override some of the forms auth classes to authenticate you own users, (you can even mock this)
  2. You're then going to create an IIdentity.
  3. You load up GenericPrincipal with a list of roles in strings (I know, no magic strings...keep reading)

Once you do the above, MVC understands enough to give you what you want! You can now use [Authorize(Roles = "Write,Read")] over any controller and MVC will do almost everything. Now for no magic strings, all you have to do it create a wrapper around that attribute.


Long answer

You use the Internet Application Template that comes with MVC, So first you begin by creating MVC project, in the new dialog, say you want an Internet Application.

When you check the application, it will have one main class that overrides forms authentication. IMembershipService Remove the local MembershipProvider variable __provider_ and in this class you should atleast add logic into the ValidateUser Method. (Try adding a fake authentication to one user/pass) Also see the default v test application created in VS.

Implement IIdentity

    public class MyIdentity : IIdentity
    {
            public MyIdentity(string username)
            {
               _username = username;//auth from the DB here.
               //load up the Roles from db or whatever
            }
            string _username;
            public User UserData { get; set; }
            #region IIdentity Members
            public string AuthenticationType
            {
                get { return "MyOwn.Authentication"; }
            }
            public bool IsAuthenticated
            {
                get { return true; }
            }
            public string Name
            {
                get { return _username; }
            }
            #endregion
            public string[] Roles
            {
                get
                {
                    return //get a list of roles as strings from your Db or something.
                }
            }
    }

Remember we're still using the Default Internet Application template that comes with an MVC project.

So now AccountController.LogOn() should look like this:

 [HttpPost]
 public virtual ActionResult LogOn(LogOnModel model, string returnUrl)
 {
    if (ModelState.IsValid)
    {
       if (MembershipService.ValidateUser(model.UserName, model.Password))
       {
          FormsService.SignIn(model.UserName, model.RememberMe);
          FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(model.UserName, model.RememberMe, 15);
          string encTicket = FormsAuthentication.Encrypt(ticket);
          this.Response.Cookies.Add(new HttpCookie(FormsAuthentication.FormsCookieName, encTicket));

                    if (Url.IsLocalUrl(returnUrl))
                    {
                        return Redirect(returnUrl);
                    }
                    else
                    {
                        return RedirectToAction("Index", "Home");
                    }
                }
                else
                {
                    ModelState.AddModelError("", "The user name or password provided is incorrect.");
                }
            }

So what you're doing is setting a forms ticket like a session and then we'll read from it on every request like this: Put this in Global.asax.cs

public override void Init()
      {
         this.PostAuthenticateRequest += new EventHandler(MvcApplication_PostAuthenticateRequest);
         base.Init();
      }

void MvcApplication_PostAuthenticateRequest(object sender, EventArgs e)
      {
          HttpCookie authCookie = HttpContext.Current.Request.Cookies[FormsAuthentication.FormsCookieName];
          if (authCookie != null)
          {
              string encTicket = authCookie.Value;
                 if (!String.IsNullOrEmpty(encTicket))
                 {
                        FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(encTicket);
                        MyIdentity id = new MyIdentity(ticket.Name);
                        //HERE is where the magic happens!!
                        GenericPrincipal prin = new GenericPrincipal(id, id.Roles);
                        HttpContext.Current.User = prin;
                 }
           }
      }

I asked a question one how efficient and correct the above method was here.

Ok now you are almost done, you can decorate your controllers like this: [Authorize(Roles="RoleA,RoleB")] (more on the strings later)

Theres one small problem here, if you decorate your controller with AuthorizeAttribute, and the logged user does not have a particular permission, instead of saying "access denied" by default the user will be re directed to the login page to login again. You fix this like this(I tweaked this from an SO answer):

public class RoleAuthorizeAttribute : AuthorizeAttribute
    {
        protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
        {
            // Returns HTTP 401 
            // If user is not logged in prompt
            if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
            {
                base.HandleUnauthorizedRequest(filterContext);
            }
            // Otherwise deny access
            else
            {
                filterContext.Result = new RedirectToRouteResult(@"Default", new RouteValueDictionary{
                {"controller","Account"},
                {"action","NotAuthorized"}
                });
            }
        }
    }

Now all you do is add another wrapper around the AuthorizeAttribute to support strong types that will translate into the strings that Principal expects. See this article for more.

I plan to update my application to use strong types later, I'll update this answer then.

I hope it helped.