Pull to refresh

Breeze Server — разграничиваем доступ к объектам при помощи атрибутов

Reading time12 min
Views8.5K

В прошлой статье Breeze.js + Entity Framework + Angular.js = удобная работа с сущностями базы данных прямо из браузера мы рассмотрели создание простейшего приложения, где делали выборки и сохраняли данные в базе прямо из javascript в браузере. Конечно же первыми у читателей возникли вопросы о безопасности. Поэтому сегодня мы рассмотрим, как можно организовать разграничение доступа. Для этого мы немного доработаем наше приложение из прошлой статьи так, чтобы можно было при помощи атрибутов раздать определённые права доступа на добавление, удаление, изменение и просмотр данных определённым пользователям или ролям.

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

Путь первый


Сохранение абсолютно всех изменений в нашем приложении происходило при помощи метода SaveChanges единственного контроллера DbController. И, если нам не требуется гибкого разграничения доступа, а нужно просто разрешить кому-то сохранение данных, либо его запретить — то самым лёгким выходом будет просто навесить на SaveChanges атрибут AuthorizeAttribute, и тогда уже WebApi позаботится о том, чтобы дать/запретить доступ на изменение данных. Это вариант очень прямолинейный и абсолютно не гибкий, всё или ничего, и, как правило, в реальных проектах этого всегда недостаточно.

Путь Второй


Метод SaveChanges принимает один параметр JObject, в нём одним пакетом содержатся все данные, которые нужно сохранить. Затем мы его передаём EFContextProvider в метод SaveChanges, а он уже и разбирает объект с данными и сохраняет изменения в базу. У него есть виртуальный метод BeforeSaveEntity, который вызывается каждый раз перед сохранением сущности, им мы и воспользуемся.

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

Пожалуй, перейдём сразу к практике, и, по ходу дела, разберём, что да как. Начнём с того места, на котором остановились в прошлой статье, для этого можете скачать проект по этой ссылке. После скачивания проект нужно построить, чтобы NuGet восстановил все пакеты, которые я удалил из проекта для уменьшения размера, и можно приступать.

Для начала нужно реализовать аутентификацию. Сделаем простейшую аутентификацию на основе cookies, для этого установим NuGet пакет Microsoft ASP.NET Identity Owin, Microsoft ASP.NET Web API 2.2 OWIN и Microsoft.Owin.Host.SystemWeb, так как в прошлый раз мы не использовали OWIN в приложении, далее создадим OWIN Startup класс Startup.cs, и в нём зарегистрируем стандартный маршрут для контроллеров WebApi, установим тип аутентификации DefaultAuthenticationTypes.ApplicationCookie и при помощи CamelCasePropertyNamesContractResolver заставим WebApi отдавать нам данные в camelCase.
using System;
using System.Threading.Tasks;
using Microsoft.Owin;
using Owin;
using System.Web.Http;
using Newtonsoft.Json.Serialization;
using Microsoft.Owin.Security.Cookies;
using Microsoft.AspNet.Identity;

[assembly: OwinStartup(typeof(BreezeJsDemo.App_Start.Startup))]
namespace BreezeJsDemo.App_Start
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            HttpConfiguration config = new HttpConfiguration();
            config.MapHttpAttributeRoutes();
            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie
            });
            config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
            app.UseWebApi(config);

        }
    }
}

Теперь создадим контроллер LoginController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Http;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using System.Security.Claims;
using Microsoft.AspNet.Identity;

namespace BreezeJsDemo.Controllers
{
    public class LoginController : ApiController
    {
        public class LoginViewModel
        {
            public string user { get; set; }
            public string role { get; set; }
        }

        public IHttpActionResult Post(LoginViewModel login)
        {
            var authenticationManager = HttpContext.Current.GetOwinContext().Authentication;
            if (authenticationManager.User.Identity.IsAuthenticated)
            {
                authenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
            }

            var claims = new Claim[] {
                new Claim( ClaimTypes.Name, login.user),
                new Claim( ClaimTypes.Role, login.role)
            };
            var identity = new ClaimsIdentity(claims, DefaultAuthenticationTypes.ApplicationCookie);
            authenticationManager.SignIn(identity);
            return Ok();
        }
    }
}

Здесь мы методом Post принимаем имя пользователя и имя роли, создаём на их основе ClaimsIdentity и осуществляем вход. Дабы не усложнять пример, ни проверок, ни паролей, ни базы пользователей мы делать не будем, так сказать:
-У нас здесь все джентльмены, все друг другу верят на слово.

Теперь добавим соответствующие поля в интерфейс. Прежде всего для этого нужно немного изменить /app/shoppingList/shoppingList.controller.js. Нам потребуется сервис $http, поэтому добавим его в зависимости
...
   ShoppingListController.$inject = ['$scope', '$http', 'breeze'];
   function ShoppingListController($scope, $http, breeze) {
...

И функцию login()
...
        vm.login = login;
...
        function login() {
            $http.post('api/login', { user: vm.user, role: vm.role });
        }
...

Добавим поля ввода имени пользователя и роли в разметку /app/shoppingList/shoppingList.html, например, наверх в navbar
    <nav class="navbar navbar-default">
        <ul class="navbar-nav nav">
            <li ng-if="vm.hasChanges()"><a ng-click="vm.saveChanges()"><span class="glyphicon glyphicon-thumbs-up"></span> Сохранить изменения</a></li>
            <li ng-if="vm.hasChanges()"><a ng-click="vm.rejectChanges()"><span class="glyphicon glyphicon-thumbs-down"></span> Отменить изменения</a></li>
            <li><a ng-click="vm.refreshData()"><span class="glyphicon glyphicon-refresh"></span> Обновить</a></li>
        </ul>
        <form class="navbar-form navbar-right">
            <div class="form-group">
                <input ng-model="vm.user" class="form-control" placeholder="Имя пользователя" />
                <input ng-model="vm.role" class="form-control" placeholder="Роль" />
            </div>
            <button ng-click="vm.login()" class="btn btn-link"><span class="glyphicon glyphicon-user"></span> Вход</button>
        </form>
    </nav>

Теперь, когда мы можем представиться нашему приложению кем захотим, перейдём к атрибутам доступа. Допустим, мы будем раздавать права на доступ с помощью таких атрибутов: CanAddAttribute(даёт право на добавление в базу новой записи), CanDeleteAttribute(право на удаление) и CanEditAttribute(право на изменение), причём, если повесить CanEditAttribute на свойства класса — пользователь сможет изменять их значения, а если повесить его на класс — пользователь сможет изменять все его свойства без исключений. Конечно, в реальном проекте такая схема будет крайне неудобна и нежизнеспособна, но, чтобы объяснить идею этого набора будет вполне достаточно.
    public class HasRightsAttribute: Attribute
    {
        public String User { get; set; }
        public String Role { get; set; }
    }

    [AttributeUsage(AttributeTargets.Class, AllowMultiple=true)]
    public class CanAddAttribute: HasRightsAttribute
    {
    }

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
    public class CanDeleteAttribute : HasRightsAttribute
    {
    }

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)]
    public class CanEditAttribute: HasRightsAttribute
    {
    }

Раздадим эти атрибуты нашим моделям, например
    [CanEdit(User = "User")]
    [CanAdd(Role = "Role")]
    [CanDelete(Role = "Role2")]
    public class ListItem
    {
        public int Id { get; set; }
        public String Name { get; set; }

        [CanEdit(User = "User2", Role = "Role2")]
        public Boolean IsBought { get; set; }
        public int CategoryId { get; set; }
        public Category Category { get; set; }
    }

И вот что это будет означать:
  • пользователи с ролью «Role» имеют право добавлять в базу объекты класса ListItem
  • пользователи с ролью «Role2» могут их удалять
  • пользователь с именем «User» может изменять значения любых его полей
  • пользователи с ролью «Role2» может изменять значение поля IsBought
  • пользователи с именем «User2» может изменять значение поля IsBought

И нечто подобное добавим классу Category
    [CanEdit( User = "User")]
    [CanAdd( Role = "Role")]
    public class Category
    {
        public int Id { get; set; }
        public String Name { get; set; }
        public List<ListItem> ListItems { get; set; }
    }

Теперь займёмся основным, создадим класс SecureEFContextProvider — наследник EFContextProvider, который будет осуществлять разграничение доступа
using Breeze.ContextProvider;
using Breeze.ContextProvider.EF6;
using BreezeJsDemo.Classes.Attributes;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Reflection;
using System.Web.Http;
using System.Net;
using System.Security.Claims;
using System.Data.Entity.Infrastructure;
using System.Data.Entity.Core.Objects;
using BreezeJsDemo.Model;

namespace BreezeJsDemo.Classes
{
    public class SecureEFContextProvider<T> : EFContextProvider<T> where T : class, new()
    {
        protected override bool BeforeSaveEntity(EntityInfo entityInfo)
        {
            var user = HttpContext.Current.GetOwinContext().Authentication.User;

            if (user.Identity.IsAuthenticated)
            {
                var userName = user.FindFirst(ClaimTypes.Name).Value;
                var role = user.FindFirst(ClaimTypes.Role).Value;
                var entityType = entityInfo.Entity.GetType();
                switch (entityInfo.EntityState)
                {
                    case EntityState.Added:
                        //Если тип имеет атрибут CanAddAttribute для текущего пользователя или роли
                        if (entityType.GetCustomAttributes<CanAddAttribute>().Any(x => x.Role == role || x.User == userName))
                        {
                            return true;
                        }
                        break;
                    case EntityState.Deleted:
                        //Если тип имеет атрибут CanDeleteAttribute для текущего пользователя или роли
                        if (entityType.GetCustomAttributes<CanDeleteAttribute>().Any(x => x.Role == role || x.User == userName))
                        {
                            return true;
                        }
                        break;
                    case EntityState.Modified:
                        //Если тип имеет атрибут CanEditAttribute для текущего пользователя или роли
                        if (entityType.GetCustomAttributes<CanEditAttribute>().Any(x => x.Role == role || x.User == userName))
                        {
                            return true;
                        }

                        //Если все изменённые свойства
                        if (entityInfo.OriginalValuesMap.All(x => entityType
                            .GetProperty(x.Key)

                        //Имеют атрибут CanEditAttribute для текущего пользователя или роли
                            .GetCustomAttributes<CanEditAttribute>().Any(y => y.Role == role || y.User == userName)))
                        {
                            return true;
                        }

                        break;
                }
            }
            //Иначе доступ запрещён
            throw new HttpResponseException(HttpStatusCode.Forbidden);
        }
    }
}

В этом классе мы перегрузили метод BeforeSaveEntity, EFContextProvider вызывает его перед сохранением каждой сущности. Это место предусмотрено разработчиками специально для того, чтобы проверять, что именно мы собираемся изменять, для валидации изменений или изменения некоторых данных перед сохранением, например, даты последнего изменения объекта. Если метод вернёт false — сущность не будет сохранена, если в методе возникнет исключение, то будет отменено сохранение всего пакета изменений, а клиенту будет возвращено исключение. Базовая версия метода просто всегда возвращает true, поэтому вместо его вызова можно писать return true.

Так же можно перегрузить метод protected Dictionary<Type, List> BeforeSaveEntities(Dictionary<Type, List> saveMap), в него попадает сразу весь пакет сохранения, возвращать следует так же Dictionary<Type, List>, это и будет новым пакетом для сохранения.

Эти методы принимают на вход объекты типа EntityInfo, где содержатся данные о сущности, которую требуется сохранить и тип операции, которую требуется произвести, рассмотрим некоторые из его свойств

  • ContextProvider ContextProvider — ссылка на ContextProvider
  • Object Entity — непосредственно сама сохраняемая сущность в виде объекта .NET, со значениями свойств, пришедшими с клиента в пакете
  • EntityState EntityState — статус объекта (Добавлен, Изменён, Удалён)
  • Dictionary<String, Object> OriginalValuesMap — оригинальные значения свойств, до сохранения. Бриз изменит в базе значения только тех полей, чьи имена являются ключами этого словаря, при этом не тронув остальную запись. Причём это данные, которые пришли с клиента в пакете изменений, то есть этим данным доверять нельзя. Сам бриз не обращает внимания на значения свойств, которые там хранятся (за исключением полей, которые используются для concurrency check), важно лишь наличие ключа с именем изменяемого поля в словаре. То есть, если Вы, например, хотите уже на стороне сервера изменить свойство EditDate, которое не изменено на клиенте, Вам надо будет сначала изменить его значение в Entity, а затем в OriginalValuesMap добавить ключ «EditDate», с любым значением, например, null. И наоборот, если Вы не хотите изменять значение какого-либо поля, которое захотел изменить клиент — нужно удалить соответствующий ему ключ из OriginalValuesMap.
  • bool ForceUpdate — если установить true — бриз обновит значения всех полей сущности, не смотря на содержание OriginalValuesMap, по-умолчанию стоит false
  • Dictionary<String, Object> UnmappedValuesMap — значения прочих свойств сущности, которые пришли с пакетом сохранения в json, но не попадающих в Вашу модель.

Первым делом в методе мы проверяем, прошёл ли пользователь аутентификацию (user.Identity.IsAuthenticated), и, если нет — запрещаем сохранение. Далее мы проверяем статус сущности и ищем соответствующий атрибут для имени пользователя или его роли, если таковой присутствует — разрешаем сохранение. Если же статус EntityState.Modified и у пользователя нет прав на изменение всего объекта — смотрим изменившиеся свойства в OriginalValuesMap и ищем нужный атрибут у свойства, если такового нет — запрещаем сохранение.

Учитывая, что каждая сущность перед сохранением попадает в этот метод — возможно также реализовать разграничение не на уровне полей, а на уровне отдельных записей. Например, запретить пользователю User удалять запись с идентификатором 1. Так же можно, например, вместо атрибутов, хранить все права доступа где-нибудь в базе данных, чтобы можно было изменять их в рантайме.

Далее в DbController нужно заменить EFContextProvider на наш SecureEFContextProvider
...
private SecureEFContextProvider<ShoppingListDbContext> _contextProvider = new SecureEFContextProvider<ShoppingListDbContext>();
...

Теперь сохранение каждой сущности под нашим контролем. Но если сейчас попробовать внести какие-либо изменения — пользователь подумает, что всё прошло успешно, потому что мы не делали обработки ошибки при сохранении. Внесём небольшие изменения в метод saveChanges.
        function saveChanges() {
            manager.saveChanges().then(null, function (error) {
                if (error.status === 403) {
                    manager.rejectChanges();
                    alert("Пользователю запрещено это действие");
                }
            });
        }

Теперь в случае, если сервер вернёт 403 — будет произведён откат изменений, которые не удались, а пользователь увидит сообщение.

С сохранением разобрались. Теперь поговорим о доступе на чтение тех или иных данных. Самый логичный и надёжный метод, конечно, создать на каждую сущность, с которой требуется работать, свой DTO, сделать между ними связи, затем создать для них специальный DbContext, чтобы так же просто генерировать клиентские метаданные с помощью EFContextProvider. В DbController сделать соответствующие методы для каждого DTO. В общем, всё точно так же, как в нашем приложении, но в методе BeforeSaveEntity — принимать DTO, а работать уже с реальным контекстом и реальными сущностями, а затем возвращать из метода false, чтобы DTO контекст не пробовал делать сохранение. В этом случае лучше подойдёт метод BeforeSaveEntities, потому что в него попадает сразу весь пакет сохранения, и, соответственно, можно будет сохранить сразу все изменения в один присест, затем нужно из метода вернуть пустой словарь Dictionary<Type, List>, чтобы DTO контекст не стал ничего сохранять.

При таком подходе на клиент не попадёт никаких лишних данных, плюс мы не раскрываем схему нашей БД. Но, естественно, увеличивается и количество работы по созданию DTO и дополнительной обработке их сохранения. Конечно, трудно кого-то этим напугать, но почему бы нам ещё немного не пофантазировать…

Первым делом напишем атрибут, которым будем раздавать права на чтение сущностей и свойств
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)]
    public class CanReadAttribute: HasRightsAttribute
    {
    }

Раздадим атрибуты классам
    [CanEdit(User = "User")]
    [CanAdd(Role = "Role")]
    [CanDelete(Role = "Role2")]
    [CanRead(User = "User", Role = "Role")]
    public class ListItem
    {
        [CanRead(Role = "Role2", User = "User2")]
        public int Id { get; set; }
        public String Name { get; set; }

        [CanEdit(User = "User2", Role = "Role2")]
        [CanRead(Role="Role2", User="User2")]
        public Boolean IsBought { get; set; }

        [CanRead(Role = "Role2", User = "User2")]
        public int CategoryId { get; set; }
        public Category Category { get; set; }
    }

    [CanEdit(User = "User")]
    [CanAdd(Role = "Role")]
    [CanRead(User="User", Role="Role")]
    [CanRead(Role = "Role2", User = "User2")]
    public class Category
    {
        public int Id { get; set; }
        public String Name { get; set; }
        public List<ListItem> ListItems { get; set; }
    }

Принцип тот же, если есть атрибут на классе — пользователь имеет право на просмотр всех свойств, если нет — то только тех, на которых есть атрибут. Скрывать данные будем в событии ObjectContext ObjectMaterialized, для этого немного дополним класс SecureEFContextProvider
    public class SecureEFContextProvider<T> : EFContextProvider<T> where T : class, new()
    {
        public SecureEFContextProvider()
        {
            ObjectContext.ObjectMaterialized += ObjectContext_ObjectMaterialized;
        }

        private void ObjectContext_ObjectMaterialized(object sender, ObjectMaterializedEventArgs e)
        {
            var user = HttpContext.Current.GetOwinContext().Authentication.User;
            String userName = null;
            String role = null;
            if (user.Identity.IsAuthenticated)
            {
                userName = user.FindFirst(ClaimTypes.Name).Value;
                role = user.FindFirst(ClaimTypes.Role).Value;
            }
            var entityType = e.Entity.GetType();

            //Если тип имеет атрибут CanReadAttribute для текущего пользователя или роли - то сразу выходим из функции
            if (entityType.GetCustomAttributes<CanReadAttribute>().Any(x => x.Role == role || x.User == userName))
            {
                return;
            }

            //Выберем все свойства, к которым пользователь не имеет доступа
            var _forbiddenProperties = e.Entity.GetType().GetProperties()
                .Where(x => !x.GetCustomAttributes<CanReadAttribute>()
                    .Any(y => y.Role == role || y.User == userName));

            foreach (var property in _forbiddenProperties)
            {
                //И спрячем их значения от посторонних глаз
                property.SetValue(e.Entity, null);
            }
        }

Здесь мы просто устанавливаем значение null всем свойствам, доступа к которым пользователь не имеет. Теперь, если запустить проект, можно увидеть, что неавторизованный пользователь не имеет прав даже на чтение ключей объектов, Пользователь User или роль Role имеет права на просмотр всех свойств, а вот пользователь User2 и роль Role2 не могут видеть названий элементов списка. Этого мы и добивались. Но, хочу заметить, несмотря на то, что пользователь не имеет доступа к непосредственно данным в некоторых свойствах объектов, полная схема моделей данных полностью известна на клиенте.

Вот, пожалуй, и всё, о чём хотелось рассказать сегодня. Только замечу, что пользователь будет очень обескуражен, не зная, какие поля ему можно редактировать, а какие нет, а User2 будет в шоке от того, что у пунктов списка пустые имена. Поэтому в следующий раз мы рассмотрим работу с метаданными в бризе и дадим нашему пользователю визуальные подсказки о том, какие права есть у него в приложении. Готовый проект можно скачать по ссылке.

P.S. Пока писал статью решил написать библиотеку, которая будет реализовать такое разграничение доступа при помощи атрибутов/fluent interface. Если есть какие-либо идеи, советы и пожелания по функционалу или реализации — милости прошу в комментарии. Как будет готово что-то более-ли менее пристойное — опубликую на GitHub, в NuGet, и напишу здесь tutorial.
Tags:
Hubs:
Total votes 9: ↑9 and ↓0+9
Comments2

Articles