Breeze Server — разграничиваем доступ к объектам при помощи атрибутов
В прошлой статье 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 Теперь, когда мы можем представиться нашему приложению кем захотим, перейдём к атрибутам доступа. Допустим, мы будем раздавать права на доступ с помощью таких атрибутов: 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
namespace BreezeJsDemo.Classes
{
public class SecureEFContextProvider
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
//Если все изменённые свойства if (entityInfo.OriginalValuesMap.All (x => entityType .GetProperty (x.Key)
//Имеют атрибут CanEditAttribute для текущего пользователя или роли
.GetCustomAttributes
break;
}
}
//Иначе доступ запрещён
throw new HttpResponseException (HttpStatusCode.Forbidden);
}
}
}
В этом классе мы перегрузили метод BeforeSaveEntity, EFContextProvider вызывает его перед сохранением каждой сущности. Это место предусмотрено разработчиками специально для того, чтобы проверять, что именно мы собираемся изменять, для валидации изменений или изменения некоторых данных перед сохранением, например, даты последнего изменения объекта. Если метод вернёт false — сущность не будет сохранена, если в методе возникнет исключение, то будет отменено сохранение всего пакета изменений, а клиенту будет возвращено исключение. Базовая версия метода просто всегда возвращает true, поэтому вместо его вызова можно писать return true.Так же можно перегрузить метод protected Dictionary
Эти методы принимают на вход объекты типа EntityInfo, где содержатся данные о сущности, которую требуется сохранить и тип операции, которую требуется произвести, рассмотрим некоторые из его свойств
ContextProvider ContextProvider — ссылка на ContextProvider
Object Entity — непосредственно сама сохраняемая сущность в виде объекта .NET, со значениями свойств, пришедшими с клиента в пакете
EntityState EntityState — статус объекта (Добавлен, Изменён, Удалён)
Dictionary
Далее в DbController нужно заменить EFContextProvider на наш SecureEFContextProvider
…
private SecureEFContextProvider
При таком подходе на клиент не попадёт никаких лишних данных, плюс мы не раскрываем схему нашей БД. Но, естественно, увеличивается и количество работы по созданию 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
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
//Выберем все свойства, к которым пользователь не имеет доступа
var _forbiddenProperties = e.Entity.GetType ().GetProperties ()
.Where (x =>! x.GetCustomAttributes
foreach (var property in _forbiddenProperties) { //И спрячем их значения от посторонних глаз property.SetValue (e.Entity, null); } } Здесь мы просто устанавливаем значение null всем свойствам, доступа к которым пользователь не имеет. Теперь, если запустить проект, можно увидеть, что неавторизованный пользователь не имеет прав даже на чтение ключей объектов, Пользователь User или роль Role имеет права на просмотр всех свойств, а вот пользователь User2 и роль Role2 не могут видеть названий элементов списка. Этого мы и добивались. Но, хочу заметить, несмотря на то, что пользователь не имеет доступа к непосредственно данным в некоторых свойствах объектов, полная схема моделей данных полностью известна на клиенте.Вот, пожалуй, и всё, о чём хотелось рассказать сегодня. Только замечу, что пользователь будет очень обескуражен, не зная, какие поля ему можно редактировать, а какие нет, а User2 будет в шоке от того, что у пунктов списка пустые имена. Поэтому в следующий раз мы рассмотрим работу с метаданными в бризе и дадим нашему пользователю визуальные подсказки о том, какие права есть у него в приложении. Готовый проект можно скачать по ссылке.
P.S. Пока писал статью решил написать библиотеку, которая будет реализовать такое разграничение доступа при помощи атрибутов/fluent interface. Если есть какие-либо идеи, советы и пожелания по функционалу или реализации — милости прошу в комментарии. Как будет готово что-то более-ли менее пристойное — опубликую на GitHub, в NuGet, и напишу здесь tutorial.