基于输入参数/服务层的ASP.NET MVC访问控制?

41

前言:这是一个有些哲学意味的问题。我更关心的是“正确”的方法,而不是“一种”方法。

假设我有一些产品,并且有一个ASP.NET MVC应用程序来执行对这些产品的CRUD操作:

mysite.example/products/1
mysite.example/products/1/edit

我正在使用仓储模式,因此这些产品来自哪里并不重要:

public interface IProductRepository
{
  IEnumberable<Product> GetProducts();
  ....
}
此仓库描述了一个用户列表,以及他们管理的产品(用户和产品之间存在多对多的关系)。在应用程序的其他地方,超级管理员对用户执行CRUD操作,并管理他们被允许管理的产品之间的关系。
任何人都可以查看任何产品,但只有被指定为特定产品“管理员”的用户才可以调用例如编辑操作。
我应该如何在ASP.NET MVC中实现这一点?除非我错过了什么,否则我不能使用内置的ASP.NET Authorize属性,因为首先我需要为每个产品设置不同的角色,其次我不知道要检查哪个角色,直到从存储库中检索到我的产品。
显然,您可以从此场景推广到大多数内容管理场景-例如,只允许用户编辑自己的论坛帖子。 StackOverflow用户只能编辑自己的问题-除非他们拥有2000或更多的声望...
例如,最简单的解决方案是:
public class ProductsController
{
  public ActionResult Edit(int id)
  {
    Product p = ProductRepository.GetProductById(id);
    User u = UserService.GetUser(); // Gets the currently logged in user
    if (ProductAdminService.UserIsAdminForProduct(u, p))
    {
      return View(p);
    }
    else
    {
      return RedirectToAction("AccessDenied");
    }
  }
}

我的问题:

  • 部分代码需要重复使用 - 想象一下,有几个操作(Update、Delete、SetStock、Order、CreateOffer)取决于用户和产品的关系。你需要多次复制粘贴。
  • 不太容易测试 - 你必须模拟每个测试中的四个对象。
  • 控制器似乎并不需要检查用户是否允许执行操作。我更喜欢一个更加可插拔(例如通过属性的AOP)的解决方案。然而,这是否意味着您必须两次SELECT产品(在AuthorizationFilter和Controller中各一次)?
  • 如果用户不被允许进行此请求,是否最好返回403?如果是这样,我该如何做呢?

我可能会随着自己的想法而更新,但我非常渴望听到你的想法!

提前致谢!

编辑

只是在这里添加一些细节。我遇到的问题是,我希望业务规则“只有具有权限的用户才能编辑产品”仅包含在一个地方。我觉得决定用户是否可以获取或POST到Edit操作的相同代码也应该负责确定是否在Index或Details视图上呈现“Edit”链接。也许这是不可能的/不可行的,但我觉得应该这样...

第二次编辑

在这个问题上开始了一项悬赏。我收到了一些好的和有帮助的答案,但没有什么让我感到舒服“接受”。请记住,我正在寻找一个漂亮干净的方法来使决定Index视图上是否显示“Edit”链接的业务逻辑与决定是否授权对Products/Edit/1进行请求的位置相同。我希望将操作方法中的污染降到最低。理想情况下,我正在寻找一个基于属性的解决方案,但我知道这可能是不可能的。

8个回答

29
首先,我认为你已经有了一半的解决方案,因为你说过:

首先,我需要为每个产品分配不同的角色;其次,在从存储库中检索到我的产品之前,我不知道要检查哪个角色。

我看到过许多试图使基于角色的安全性能够做一些它从未被设计用来做的事情,但你已经过了那个点,所以很酷 :)
基于角色的安全性的替代方案是基于ACL(访问控制列表)的安全性,我认为这正是你需要的。
你仍然需要检索产品的ACL,然后检查用户是否具有产品的正确权限。这是如此上下文敏感和交互密集,以至于我认为纯声明式方法既过于死板又过于含蓄(也就是说,您可能没有意识到在向某些代码添加单个属性时涉及多少数据库读取)。
我认为像这样的场景最好由一个封装ACL逻辑的类来建模,使您可以根据当前上下文查询决策或进行断言 - 就像这样:
var p = this.ProductRepository.GetProductById(id);
var user = this.GetUser();
var permission = new ProductEditPermission(p);

如果您只想知道用户是否可以编辑产品,则可以发出查询:

bool canEdit = permission.IsGrantedTo(user);

如果你只想确保用户有权继续,你可以发出一个 Assertion:

permission.Demand(user);

如果没有被授权,则应引发异常。

这一切都假设Product类(变量p)有一个关联的ACL,就像这样:

public class Product
{
    public IEnumerable<ProductAccessRule> AccessRules { get; }

    // other members...
}

您可能希望查看System.Security.AccessControl.FileSystemSecurity,以获取有关建模ACL的灵感。

如果当前用户与Thread.CurrentPrincipal相同(在ASP.NET MVC中是这种情况,如果我没记错),则可以将上述权限方法简化为:

bool canEdit = permission.IsGranted();
或者
permission.Demand();

因为用户是隐式的。你可以查看System.Security.Permissions.PrincipalPermission来获得灵感。


26
你有没有或者知道一个在MVC应用中使用ACL-based安全的例子? - Jiho Han
为什么我们需要拥有一个列表?我也面临同样的困境,但我不想存储整个可以编辑产品的用户列表。我只想快速查看(为了视图和服务层方法),用户是否可以编辑产品。比较简单,就是“用户租户是否拥有该产品”。为什么我们需要存储整个列表呢? - Worthy7

16
从您所描述的情况来看,似乎您需要一些形式的用户访问控制,而不是基于角色的权限。如果是这种情况,则需要在您的业务逻辑中实现它。根据您的场景,您可以在服务层中实现它。
基本上,您必须从当前用户的角度实现ProductRepository中的所有函数,并为该用户标记具有相应权限的产品。
听起来比实际上要困难。首先,您需要一个用户令牌接口,其中包含uid和角色列表(如果想使用角色)。您可以使用IPrincipal或创建类似的自己的接口。
public interface IUserToken {
  public int Uid { get; }
  public bool IsInRole(string role);
}

然后在你的控制器中,将用户令牌解析到你的Repository构造函数中。

IProductRepository ProductRepository = new ProductRepository(User);  //using IPrincipal

如果您正在使用FormsAuthentication和自定义的IUserToken,则可以创建一个IPrincipal的包装器,使得您的ProductRepository被创建为:

IProductRepository ProductRepository = new ProductRepository(new IUserTokenWrapper(User));

现在,您的所有IProductRepository功能都应访问用户令牌以检查权限。例如:

public Product GetProductById(productId) {
  Product product = InternalGetProductById(UserToken.uid, productId);
  if (product == null) {
    throw new NotAuthorizedException();
  }
  product.CanEdit = (
    UserToken.IsInRole("admin") || //user is administrator
    UserToken.Uid == product.CreatedByID || //user is creator
    HasUserPermissionToEdit(UserToken.Uid, productId)  //other custom permissions
    );
}
如果你想获取所有产品的列表,可以在你的数据访问代码中通过权限进行查询。在你的情况下,左连接来查看多对多表是否包含了UserToken.Uid和productId。如果连接的右侧存在,则说明用户有该产品的权限,然后你可以设置Product.CanEdit布尔值。
使用这种方法,你可以在视图中使用以下内容(其中Model是你的Product)。
<% if(Model.CanEdit) { %>
  <a href="/Products/1/Edit">Edit</a>
<% } %>

或者在你的控制器中

public ActionResult Get(int id) {
  Product p = ProductRepository.GetProductById(id);
  if (p.CanEdit) {
    return View("EditProduct");
  }
  else {
    return View("Product");
  }
}

这种方法的好处在于安全性是内置于您的服务层(ProductRepository)中的,因此不由控制器处理,并且不能被控制器绕过。

关键是将安全性放置在业务逻辑中,而不是控制器中。


这里的例子确实让人印象深刻。谢谢你。 - Jeremy Smith
从MVC角度来看,这个CanEdit属性应该放在DTO上吗? 似乎您希望在许多地方都能使用CanEdit,因此自然希望CanEdit属性位于ProductDto上,以便所有具有此信息的记录都可以使用它。 但是,例如,如果我加载了100个产品的列表,则应用程序必须为每个产品执行计算,以获取CanEdit标志。这似乎有点荒谬...我漏掉了什么? - Worthy7

3
复制粘贴的解决方案在一段时间后会变得单调乏味,并且维护起来非常烦人。我可能会选择使用自定义属性来实现所需功能。您可以使用优秀的.NET Reflector来查看如何实现AuthorizeAttribute并执行自己的逻辑。
它的作用是继承FilterAttribute并实现IAuthorizationFilter。我目前无法测试这个,但像这样的东西应该可以工作。
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
public class ProductAuthorizeAttribute : FilterAttribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationContext filterContext)
    {
        if (filterContext == null)
        {
            throw new ArgumentNullException("filterContext");
        }

        object productId;
        if (!filterContext.RouteData.Values.TryGetValue("productId", out productId))
        {
            filterContext.Result = new HttpUnauthorizedResult();
            return;
        }

        // Fetch product and check for accessrights

        if (user.IsAuthorizedFor(productId))
        {
            HttpCachePolicyBase cache = filterContext.HttpContext.Response.Cache;
            cache.SetProxyMaxAge(new TimeSpan(0L));
            cache.AddValidationCallback(new HttpCacheValidateHandler(this.Validate), null);
        }
        else
            filterContext.Result = new HttpUnauthorizedResult();
    }

    private void Validate(HttpContext context, object data, ref HttpValidationStatus validationStatus)
    {
        // The original attribute performs some validation in here as well, not sure it is needed though
        validationStatus = HttpValidationStatus.Valid;
    }
}

你可能还可以将你在filterContext.Controller中获取的产品/用户存储在TempData中,以便在控制器中获取它,或者将其存储在某个缓存中。

编辑:我刚刚注意到关于编辑链接的部分。我能想到的最好方法是从属性中分离出授权部分,并为其创建一个HttpHelper,这样你就可以在视图中使用它。


1
我倾向于认为授权是您的业务逻辑的一部分(或者至少在您的控制器逻辑之外)。我同意上面kevingessner的观点,即授权检查应该是调用获取项目的一部分。在他的OnException方法中,您可以通过以下方式显示登录页面(或者您在web.config中配置的任何内容):
if (...)
{
    Response.StatusCode = 401;
    Response.StatusDescription = "Unauthorized";
    HttpContext.Response.End();
}

而不是在所有的操作方法中调用UserRepository.GetUserSomehowFromTheRequest(),我会在Controller.OnAuthorization方法的重写中执行一次,然后将该数据存储在控制器基类中以供以后使用(例如一个属性)。


1
我认为期望控制器/模型代码控制视图渲染是不现实的,并且违反了关注点分离。 控制器/模型代码可以在视图模型中设置一个标志,供视图用于确定它应该执行什么操作,但我认为你不应该期望单个方法被控制器/模型和视图同时使用来控制对模型的访问和呈现。
话虽如此,你可以采用以下两种方法之一来解决这个问题——两种方法都涉及到一个视图模型,该视图模型除了实际的模型之外还携带了一些供视图使用的注释。 在第一种情况下,您可以使用属性来控制对操作的访问。 这将是我的首选,但除非控制器中的所有操作具有相同的访问属性,否则将需要独立地装饰每个方法。
我为此开发了一个“角色或所有者”属性。它验证用户是否处于特定角色或是该方法生成的数据的所有者。在我的情况下,所有权由用户和相关数据之间的外键关系控制 - 也就是说,您有一个ProductOwner表,产品和当前用户需要包含在该行中。它与普通的AuthorizeAttribute不同之处在于当所有权或角色检查失败时,用户将被引导到错误页面而不是登录页面。在这种情况下,每个方法都需要在视图模型中设置一个标志,指示该模型可以被编辑。
或者,您可以在控制器的ActionExecuting/ActionExecuted方法(或基础控制器中)实现类似的代码,以便它在所有控制器中一致地应用。在这种情况下,您需要编写一些代码来检测正在执行的操作类型,以便根据所讨论的产品的所有权来中止操作。相同的方法将设置标志以指示该模型可以被编辑。在这种情况下,您可能需要一个模型层次结构,以便可以将模型强制转换为可编辑模型,以便无论具体的模型类型如何,都可以设置属性。

对我来说,这个选项似乎比使用属性更紧密联系,并且可能更加复杂。在属性的情况下,您可以设计它,使其将各种表和属性名称作为属性传递给属性,并使用反射根据属性的属性从存储库中获取正确的数据。


只要控制器用于设置标志的代码与确定其他请求是否经过授权的代码相同,那么“标志”是否被设置就无关紧要了。我不在意结果如何到达目的地,我只在意实现业务规则的代码不会重复。你需要在DRY和SoC之间做出权衡。在NerdDinner教程中,如果业务规则“只有制作晚餐的人才能编辑它”发生变化,您需要在五个位置(按我的计算)编辑代码。这并不理想。 - Iain Galloway
如果您在编辑操作上使用属性,当属性成功时,您可以假设用户被允许进行编辑--属性将在方法之前运行并可以设置结果--在这种情况下,方法将永远不会被执行。鉴于此,您可以在编辑方法中的视图模型中设置标志--请注意,如果您有一个单独的编辑视图,则甚至不需要这样做。 - tvanfosson

0
回答自己的问题(哎呀!),《专业ASP.NET MVC 1.0》第1章(NerdDinner教程)推荐了与我上面类似的解决方案:
public ActionResult Edit(int id)
{
  Dinner dinner = dinnerRepositor.GetDinner(id);
  if(!dinner.IsHostedBy(User.Identity.Name))
    return View("InvalidOwner");

  return View(new DinnerFormViewModel(dinner));
}

除了让我饿得想吃晚饭外,这并没有真正为教程增加任何内容。教程继续重复实现业务规则的代码,即在匹配的POST操作方法和详细信息视图中(实际上是详细信息视图的子部分)

这是否违反了SRP原则?如果业务规则发生变化(例如,任何已经确认参加晚宴的人都可以编辑晚宴),则必须更改GET和POST方法以及视图(还有删除操作的GET和POST方法和视图,尽管这在技术上是一个单独的业务规则)。

将逻辑提取到某种权限仲裁对象中(如上所示)是否就是最好的解决方案?


0

你走在正确的道路上,但是你可以将所有的权限检查封装到一个方法中,比如GetProductForUser,该方法接受产品、用户和所需权限作为参数。通过在控制器的OnException处理程序中抛出异常,处理逻辑都集中在一个地方:

enum Permission
{
  Forbidden = 0,
  Access = 1,
  Admin = 2
}

public class ProductForbiddenException : Exception
{ }

public class ProductsController
{
  public Product GetProductForUser(int id, User u, Permission perm)
  {
    Product p = ProductRepository.GetProductById(id);
    if (ProductPermissionService.UserPermission(u, p) < perm)
    {
      throw new ProductForbiddenException();
    }
    return p;
  }

  public ActionResult Edit(int id)
  {
    User u = UserRepository.GetUserSomehowFromTheRequest();
    Product p = GetProductForUser(id, u, Permission.Admin);
    return View(p);
  }

  public ActionResult View(int id)
  {
    User u = UserRepository.GetUserSomehowFromTheRequest();
    Product p = GetProductForUser(id, u, Permission.Access);
    return View(p);
  }

  public override void OnException(ExceptionContext filterContext)
  {
    if (typeof(filterContext.Exception) == typeof(ProductForbiddenException))
    {
      // handle me!
    }
    base.OnException(filterContext);
  }
}

您只需提供ProductPermissionService.UserPermission,即可返回给定产品上用户的权限。通过使用Permission枚举(我认为我已经掌握了正确的语法...)并将权限与<进行比较,管理员权限意味着访问权限,这几乎总是正确的。


我唯一看到的一个大问题是将权限与请求合并在一起,这会有所阻碍你使用相同的代码来生成视图。想象一下您控制器中的Index(列表)方法。客户向您提出要求,在“列表”视图中为当前用户被允许编辑的产品旁边添加“编辑”链接。如何使能够决定用户是否可以获取或提交编辑操作的相同代码还决定是否在列表或详细视图上呈现“编辑”链接呢? - Iain Galloway
每个操作方法所关联的权限(即控制访问页面和访问模型的权限,可能是相同的)可以存储在操作属性上。然后,可以使用 http://msdn.microsoft.com/en-us/library/71s1zwct.aspx#cpconretrievingsingleinstanceofattribute 中的代码来从属性中检索该权限以调用 GetProductForUser。 - kevingessner
在视图中,像Controller.UserCanAccessAction(User u, string action)这样的方法可以根据属性中的权限返回true或false,如果用户可以访问视图。 因此,视图代码可能如下所示:<% if (ProductController.UserCanAccessAction(CurrentUser, "Edit")) { Response.Write(Html.ActionLink("Edit", ....)); } %>该技术不会复制操作和视图中的权限。 如果权限不同,则可以使用两个属性(例如ModelPermissionAttribute和ActionPermissionAttribute)。 - kevingessner

0
你可以使用基于XACML的实现。这样可以将授权外部化,并且在代码之外拥有策略库。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接