tags:

views:

498

answers:

4

I've seen a great answer to a similar question which explains, by inheriting all controllers from a new base class decorated with your own ActionFilter attribute, how you could apply some logic to all requests to your site.

I'd like to find a way to do that based on the area of a site my user is visiting.

For example, I will have a Product controller with a View action but I want to allow that to be used for the two following urls:

/Product/View/321 - display product id 321 to 'normal' users /Admin/Product/View/321 - use the same View controller but spit out extra functionality for my admin users.

I could pass "admin" in as a parameter named "user" into my view action on my product controller to show extra information for administrators, a method for doing that is shown here. But what I'd then need to do is confirm my user was allowed to view that url. I don't want to decorate my Product controller with an ActionAttribute that checks for authentication because when unauthenticated users (and logged in administrators) view it at /Product/View/321, I want them all to see the standard view.

So what I'd like to do, is described below in pseudo-code:

When a url in the format "{userlevel}/{controller}/{action}/{id}" is called, I'd like like to call another controller that does the authentication check and then 'chain' to the original {controller} and pass through the {action}, {id} and {userlevel} properties.

How would I do that?

(I know that the over-head for doing a check on every call to the controller is probably minimal. I want to do it this way because I might later need to do some more expensive things in addition to user authentication checks and I'd prefer to only ever run that code for the low-traffic admin areas of my site. There seems no point to do these for every public user of the site)

+1  A: 

1) Can't you just check for the role within the view?

<% if (HttpContext.Current.User.IsInRole ("Administrator")) { %>
  // insert some admin specific stuff here
  <%= model.ExtraStuff %>
% } %>

You can perform the same check in the controller if you need to set admin specific view model properties. In your controller you can do your extra processing only when the user is already authenticated:

public ActionResult Details (int productId)
{
  ProductViewModel model = new ProductViewModel ();

  if (User.Identity.IsAuthenticated && User.IsInRole ("Administrator"))
  {
    // do extra admin processing
    model.ExtraStuff = "stuff";
  }

  // now fill in the non-admin specific details
  model.ProductName = "gizmo";

  return View (model);
}

The only thing missing here is a redirect to your login page when an admin tries to access the view without being authenticated.

2) Alternatively if you want to reuse your default product view with some extra bits you could try the following:

public class AdminController
{
  [Authorize(Roles = Roles.Admin)]
  public ActionResult Details(int productId)
  {
    ProductController productController = new ProductController(/*dependencies*/);

    ProductViewModel model = new ProductViewModel();
    // set admin specific bits in the model here
    model.ExtraStuff = "stuff";
    model.IsAdmin = true;

    return productController.Details(productId, model);
  }
}

public class ProductController
{
  public ActionResult Details(int productId, ProductViewModel model)
  {
    if (model == null)
    {
        model = new ProductViewModel();      
    }

    // set product bits in the model

    return Details(model);
  }
}

NOTE: I would prefer solution 1) over 2) due to the fact that you need to create a new instance of ProductController and that brings up it's own set of issues especially when using IoC.

Todd Smith
From Question: "I want to do it this way because I might later need to do some more expensive things in addition to user authentication checks " - What your basically saying is do those expensive checks on every page anyway.
jfar
Thanks jfar :-)I think the question seems a lot simpler at first glance but there's specifics I want people to consider.
Neil Trodden
You can perform the same check in the controller and avoid the extra processing when not an administrator. Just leave a few fields in your view model null and only use them when an admin accesses the view.
Todd Smith
Again Todd, it runs that code when I access that controller from /Products/View and /Admin/Products/View. Accessing via the latter *must* call a controller (or some logic) that enforces validation before then passing execution to Products. Calls to the former url must not even call this extra logic.
Neil Trodden
@Neil I understand what you're asking. I'm simply proposing a different solution, which I've seen in several other applications, which solves the same problem. I added another solution closer to what you want but it has a few issues which is why I was susgesting a different approach.
Todd Smith
Todd, I haven't had the time to look at this much - MVC is a side project for me when I'm not doing my paid-for development.I think your answer is good, but my question was rather specific in that it didn't leave much room for alternatives! I know I could have came up with other ways of doing it but my reasons above are why I rejected them. Still, as this is the only answer and considering the effort you took (and the fact it is a reasonable suggestion that will help others), I'll vote it up. I'll post my solution in the end, whatever it is! I have some ideas based on the links on my OP.
Neil Trodden
..no space on comments! Thanks for your time with this, much appreciated.
Neil Trodden
I'm with Todd partially for the option of putting the checks in the page - not like the given example, but in helpers. In a round trip to the server the same checks will be made whether you do them in the controllers or the Html helpers. In your case, using the Membership provider, multiple calls to essentially the same thing will be cached so you dont need to worry about multiple checks being 'expensive'.As for the routing thing - return a RedirectToRouteResult - this should give you space for everything you need to construct your new route if you -must- use a different admin view. peace
cottsak
+1  A: 

You can solve this fairly easily by creating a base controller class which checks the user level in OnActionExecuting and, if authorized, sets a Role property to the same value and adds a "Role" entry to ViewData for use in the view. You can use this as a base class for all of your controllers and they will all have access to the Role property and all your views will have a "Role" entry added to ViewData:

public abstract class BaseController : Controller
{
    public string Role { get; protected set; }

    protected override void OnActionExecuting( ActionExecutingContext filterContext )
    {
     base.OnActionExecuting( filterContext );
     Role = string.Empty;
     string role = string.Empty;
     object value;
     if ( filterContext.RouteData.Values.TryGetValue( "role", out value ) )
      role = value as string ?? string.Empty;
     if ( filterContext.HttpContext.User.IsInRole( role ) )
      Role = role.ToLowerInvariant();
     ViewData[ "role" ] = Role;
    }
}

Change the default route in Global.asax.cs:

routes.MapRoute(
 "Default",                                              
 "{role}/{controller}/{action}/{id}",                           
 new { role = "", controller = "Home", action = "Index", id = "" }  
);

Now, in your controller actions, check the Role property for e.g. "admin" and, if so, add any necessary view data for the admin functions.

Render your admin UI using partials and in your view, check the role and call RenderPartial:

<% if ( Equals( ViewData[ "role" ], "admin" ) )
        Html.RenderPartial( "_AdminFunctions" ); %>
<p>
    This is the standard, non Admin interface...
</p>
Mike Scott
+2  A: 

At first I thought this might be as simple as adding a new route like this:

routes.MapRoute(
    "Admin",
    "Admin/{*pathInfo}",
    new { controller="Admin", action="Index", pathInfo="" }
    );

and then have a controller something like this:

public class AdminController : Controller
{
    public ActionResult Index(string pathInfo)
    {
     //Do admin checks, etc here....
     return Redirect("/" + pathInfo);
    }
}

However, unfortunately all the options you have available in order to do the redirect (i.e. Redirect, RedirectToAction & RedirectToRoute) all do a 302 style redirect. Basically this means that your /Admin/Product/Whatever will execute & then bounce back to the browser telling it to redirect to /Product/Whatever in a totally new request, which means you've lost your context. I don't know of a clean way of keeping the redirect server side (i.e. like a Server.Transfer of old), apparently neither does the SO community...

(obviously, this is a non-solution, since it doesn't solve your problem, but I thought I'd put it here anyway, in case you could use the ideas in some other way)


So, what's an actual solution to the problem then? Another idea is to use an ActionFilter (yes I know you said you didn't want to do so, but I think the following will serve your purposes). Add a new route like this:

routes.MapRoute(
    "Admin",
    "Admin/{controller}/{action}/{id}",
    new { controller = "Home", action = "Index", id = "", userLevel = "Admin" }
    );

and then add an ActionFilter like this (that you could apply to all requests via a base controller object as you mentioned):

public class ExtendedAdminViewAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
     object userLevel = filterContext.RouteData.Values["userLevel"];
     if (userLevel != null && userLevel.ToString() == "Admin")
     {
      //Do your security auth checks to ensure they really are an admin
      //Then do your extra admin logic...
     }
    }
}

So although it is using an ActionFilter that will apply to all requests, the only extra work done in most normal cases (i.e. a request for /Product/Whatever), is a single check of that bit of route data (userLevel). In other words, you should really see a performance hit for normal users since you're only doing the full auth check and extra admin work if they requested via /Admin/Product/Whatever.

Alconja
Very well deserved. I know I was rather specific in my question but I'm happy with this answer, it seems like a nice solution. Thanks, Alconja!
Neil Trodden
Cheers, Neil, I answered this in essentially the same way using a base controller and overriding OnActionExecuting but with more detail and case invariance etc. a day before Alconja.
Mike Scott
Alconja
A: 

This is an "outside the box" answer:

What about leveraging the policy injection block in entLib? With that you could create a policy that would run a "pre-method" on your action. Your pre-method could perhaps handle your problem.

nikmd23