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

Изменения в ASP.NET 5


В контексте ASP.NET 5 все компоненты стека были обновлены или же полностью переписаны. Изменения не обошли стороной и ASP.NET Identity, что не может не радовать. В том числе, была обновлена и система авторизации, причём это действительно качественные изменения (IMHO). Далее мы рассмотрим, какие именно были внесены изменения в системы аутентификации и авторизации.

ASP.NET Identity


На Хабре уже была обзорная статья на тему ASP.NET Identity, потому пропустим вводную часть.

ASP.NET Identity предоставляет инструментарий для работы с пользователями и их аутентификацией. Эта система состоит из библиотеки Microsoft.AspNet.Identity с описанием основных классов и абстракций в виде интерфейсов хранилища пользователей и т.д. Microsoft.AspNet.Identity.EntityFramework — библиотека, которая содержит реализацию пользователя/роли и хранилищ на основании Entity Framework 7.

Первое, на что хочется обратить внимание, это отсутствие интерфейсов для пользователей и ролей. Identity не диктует нам, каким должен быть наш пользователь. При настройке достаточно указать, какие классы пользователя и роли мы хотим использовать. Достигается это за счёт того, что класс UserManager делегирует хранилищу чтение/запись свойств пользователя. Например, такие операции класса UserManager, как GetUserName, SetUserName и ChangePassword приводят к вызовам однотипных методов у UserStore.

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

Список интерфейсов для хранилища пользователей:

  • IUserLoginStore<TUser>
  • IUserRoleStore<TUser>
  • IUserClaimStore<TUser>
  • IUserPasswordStore<TUser>
  • IUserSecurityStampStore<TUser>
  • IUserEmailStore<TUser>
  • IUserLockoutStore<TUser>
  • IUserPhoneNumberStore<TUser>
  • IQueryableUserStore<TUser>
  • IUserTwoFactorStore<TUser>

В простом случае нам достаточно только IUserStore и IUserPasswordStore.
  • IUserStore — управление именем и ID пользователя.
  • IUserPasswordStore — установка и чтение пароля.

ASP.NET 5 предоставляет свою инфраструктуру для настройки конвейера приложения и его сервисов (встроенный DI контейнер).

Подключение сервисов:

public void ConfigureServices(IServiceCollection services)
{
    // more here

    // регистрация сервисов Identity
    services.AddIdentity<ApplicationUser, IdentityRole>()
        // регистрация UserStore/RoleStore на основе EF
        .AddEntityFrameworkStores<ApplicationDbContext>()
        // регистрация стандартных токен провайдеров 
        .AddDefaultTokenProviders();

    // more here
}

Активация middleware-аутентификации:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    // more here
    app.UseIdentity(); // настройка Cookie Auth Middleware
    // more here
}

Это настройка по умолчанию.

За счёт модульности и использования DI самого ASP.NET, Identity позволяет заменять свои компоненты без полного переписывания. Например, мы хотим изменить хэширование паролей, валидатор пользователя (проверяющий уникальность и т.д.), подключить свои хранилища пользователей и ролей, а также указать свой тип Claim для хранения ролей:

services.AddScoped<IPasswordHasher<User>, MyPasswordHasher>();

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequiredLength = 8;
    options.SignIn.RequireConfirmedEmail = true;
    options.ClaimsIdentity.RoleClaimType = "MyRoleClaimType";
})
    .AddUserStore<MyStore>()
    .AddRoleStore<MyRoleStore>()
    .AddUserValidator<MyUserValidator>()
    .AddDefaultTokenProviders();


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

Конфигурирование Identity теперь осуществляется с помощью Action<IdentityOptions>, как представлено в примере выше.

Можно заметить, что улучшилось разделение ответственности. UserManager занимается только управлением пользователями, делегируя конкретные задачи на другие компоненты, такие как хранилище пользователей, хэшер паролей, валидатор пользователя. Для конфигурирования нет необходимости заменять конкретные классы, для этого мы используем IdentityOptions.

Ещё одно небольшое замечание по Identity: учитывая опыт Identity 2 и проблему с производительностью при поиске пользователей, класс пользователя может иметь свойства NameNormilized и EmailNormalized. Класс UserManager при обновлении пользователя автоматически обновляет и эти поля, используя интерфейс IUserStore. Сделано это для того, чтобы избежать вызова функций UPPER() на стороне БД. К тому же, различные хранилища могут производить регистрозависимый поиск, а при наличии поля с нормализованной строкой можно производить поиск независимо от хранилища.

В целом можно отметить, что влияние Identity на код приложения уменьшилось. Система стала более модульной и изменяемой/расширяемой за счёт адаптации к ASP.NET 5. С точки зрения использования, это практически та же система, что и раньше, с привычной механикой работы. Но есть и ложка дёгтя: осталась старая проблема, которая присутствует ещё с MembershipProvider (.NET 2.0): если наше хранилище не описывает какой-то из интерфейсов, то при вызове соответствующего метода у UserManager мы получим NotSupportedException.

Далее перейдем к аутентификации


В примере выше, в методе Configure, мы вызываем метод расширения app.UseIdentity(). Под капотом этого метода происходит подключение middleware, настройка пайплайна cookie аутентификации:

var options = app.ApplicationServices.GetRequiredService<IOptions<IdentityOptions>>().Value;
app.UseCookieAuthentication(options.Cookies.ExternalCookie);
app.UseCookieAuthentication(options.Cookies.TwoFactorRememberMeCookie);
app.UseCookieAuthentication(options.Cookies.TwoFactorUserIdCookie);
app.UseCookieAuthentication(options.Cookies.ApplicationCookie);

В свою очередь, UseCookieAuthentication подключает middleware:

app.UseMiddleware<CookieAuthenticationMiddleware>(options);

Для взаимодействия с middleware аутентификации необходимо использовать класс AuthenticationManager, который висит на HttpContext. Допустим, мы не хотим использовать Identity для управления нашими пользователями, а предпочитаем делать это самостоятельно:

// SignOut для текущего пользователя
var authenticationDescriptions = HttpContext.Authentication.GetAuthenticationSchemes();
foreach (var authenticationDescription in authenticationDescriptions)
{
    HttpContext.Authentication.SignOutAsync(authenticationDescription.AuthenticationScheme);
}

// Аутентифицированный пользователь в ASP.NET представлен в виде ClaimsIdentity
// Создаем ClaimsIdentity и добавляем необходимые данные
var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, "123"),
            new Claim(ClaimsIdentity.DefaultNameClaimType, "MyUserName"),
            new Claim(ClaimTypes.Role, "Administrators"),
            new Claim("Lastname", "MyLastName"),
            new Claim("email", "bob@smith.com")
        };

var id = new ClaimsIdentity(claims, "MyCookiesScheme", ClaimsIdentity.DefaultNameClaimType, ClaimTypes.Role);
var principal = new ClaimsPrincipal(id);

// Аутентификация ClaimsPrincipal
HttpContext.Authentication.SignInAsync("MyCookiesScheme", new ClaimsPrincipal(id));

Примерно по такой же схеме действует и SignInManager из ASP.NET Identity. При аутентификации нам необходимо указать, с помощью какой именно схемы аутентификации мы логиним пользователя. На основании этого параметра AuthenticationManager определяет, с каким middleware необходимо взаимодействовать.

Схема работы практически идентична MVC 5 и OWIN.

Авторизация


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

В приложениях с большим количеством ролей существует проблема использования атрибута [Authorize] и проверок вида user.IsInRole. При изменении требований безопасности внесение изменений в приложение является крайне трудоемким и подверженным ошибкам процессом. Необходимо найти все места, где используется та или иная роль, или же необходимо найти и изменить все места, где происходит обращение к определённой функции приложения. Т.е. код авторизации размыт по приложению.

Приступим. Помимо проверок на членство пользователя, в ролях у нас появились новые инструменты авторизации. По умолчания атрибут [Authorize] теперь принимает не список ролей, а «политики».

(атрибут [Authorize(Roles=””)] поддерживается ради обратной совместимости)

services.AddAuthorization(options =>
{
    options.AddPolicy("SuperAdministrationPolicy", policy =>
    {
        policy.RequireRole("Admins");
        policy.RequireClaim("adminlevel", "Level1", "Level2");
    });
    options.AddPolicy("RegularAdministrationPolicy", policy =>
    {
        policy.RequireRole("Admins");
    });
});

AuthorizationPolicy содержит в себе набор Requirements (требований). По умолчанию мы имеем Requirements для проверки ролей, клеймов и имени пользователя. Также можно описывать Requirement в виде отдельного класса:

public class AdminLevelRequirement : AuthorizationHandler<AdminLevelRequirement>, IAuthorizationRequirement
{
    private readonly string _adminLevel;

    public AdminLevelRequirement(string adminLevel)
    {
        _adminLevel = adminLevel;
    }

    protected override void Handle(AuthorizationContext context, AdminLevelRequirement requirement)
    {
        var user = context.User;
        if (user.IsInRole("Admins") && user.HasClaim("adminlevel", _adminLevel))
        {
            context.Succeed(requirement);
        }
    }
}

// использование requirement описанного в виде класса
options.AddPolicy("AdminLevel1Policy", policy =>
                {
                    policy.AddRequirements(new AdminLevelRequirement("Level1"));
                });

В данном примере мы наследуем AuthorizationHandler<> и IAuthorizationRequirement, но это не обязательно. Здесь AdminLevelRequirement описывает и требование, и обработчика требования. В простом случае это вполне подходящее решение. Но если нужно больше гибкости, то мы можем разделять требования и обработчики. Класс нашего требования (Requirement) необходимо унаследовать от IAuthorizationRequirement (маркерный интерфейс), и он будет нести в себе только описание параметров требования. Например, уровень администратора, его уровень доступа. В свою очередь, классы для обработки требований наследуют AuthorizationHandler<> и содержат только методы Handle().

Пример:

// класс описывающий требование
public class AdminLevelRequirement : IAuthorizationRequirement
{
    public string AdminLevel { get; }

    public AdminLevelRequirement(string adminLevel)
    {
        AdminLevel = adminLevel;
    }
}
// обработчик требования
public class AdminLevelRequirementHandler : AuthorizationHandler<AdminLevelRequirement>
{
    protected override void Handle(AuthorizationContext context, AdminLevelRequirement requirement)
    {
        var user = context.User;
        if (user.IsInRole("Admins") && user.HasClaim("adminlevel", requirement.AdminLevel))
        {
            context.Succeed(requirement);
        }
    }
}
// добавление требования к политике
policy.AddRequirements(new AdminLevelRequirement("Level1"));
// добавление обработчиков требования в коллекцию сервисов приложения
services.AddInstance<IAuthorizationHandler>(new AdminLevelRequirementHandler());

Обработчик может возвращать AuthorizationContext.Suceed() или AuthorizationContext.Fail(). В случае, если у нас описано несколько обработчиков для одного требования, мы можем выполнять их по принципу OR или AND. Для выполнения в стиле OR, обработчики могут возвращать только Succeed(). Если же нужна логика обработки по AND, то мы можем воспользоваться возвратом Fail(). Т.е. если в обработчиках никогда не вызывается Fail(), то при удачной проверке любого из обработчиков пользователю будет предоставлен доступ.

Переходим к использованию.

Использование политик авторизации декларативным способом с помощью атрибута:

[Authorize("PoliсyName")]
public IActionResult ActionName()

В инструментарий добавлен сервис авторизации, который можно получить путём объявления зависимости в своем классе:

// класс описывающий требование
public class HomeController : Controller
{
    private readonly IAuthorizationService _authorizationService;


    public HomeController(IAuthorizationService authorizationService)
    {
        _authorizationService = authorizationService;
    }
}

Воспользуемся сервисом для проверки соответствия пользователя политике авторизации:

if (await _authorizationService.AuthorizeAsync(User, "MyPolicy"))

Помимо политик безопасности, нам доступна авторизация на основе ресурсов (Resource Based Authorization). Она применяется в случаях, когда авторизация зависит от объекта, к которому осуществляется доступ. Для использования этого типа авторизации нам необходимо реализовать свой хендлер, который обрабатывает определённый Requirement/Policy и ресурс:

public class TenantAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, Tenant>
{
    protected override void Handle(AuthorizationContext context,
        OperationAuthorizationRequirement requirement,
        Tenant resource)
    {
        if (requirement.Name == "Update")
        {
            if (!context.User.HasClaim("adminlevel", "Level1"))
            {
                context.Fail();
                return;
            }

            if (!context.User.HasClaim("region", resource.Region))
            {
                context.Fail();
                return;
            }

            context.Succeed(requirement);
            return;
        }

        context.Fail();
    }
}

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

OperationAuthorizationRequirement — это класс требования по умолчанию, который имеет только свойство Name. Данный класс можно использовать для простых сценариев и проверок доступа пользователя к именованным операциям, например, Read, Update, Delete и так далее. Само собой, в этом случае мы можем описывать и собственные классы требований.

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

Полезные ссылки:

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