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', 'clear'];
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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.