В мире ASP.NET существуют мощные и гибкие механизмы авторизации. Например, ASP.NET Core 2.0 предоставляет разработчику возможность использования политик авторизации, обработчиков и т.д.

Но как реализовать метод GET, возвращающий список ресурсов? А если этот метод к тому же должен возвращать не все ресурсы, а лишь специфицированную страницу? Каждый пользователь должен видеть только те ресурсы, к которым у него есть доступ. Можно получать из базы каждый раз полный список и затем фильтровать его на основе прав текущего пользователя, но это будет слишком неэффективно – количество ресурсов может быть очень велико. Предпочтительно решать вопросы авторизации и разбиения на страницы на уровне запроса к базе данных.

В этой статье описывается подход к решению проблемы авторизации в REST сервисе на базе ASP.NET Web API 2 с использованием Entity Framework.

Задача


Предположим, мы разрабатываем сайт, позволяющий размещать различные ресурсы, например, текстовые документы. У нас есть REST-сервис, осуществляющий CRUD-операции над этими документами. Задача аутентификации, то есть определения подлинности пользователя, у нас уже решена. Пользователи в нашей системе могут иметь различные роли. Будем считать, что у нас есть два типа пользователей: администраторы и простые пользователи.

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

Начинаем


Итак, пользователи делятся на два типа: администраторы и обычные пользователи. Администраторы имеют максимальные права на доступ к любому документу, обычные пользователи имеют максимальные права на свои документы и предоставленные права на чужие. Будем считать, что есть три полномочия: чтение, запись (изменение) и удаление документа: Read, Write и Delete. Каждое последующее полномочие включает в себя предыдущее, т.е. Write включает Read, а Delete включает в себя Write и Read.

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



Здесь ObjectId – это идентификатор ресурса, ObjectType – тип ресурса, UserId – Id пользователя и, наконец, Permission – полномочия.

Добавим необходимые определения:

public enum ObjectType
{
    // May grow in the future
    Document
}

public enum Permission
{
    None = 0,
    Read = 1,
    Write = 2,
    Delete = 3
}

public enum Role
{
    Administrator,
    User
}

При добавлении нового ресурса в таблице Permissions должна появляться запись с максимальными правами для пользователя, создавшего ресурс. Проще всего это сделать с помощью DB триггера. Будем считать, что у нас в таблице Documents имеются столбцы Id (идентификатор документа) и CreatedBy (идентификатор пользователя, создавшего документ). Добавим в таблицу Documents новый триггер:

CREATE TRIGGER [dbo].[TR_Documents_Insert] ON [dbo].[Documents] FOR INSERT 
AS 
BEGIN
        INSERT INTO Permissions(ObjectId, ObjectType, UserId, Permission)
        SELECT inserted.Id,
                1, -- ObjectType.Document
                inserted.CreatedBy,
                3  -- Permission.Delete
        FROM inserted
END

Таким образом, у нас будет автоматически появляться полномочие Delete для создателя документа.

Можно также добавить триггер на удаление:

CREATE TRIGGER [dbo].[TR_Documents_Delete] on [dbo].[Documents] FOR DELETE
AS
BEGIN
        DELETE FROM Permissions
        WHERE ObjectId IN (SELECT ID FROM deleted) AND ObjectType = 1
END

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

Тем не менее, лучше всё-таки использовать общий подход. Что произойдёт, если администратор вдруг перестанет быть администратором и перейдёт в категорию обычных пользователей?

Модель


Мы используем Entity Framework. Классы и интерфейсы модели данных выглядят примерно так:

public class Document
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public long Id { get; set; }

    public int CreatedBy { get; set; }

    public string Source { get; set; }
}

public class UserPermission
{
    [Key]
    [Column(Order = 1)]
    public long ObjectId { get; set; }

    [Key]
    [Column(Order = 2)]
    public byte ObjectType { get; set; }

    [Key]
    [Column(Order = 3)]
    public int UserId { get; set; }

    public byte Permission { get; set; }
}

public interface IModel
{
    IQueryable<Document> Documents { get; }
    IQueryable<UserPermission> Permissions { get; }
}

public class MyDbContext : DbContext, IModel
{
    public MyDbContext()
    {
    }

    public MyDbContext(string connectString)
        : base(connectString)
    {
#if DEBUG
        Database.Log = x => Trace.WriteLine(x);
#endif
    }

    public DbSet<Document> Documents { get; set; }
    public DbSet<UserPermission> Permissions { get; set; }

#region Explicit IModel interface implementations
    IQueryable<Document> IModel.Documents => Documents;
    IQueryable<UserPermission> IModel.Permissions => Permissions;
#endregion
}

Введение интерфейса IModel может оказаться полезным для юнит-тестирования, если нам потребуются тестовые данные:
internal class DbContextStub : IModel
{
    public List<Document> Documents { get; } =
        new List<Document>();
    public List<UserPermission> Permissions { get; } =
        new List<UserPermission>();

#region Explicit Interface Implementations
    IQueryable<Document> IModel.Documents => Documents.AsQueryable();
    IQueryable<UserPermission> IModel.Permissions => Permissions.AsQueryable();
#endregion
}

Обратите также внимание на тело конструктора MyDbContext. Строка Database.Log = x => Trace.WriteLine(x) позволяет увидеть реальные SQL запросы в Output-окне Visual Studio при отладке.

Классы для авторизации


Создадим интерфейс IAccessor:

public interface IAccessor
{
    IQueryable<T> GetQuery<T>() where T : class, IAuthorizedObject;
    Permission GetPermission<T>(long objectId)
        where T : class, IAuthorizedObject;
    bool HasPermission<T>(long objectId, Permission permission)
        where T : class, IAuthorizedObject;
}

Метод GetQuery будет возвращать интерфейс IQueriable для выборки ресурсов, в нашем случае документов, доступных для чтения текущему пользователю. Метод GetPermission вернёт полномочие текущего пользователя на специфицированный ресурс. Метод HasPermission добавлен для удобства. Он отвечает на вопрос, имеет ли текущий пользователь заданное право на специфицированный ресурс.

Интерфейс IAuthorizedObject определяет ресурс, который мы собираемся авторизовать. Этот интерфейс весьма прост и содержит только Id ресурса:

public interface IAuthorizedObject
{
    long Id { get; }
}

Класс Document нужно будет унаследовать от интерфейса IAuthorizedObject:

public class Document : IAuthorizedObject

Пришло время имплементировать конкретные реализации интерфейса IAccessor. У нас будет две реализации: Administrator и User. Вначале добавим базовый класс UserBase:

public abstract class UserBase : IAccessor
{
    protected readonly IModel Model;
    protected readonly int Id;

    private readonly Dictionary<Type, IQueryable> _typeToQuery =
        new Dictionary<Type, IQueryable>();
    private readonly Dictionary<Type, ObjectType> _typeToEnum =
        new Dictionary<Type, ObjectType>();

    protected UserBase(IModel model, int userId)
    {
        Model = model;
        Id = userId;

        AppendAuthorizedObject(Auth.ObjectType.Document, Model.Documents);
        // Append new authorized objects here...
    }

    private void AppendAuthorizedObject<T>(ObjectType type, IQueryable<T> source)
        where T : class, IAuthorizedObject
    {
        _typeToQuery.Add(typeof(T), source);
        _typeToEnum.Add(typeof(T), type);
    }

    protected IQueryable<T> Query<T>() where T : class, IAuthorizedObject
    {
        IQueryable query;
        if (!_typeToQuery.TryGetValue(typeof(T), out query))
            throw new InvalidOperationException(
                $"Unsupported object type {typeof(T)}");

        return query as IQueryable<T>;
    }

    protected byte ObjectType<T>() where T : class, IAuthorizedObject
    {
        ObjectType type;
        if (!_typeToEnum.TryGetValue(typeof(T), out type))
            throw new InvalidOperationException(
                $"Unsupported object type {typeof(T)}");

        return (byte)type;
    }

    protected Permission GetPermission<T>(int userId, long objectId)
        where T : class, IAuthorizedObject
    {
        var entities = Query<T>();
        var objectType = ObjectType<T>();
        var query =
            from obj in entities
            from p in Model.Permissions
            where
                p.ObjectType == objectType && p.ObjectId == objectId &&
                obj.Id == p.ObjectId &&
                p.UserId == userId
            select p.Permission;

        return (Permission) query.FirstOrDefault();
    }

    public abstract IQueryable<T> GetQuery<T>()
        where T : class, IAuthorizedObject;
    public abstract Permission GetPermission<T>(long objectId)
        where T : class, IAuthorizedObject;
    public abstract bool HasPermission<T>(long objectId, Permission permission)
        where T : class, IAuthorizedObject;
}

UserBase будет полезен нам при имплементации классов Administrator и User. В конструкторе он инициализирует свои члены, для того чтобы иметь возможность реализовать обобщенные методы. Метод Query возвращает набор данных из DB контекста по заданному типу, ObjectType возвращает нативное значение перечисления по типу и GetPermission возвращает полномочие по заданному идентификатору пользователя и объекта для обобщенного типа.

Теперь мы можем приступить к созданию классов Administrator и User. Что касается администратора, то здесь всё просто, поскольку администратор имеет полные права на все документы:

public class Administrator : UserBase
{
    public Administrator(IModel model, int userId)
        : base(model, userId)
    {
    }

    public override IQueryable<T> GetQuery<T>()
    {
        return Query<T>();
    }

    public override bool HasPermission<T>(long objectId, Permission permission)
    {
        return permission != Permission.None;
    }

    public override Permission GetPermission<T>(long objectId)
    {
        return Permission.Delete;
    }
}

С классом User всё гораздо интереснее: метод GetQuery должен возвращать только те документы, к которым у пользователя есть доступ. Поэтому мы должны учитывать полномочия данного пользователя. Мы реализуем это в одном запросе к БД, т.е. проделаем то, из-за чего, собственно, всё и затевалось.

public class User : UserBase
{
    public User(IModel model, int userId)
        : base(model, userId)
    {
    }

    public override IQueryable<T> GetQuery<T>()
    {
        var entities = Query<T>();
        var objectType = ObjectType<T>();

        return
            from obj in entities
            from p in Model.Permissions
            where
                p.ObjectType == objectType && p.UserId == Id &&
                obj.Id == p.ObjectId
            select obj;
    }

    public override bool HasPermission<T>(long objectId, Permission permission)
    {
        return permission == Permission.None
            ? GetPermission<T>(objectId) == Permission.None
            : GetPermission<T>(objectId) >= permission;
    }

    public override Permission GetPermission<T>(long objectId)
    {
        return GetPermission<T>(Id, objectId);
    }
}

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

Приведу пример такого класса:

public class AdvancedUser : UserBase
{
    public AdvancedUser(IModel model, int userId)
        : base(model, userId)
    {
    }

    public override IQueryable<T> GetQuery<T>()
    {
        // Advanced user can see all resources
        return Query<T>();
    }

    public override bool HasPermission<T>(long objectId, Permission permission)
    {
        if (permission == Permission.None)
            return false;

        return GetPermission<T>(objectId) >= permission;
    }

    public override Permission GetPermission<T>(long objectId)
    {
        // Return own permission if exists or Permission.Read
        return Max(GetPermission<T>(Id, objectId), Permission.Read);
    }

    private static Permission Max(Permission perm1, Permission perm2)
    {
        return (Permission) Math.Max((int) perm1, (int) perm2);
    }
}

Наконец, понадобится класс для создания конкретных реализаций интерфейса IAccessor. Выглядеть он будет примерно так:

public static class Factory
{
    public static IAccessor CreateAccessor(IPrincipal principal, IModel model)
    {
        if( IsAdministrator(principal))
            return new Administrator(model, GetUserId(principal));
        else
            return new User(model, GetUserId(principal));
    }

    private static bool IsAdministrator(IPrincipal principal)
    {
        return principal.IsInRole("SYSTEM_ADMINISTRATE");
    }

    private static int GetUserId(IPrincipal principal)
    {
        var id = 0; // TODO: Obtain user id from Thread.CurrentPrincipal here...
        return id;
    }
}

DocumentController


Теперь, когда у нас есть вся необходимая инфраструктура, мы можем легко реализовать контроллер документов:

[RoutePrefix("documents")]
public class DocumentsController : ApiController
{
    private readonly MyDbContext _db = new MyDbContext();
    private IAccessor Accessor =>
        Factory.CreateAccessor(Thread.CurrentPrincipal, _db);

    [HttpGet]
    [Route("", Name = "GetDocuments")]
    [ResponseType(typeof(IQueryable<Document>))]
    public IHttpActionResult GetDocuments()
    {
        var query = Accessor.GetQuery<Document>();
        return Ok(query);
    }

    [HttpGet]
    [Route("{id:long}", Name = "GetDocumentById")]
    [ResponseType(typeof(Document))]
    public IHttpActionResult GetDocumentById(long id)
    {
        if (!Accessor.HasPermission<Document>(id, Permission.Read))
            return NotFound();

        var document = _db.Documents.FirstOrDefault(e => e.Id == id);
        if (document == null)
            return NotFound();

        return Ok(document);
    }

    [HttpPost]
    [Route("", Name = "CreateDocument")]
    [ResponseType(typeof(Document))]
    public IHttpActionResult CreateDocument(Document document)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        _db.Documents.Add(document);
        _db.SaveChanges();

        return CreatedAtRoute("CreateDocument", new { id = document.Id }, document);
    }

    [HttpDelete]
    [Route("{id:long}", Name = "DeleteDocument")]
    [ResponseType(typeof(Document))]
    public IHttpActionResult DeleteDocument(long id)
    {
        if (Accessor.HasPermission<Document>(id, Permission.Delete))
            return NotFound();

        var document = _db.Documents.FirstOrDefault(e => e.Id == id);
        if (document == null)
            return NotFound();

        _db.Documents.Remove(document);
        _db.SaveChanges();

        return Ok(document);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
            _db.Dispose();

        base.Dispose(disposing);
    }
}

DocumentPermissionController


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

Если считать, что у нас есть класс DocumentPermissionService, который берёт на себя операции над полномочиями и разгружает контроллер, то код будет выглядеть следующим образом:

[RoutePrefix("documents")]
public class DocumentPermissionsController : ApiController
{
    private readonly MyDbContext _db = new MyDbContext();
    private readonly DocumentPermissionService _service =
        new DocumentPermissionService();
    private IAccessor Accessor =>
        Factory.CreateAccessor(Thread.CurrentPrincipal, _db);

    [HttpGet]
    [Route("{id:long}/permissions", Name = "GetPermissions")]
    [ResponseType(typeof(IQueryable<UserPermission>))]
    public IHttpActionResult GetPermissions(long id)
    {
        if (!Accessor.HasPermission<Document>(id, Permission.Write))
            return NotFound();

        var permissions = _service.GetPermissions(id);
        return Ok(permissions);
    }

    [HttpPatch]
    [Route("{id:long}/permissions", Name = "SetPermissions")]
    public HttpResponseMessage SetPermissions(
        long id, IList<PermissionDto> permissions)
    {
        if (!Accessor.HasPermission<Document>(id, Permission.Write))
            return Request.CreateResponse(HttpStatusCode.NotFound);

        string err;
        var validationCode = _service.ValidatePermissions(permissions, out err);
        if (validationCode != HttpStatusCode.OK)
            return Request.CreateResponse(validationCode, err);

        _service.SetPermissions(id, permissions);
        return Request.CreateResponse(HttpStatusCode.OK);
    }

    [Route("{id:long}/permissions/{userId:int}", Name = "DeletePermission")]
    [HttpDelete]
    public IHttpActionResult DeletePermission(long id, int userId)
    {
        if (!Accessor.HasPermission<Document>(id, Permission.Write))
            return NotFound();

        var isDeleted = _service.DeletePermission(id, userId);
        return isDeleted ? (IHttpActionResult) Ok() : NotFound();
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
            _db.Dispose();

        base.Dispose(disposing);
    }
}

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

Расширяемость


Всё меняется. У нас могут появиться новые требования и бизнес-правила. Насколько наш подход адаптивен к изменяющимся требованиям? Попытаемся представить, что может измениться в будущем.

Первое, что приходит в голову – это добавление новых типов ресурсов. Тут всё выглядит неплохо: если мы добавляем в DB модель новую сущность, скажем, Image, нам достаточно добавить новое значение перечисления ObjectType и одну строку кода в конструктор класса UserBase:

    AppendAuthorizedObject(ObjectType.Image, Model.Image);

Чуть сложнее с пользователями. Предположим, нам нужно добавить возможность группировать пользователей и назначать права на группы. Сможем ли мы относительно безболезненно внести изменения в проект?

Первое, что нужно сделать, – это добавить новый столбец AccountType в таблицу Permissions. Было бы неплохо также переименовать UserId в AccountId, поскольку теперь этот столбец будет хранить либо Id пользователя, либо Id группы в зависимости от значения AccountType.

Придётся поменять методы GetQuery в реализациях интерфейса IAccessor. Теперь нужно будет учитывать принадлежность пользователя к группе и проверять полномочия группы помимо полномочий самого пользователя.

Но в целом, подобное изменение функциональности не выглядит критическим.

Комментарии (12)


  1. Smerig
    09.11.2017 12:20

    В DocumentController нет проверки полномочий на удаление документа. Ещё непонятно, почему бы пользователей сразу не наследовать от Accessor? Соответсвенно Accessor от IAccessor.


    1. AndreyRodin Автор
      09.11.2017 14:26

      Да, вы правы, кое-что потерялось при адаптации реального проекта, поправил.
      Переименовал класс Accessor в UserBase, так, пожалуй, будет лучше.
      Спасибо за внимательное чтение и замечания!


      1. Smerig
        09.11.2017 14:47

        Ещё замечание: правильно было бы возвращать 403, а не 404, если у пользователя нет прав на ресурс. Все-таки API пилим…


        1. AndreyRodin Автор
          09.11.2017 15:34

          А вот это уже вопрос.
          Есть мнение, что с точки зрения безопасности наилучшим выбором будет возврат NotFound, поскольку это не дает потенциальному взломщику знания о том, какие ресурсы реально существуют, а какие – нет.
          Это старая дискуссия, и каждая компания выбирает свой стандарт.


  1. unsafePtr
    09.11.2017 16:17

    Почему бы не использовать стандартные Claims? Можно создать claim c типом permission, а значением будет Read, Write, Delete операции.


    1. AndreyRodin Автор
      09.11.2017 16:59

      Да, вполне можно. Класс Factory приведён лишь для иллюстрации, а реализация IPrincipal может быть самой различной, в том числе, с использованием Claims. Надо только иметь в виду, что для данного подхода необходимо наличие Id пользователя в Thread.CurrentPrincipal


  1. RomanPokrovskij
    10.11.2017 01:37

    Спасибо за матерьял. «Как делают другие» узнавать важно. Однако мое мнение будет критическим: итоговый код не выразителен (или я чего-то не уловил).

    if (!Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
    .HasPermission(id, Permission.Write))
    return NotFound();

    Когда простое и достижимое выглядело бы лучше:

    if (!_db.PrincipalHasWritePermission(id, Thread.CurrentPrincipal)
    return NotFound();


    А так еще лучше:

    return _db.Batch(id,
    (doc)=> {
    if (!doc.CheckWritePermission(Thread.CurrentPrincipal))
    return NotFound();
    // ...
    doc.DoMyBusinessMethodThatRequiresWritePermissions(...);
    return Ok();
    });

    тут уже явно отделена логика репозитория от бизнес логики (бизнес логика перенесена в методы Entity, но можно ее разместить и в сервисах).


  1. AndreyRodin Автор
    10.11.2017 12:42

    Не думаю, что перенос бизнес-логики в DB модель – это хорошая идея. Зона ответственности класса DbContext состоит в обеспечении модели сущностей, и ничего больше.
    Но вы навели меня на важную мысль. Вызовы в контроллерах, действительно, смотрятся несколько неуклюже и могут выглядеть как нарушение закона Деметры.
    Слегка подправил код, спасибо.


    1. RomanPokrovskij
      10.11.2017 16:59

      «Зона ответственности класса DbContext состоит в обеспечении модели сущностей, и ничего больше. „


      Мне не понятно какой смысл противостовлять модель и бизнес логику.
      А вот модель и DbContext это разные уровни: классы и ORM реализация. Или я не правильно использую термин Модель. В прочем я его и не использую а говорю о Entity классах, которы могут быть определены в самом базовом assembly, где нет никакого DbContex (в том же assembly могут быть interface cервисов которые бытут реализованы уже в другом assembly где есть EF и определен DbContext). И в этом самом базовом assembly (где нет никакого DbContext) я могу реализовать бизнес логику (посредством манипуляций с полями и коллекциями Enitity, и вызовом служб чьи interface определены здесь же). _db.Batch(id, func) заботится о том чтобы подать мне Entity и службы, а под конец вызовет Save на DbContext (и закроит транзакцию).
      Это всегда возможно, но не всегда удобно.

      Сомневаюсь что написал понятно и что комментарий интересный, но надеюсь это узнать из вашего отзыва.


      1. AndreyRodin Автор
        10.11.2017 19:15

        Класс DbContext предназначен для обеспечения чтения и записи в базу данных. Проще говоря, он должен содержать только свойства DbSet — и ничего более. Попытка добавить в этот класс бизнес-логику выглядит как смешение зон ответственности. Обычно этот класс используется многими контроллерами совместно. Что будет, если ради каждого контроллера мы начнём добавлять свою бизнес-логику в наш DbContext?
        Классы Entity тем более должны содержать исключительно свойства, соответствующие полям в базе. Сущности неразрывно связаны с нашим классом DbContext.
        Бизнес-логика – это отдельный слой более высокого порядка. В данном примере логика авторизации сосредоточена в реализациях интерфейса IAccessor.


  1. RomanPokrovskij
    10.11.2017 23:22

    Классы Entity тем более должны содержать исключительно свойства, соответствующие полям в базе

    Почему только поля? Почему тем более?
    Это ваше мнение, или есть соглашение «entity не должны содержать методы», или действительно известны ситуации когда методы вылазят боком?

    Пока остаюсь при мнении что это просто условности передачи параметров. И по большому счету нет никакой разницы между вызовами:

    document.Sign(key, encodingRealization);
    и
    var service = new Service(encodingRealization);
    service.Sign(document, key);
    


    IModel кстати сам по себе ничего такого в код не приносит. Везде где на него ссылаются можно просто IQueryable передавать. Заметьте интерфейс не из методов а из геттеров — звоночек, я б напрягся.


  1. LeonThundeR
    11.11.2017 18:42

    Спасибо за статью! Когда почти дочитал очень хотелось написать в коменте: Почему не сделать возможность задания прав как для конкретных пользователей так и для ролей. Но Вы все же это в принципе учли в самом конце статьи ))