Контроль доступа в ASP.NET MVC в зависимости от входных параметров / уровня обслуживания?

Преамбула: это немного философский вопрос. Я больше ищу "правильный" способ сделать это, а не "способ" сделать это.

Давайте представим, что у меня есть несколько продуктов и приложение ASP.NET MVC, выполняющее CRUD для этих продуктов:

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

Я использую шаблон репозитория, поэтому не имеет значения, откуда берутся эти продукты:

public interface IProductRepository
{
  IEnumberable<Product> GetProducts();
  ....
}

Также в моем репозитории описывается список пользователей и продуктов, для которых они являются менеджерами (многие-многие между пользователями и продуктами). В другом месте приложения Super-Admin выполняет CRUD для пользователей и управляет отношениями между пользователями и продуктами, которыми им разрешено управлять.

Любой может просматривать любой продукт, но только пользователи, которые определены как "администраторы" для определенного продукта, могут вызывать, например, действие "Изменить".

Как я должен идти о реализации этого в ASP.NET MVC? Если я что-то пропустил, я не могу использовать встроенный атрибут авторизации ASP.NET, так как, во-первых, мне нужна отдельная роль для каждого продукта, а во-вторых, я не буду знать, какую роль проверять, пока не укажу получил мой продукт из хранилища.

Очевидно, что вы можете обобщить этот сценарий для большинства сценариев управления контентом - например, пользователям разрешено редактировать только свои собственные сообщения на форуме. Пользователи Stackru могут редактировать только свои вопросы - если у них нет 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) в зависимости от отношения User-Products. Вы должны будете скопировать и вставить несколько раз.
  • Это не очень проверяемое - вы должны смоделировать по моим подсчетам четыре объекта для каждого теста.
  • Это не похоже на "работу" контроллера - проверять, разрешено ли пользователю выполнять действие. Я бы предпочел более подключаемое (например, AOP через атрибуты) решение. Однако означает ли это, что вам придется ВЫБРАТЬ продукт дважды (один раз в AuthorizationFilter и снова в Controller)?
  • Было бы лучше вернуть 403, если пользователь не может сделать этот запрос? Если так, как бы я поступил так?

Я, вероятно, буду держать это в курсе, когда получаю идеи сам, но я очень хочу услышать ваши!

Заранее спасибо!

редактировать

Просто чтобы добавить немного деталей здесь. Проблема, с которой я столкнулся, заключается в том, что я хочу, чтобы бизнес-правило "Только пользователи с разрешениями могли редактировать продукты" содержалось в одном и только одном месте. Я чувствую, что тот же код, который определяет, может ли пользователь GET или POST выполнить действие "Правка", также должен отвечать за определение, следует ли отображать ссылку "Редактировать" в представлениях "Индекс" или "Сведения". Может быть, это невозможно / не возможно, но я чувствую, что так и должно быть...

Редактировать 2

Начиная щедрость на этом. Я получил несколько хороших и полезных ответов, но ничего, что я чувствую себя комфортно, "принимая". Имейте в виду, что я ищу хороший чистый метод, чтобы сохранить бизнес-логику, которая определяет, будет ли ссылка "Редактировать" в представлении индекса отображаться в том же месте, которое определяет, будет ли запрос в Продукты / Изменить. /1 разрешено или нет. Я хотел бы свести загрязнение в моем методе действия к абсолютному минимуму. В идеале я ищу решение на основе атрибутов, но согласен, что это невозможно.

8 ответов

Решение

Прежде всего, я думаю, что вы уже на полпути поняли это, потому что вы заявили, что

во-первых, мне нужна отдельная роль для каждого продукта, а во-вторых, я не буду знать, какую роль проверять, пока не получу свой продукт из репозитория.

Я видел очень много попыток сделать так, чтобы безопасность на основе ролей делала то, чего никогда не было предназначено, но вы уже прошли этот этап, так что это круто:)

Альтернативой безопасности на основе ролей является безопасность на основе ACL, и я думаю, что это то, что вам нужно здесь.

Вам все равно нужно будет получить ACL для продукта, а затем проверить, есть ли у пользователя права на этот продукт. Это настолько контекстно-зависимо и интенсивно взаимодействует, что я думаю, что чисто декларативный подход является слишком негибким и слишком неявным (т. Е. Вы можете не понимать, сколько операций чтения из базы данных задействовано при добавлении одного атрибута в некоторый код).

Я думаю, что подобные сценарии лучше всего моделируются классом, который инкапсулирует логику ACL, позволяя вам либо запросить решение, либо сделать утверждение на основе текущего контекста - что-то вроде этого:

var p = this.ProductRepository.GetProductById(id);
var user = this.GetUser();
var permission = new ProductEditPermission(p);

Если вы просто хотите узнать, может ли пользователь редактировать продукт, вы можете выполнить запрос:

bool canEdit = permission.IsGrantedTo(user);

Если вы просто хотите убедиться, что у пользователя есть права на продолжение, вы можете выдать утверждение:

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, IIRC), вы можете просто выбрать вышеупомянутые методы разрешения для:

bool canEdit = permission.IsGranted();

или же

permission.Demand();

потому что пользователь будет неявным. Вы можете взглянуть на System.Security.Permissions.PrincipalPermission для вдохновения.

Из того, что вы описываете, звучит так, как будто вам нужна какая-то форма контроля доступа пользователя, а не права на основе ролей. Если это так, то это должно быть реализовано во всей вашей бизнес-логике. Ваш сценарий звучит так, как будто вы можете реализовать его на своем сервисном уровне.

По сути, вам необходимо реализовать все функции в вашем ProductRepository с точки зрения текущего пользователя, и продукты помечены с разрешениями для этого пользователя.

Звучит сложнее, чем есть на самом деле. Во-первых, вам нужен интерфейс токена пользователя, который содержит пользовательскую информацию uid и список ролей (если вы хотите использовать роли). Вы можете использовать IPrincipal или создать свой собственный в соответствии с

public interface IUserToken {
  public int Uid { get; }
  public bool IsInRole(string role);
}

Затем в вашем контроллере вы анализируете токен пользователя в своем конструкторе репозитория.

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.

Используя этот метод, вы можете использовать следующее, если хотите, в своем View (где Model - ваш продукт).

<% 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), поэтому она не обрабатывается вашими контроллерами и не может быть обойдена вашими контроллерами.

Главное, что безопасность заложена в вашей бизнес-логике, а не в контроллере.

Решения копирования пасты действительно становятся утомительными через некоторое время, и это действительно раздражает в обслуживании. Я бы, наверное, пошел с пользовательским атрибутом, делая то, что вам нужно. Вы можете использовать превосходный .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, который вы можете использовать в своем представлении.

Я склонен думать, что авторизация является частью вашей бизнес-логики (или, по крайней мере, вне логики вашего контроллера). Я согласен с Kevingessner выше, в том, что проверка авторизации должна быть частью вызова для получения элемента. В его методе OnException вы могли бы показать страницу входа (или что-то еще, что вы настроили в web.config) примерно так:

if (...)
{
    Response.StatusCode = 401;
    Response.StatusDescription = "Unauthorized";
    HttpContext.Response.End();
}

И вместо того, чтобы делать вызовы UserRepository.GetUserSomehowFromTheRequest() во всех методах действия, я бы сделал это один раз (например, в переопределении метода Controller.OnAuthorization), а затем вставил эти данные в базовый класс контроллера для последующего использования (например, недвижимость).

Я думаю, что нереально и является нарушением разделения интересов, ожидать, что код контроллера / модели будет контролировать то, что отображает представление. Код контроллера / модели может установить флаг в модели представления, который представление может использовать для определения того, что он должен делать, но я не думаю, что вы должны ожидать, что и контроллер / модель и представление будут использовать один метод контролировать как доступ к модели, так и ее отображение.

Сказав, что вы можете подойти к этому одним из двух способов - оба будут включать модель представления, которая содержит некоторые аннотации, используемые представлением в дополнение к фактической модели. В первом случае вы можете использовать атрибут для управления доступом к действию. Это было бы моим предпочтением, но включало бы декорирование каждого метода независимо - если только все действия в контроллере не имеют одинаковые атрибуты доступа.

Я разработал атрибут "роль или владелец" только для этой цели. Он проверяет, что пользователь играет определенную роль или является владельцем данных, создаваемых методом. Владение, в моем случае, контролируется наличием отношения внешнего ключа между пользователем и рассматриваемыми данными, то есть у вас есть таблица ProductOwner и должна быть строка, содержащая пару продукт / владелец для продукта. и текущий пользователь. Он отличается от обычного атрибута AuthorizeAttribute тем, что при сбое проверки прав собственности или роли пользователь перенаправляется на страницу с ошибкой, а не на страницу входа. В этом случае каждый метод должен был бы установить флаг в модели представления, который указывает, что модель может быть отредактирована.

В качестве альтернативы вы могли бы реализовать подобный код в методах ActionExecuting/ActionExecuted контроллера (или базового контроллера, чтобы он применялся последовательно для всех контроллеров). В этом случае вам потребуется написать некоторый код, чтобы определить, какое действие выполняется, чтобы вы знали, следует ли прервать действие, основываясь на праве собственности на данный продукт. Тот же метод установит флаг, чтобы указать, что модель может быть отредактирована. В этом случае вам, вероятно, понадобится иерархия моделей, чтобы вы могли преобразовать модель в редактируемую модель, чтобы вы могли установить свойство независимо от конкретного типа модели.

Этот вариант кажется мне более связанным, чем использование атрибута, и, возможно, более сложным. В случае атрибута вы можете спроектировать его так, чтобы он использовал различные имена таблиц и свойств в качестве атрибутов для атрибута и использовал отражение для получения надлежащих данных из вашего хранилища на основе свойств атрибута.

Вы на правильном пути, но вы можете инкапсулировать всю проверку разрешений в один метод, например 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 (я думаю, у меня правильный синтаксис...) и сравнивая разрешения с <Права администратора подразумевают права доступа, что почти всегда верно.

Вы можете использовать реализацию на основе XACML. Таким образом, вы можете экспортировать авторизацию, а также иметь хранилище для ваших политик вне вашего кода.

Отвечая на мой собственный вопрос (eep!), Глава 1 Professional ASP.NET MVC 1.0 (руководство 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 и в представлении Details (фактически в дочерней части Подробности просмотра)

Это нарушает SRP? Если бизнес-правило изменилось (так что, например, любой, у кого был RSVP, мог редактировать обед), вам пришлось бы изменить как методы GET и POST, так и View (а также методы GET и POST и View для операции Delete). хотя это технически отдельное бизнес-правило).

Вытягивает ли логика какой-то объект арбитра разрешений (как я делал выше) настолько хорошо, насколько это возможно?

Другие вопросы по тегам