К основному контенту

Кастомизируем ASP.NET Identity 2.0

Посмотрев видео Securing (ASP.NET) web API-based architectures с DevWeek о новой системе безопасности и аутентификации ASP.NET Identity 2.0 в последней версии MVC 5.1 и Web API 2.0 я решил, что надо бы свой проект перенести на новые рельсы, потому что

1. Новая система безопасности создана по открытой спецификации OWIN, что позволяет немного глубже заглянуть «под капот», да и в будущем может дать большую гибкость в выборе ОС и сервера.

2. В новой системе безопасности нативно поддерживается безопасность Web API 2.0, что мне как раз и нужно.

3. В новых проектах из коробки поддерживаются OAuth 2.0 аутентификация.

Полагаю, что при разработке на ASP.NET MVC 1 - 4, многие избавляются от стандартной системы Forms аутентификации от Microsoft. Я это делал переопределением Membership Provider, Roles Provider и Identity, в общем, примеров в сети достаточно. Однако, по новой системе ASP.NET Identity 2.0 информации пока не так уж и много, поэтому мой пост должен быть полезен.

Как известно, при создании нового проекта из темплейта ASP.NET генерируется готовая схема и база данных с таким списком таблиц:

Моя цель и цель этого поста, описать процесс перехода от стандартных ASP.NET таблиц, к своим собственным, при этом оставив саму систему аутентификации работать из коробки. Это означает, что также легко можно будет включить и выключить аутентификацию от сторонних провайдеров (Google, Facebook, Вконтакте, и т.д.) или прикрутить её к WebAPI 2.0.

В своих проектах я использую структуру с разделением Представления, Модели и Логики, что мне как раз и позволяет сейчас производить минимальные изменения в Модели и Логике, а в основном ковыряться только в последней версии Представлении: ASP.NET MVC 5.1. В качестве модели данных я использую Reverse Engineered POCO классы , поэтому я могу показать какие изменения мне нужно было сделать как в схеме, так и в классах.

Итак, у меня в схеме данных используется свой собственный объект User, таблицы ролей, их связи и т.д.

Рис. Схема БД.

1.

Сначала нужно добавить недостающие таблицы и поля в нашу схему данных. Для начала нужно заглянуть в те таблицы, которые ASP.NET генерирует по умолчанию. Можно создать и запустить пустой проект ASP.NET MVC 5 и в Visual Studio подключиться к файлу сгенерированной базы данных, которая лежит в App_Data (по умолчанию скрыта, нужно выбрать Show All Files в Solution Explorer). Либо можно пройти по этой ссылке и скопировать недостающие колонки прямо в скрипт или программу моделирования. Сравнение выявило, что мне нужно добавить поля в таблицу Users, изменив при этом тип ключа с INTEGER на NVARCHAR (то же самое и в таблице Roles), а также нужно было добавить таблицы UserClaims и UserLogins.

Рис. Слева готовая новая таблица Users, справа старая.

Рис. Готовая схема.

2.

Далее, я как обычно, в программе моделирования БД сгенерировал скрипт новой базы данных, создал базу и выполнил скрипт. После чего в Visual Studio сгенерировал полную модель классов.

Рис. Готовая модель.

3.

Отключаем свой старый проект ASP.NET MVC 1-4, у меня он назывался Phi.Web, и создаем пустой новый проект MVC 5.1 назвав его WebApp (я делаю новый проект, чтобы было проще понять где и что поменять именно в системе аутентификации, остальную логику потом можно из старого проекта просто перенести в новый). В References нового проекта подключаем свои проекты типа Model, Repository и т.д. Теперь можно приступать к основным изменениям в коде.

4.

В принципе, эти шаги описаны в статье Overview of Custom Storage Providers for ASP.NET Identity, но написана она на английском, да и к тому же не полностью описывает процесс. Поэтому я позволю себе немного повториться. Чтобы сразу увидеть все места, где необходимо сделать изменения, я через NuGet сделал Uninstall расширению Microsoft ASP.NET Identity EntityFramework. Это удалило зависимости вроде готового IdentityUser, UserStore, RoleStore и, как Вы наверное уже догадываетесь, нам предстоит их создать самостоятельно. Теперь при компиляции мы видим несколько файлов с проблемными местами, в них то мы и будем вносить исправления:

1. App_Start/Startup.Auth.cs

2. App_Start/IdentityConfig.cs

3. Models/IdentityModels.cs

4. Controllers/AccountController.cs

5.

Необходимо реализовать свои собственные классы, реализующие интерфейсы IUserStore, IRoleStore, IClaimStore, IUserRoleStore, IUserLoginStore. На эти интерфейсы полагается система ASP.NET Identity 2.0, поэтому реализуем их в виде 2х классов: один UserStore (наследованный от IUserStore, IClaimStore, IUserRoleStore, IUserLoginStore) и один RoleStore (наследованный от IRoleStore). Пример моего кода для RoleStore, использующего IoC Framework (пример с MySQL тут):

class RoleStore : IRoleStore<Role>
{
    #region Private fields

    /// <summary>
    /// The _users repository.
    /// </summary>
    private readonly IRepository<PhiUser> _usersRepository;

    /// <summary>
    /// The _roles repository.
    /// </summary>
    private readonly IRepository<Role> _rolesRepository;

    /// <summary>
    /// The _user roles repository.
    /// </summary>
    private readonly IRepository<UserRole> _userRolesRepository;

    #endregion

    /// <summary>
    /// Initializes a new instance of the <see cref="RoleStore"/> class.
    /// </summary>
    /// <param name="usersRepository">The users repository.</param>
    /// <param name="rolesRepository">The roles repository.</param>
    /// <param name="userRolesRepository">The user roles repository.</param>
    public RoleStore(
        IRepository<PhiUser> usersRepository,
        IRepository<Role> rolesRepository,
        IRepository<UserRole> userRolesRepository)
    {
        this._usersRepository = usersRepository;
        this._rolesRepository = rolesRepository;
        this._userRolesRepository = userRolesRepository;
    }

    /// <summary>
    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
    /// </summary>
    public void Dispose()
    {
    }

    /// <summary>
    /// Inserts role in asynchronous mode.
    /// </summary>
    /// <param name="role">The role.</param>
    /// <returns>Task result.</returns>
    /// <exception cref="System.ArgumentNullException">Role is null.</exception>
    public Task CreateAsync(Role role)
    {
        if (role == null)
        {
            throw new ArgumentNullException("role");
        }

        _rolesRepository.Insert(role);

        return Task.FromResult<object>(null);
    }

    /// <summary>
    /// Deletes role in asynchronous mode.
    /// </summary>
    /// <param name="role">The role.</param>
    /// <returns>Task result.</returns>
    /// <exception cref="System.ArgumentNullException">Role is null.</exception>
    public Task DeleteAsync(Role role)
    {
        if (role == null)
        {
            throw new ArgumentNullException("user");
        }

        _rolesRepository.Delete(role);

        return Task.FromResult<Object>(null);
    }

    /// <summary>
    /// Searches role by Id in asynchronous mode.
    /// </summary>
    /// <param name="role">The role Id.</param>
    /// <returns>Task result.</returns>
    /// <exception cref="System.ArgumentNullException">Role Id is null.</exception>
    public Task<Role> FindByIdAsync(string roleId)
    {
        if (String.IsNullOrEmpty(roleId))
        {
            throw new ArgumentNullException("roleId");
        }

        Role result = _rolesRepository.GetById(roleId);

        return Task.FromResult<Role>(result);
    }


    /// <summary>
    /// Searches role by name in asynchronous mode.
    /// </summary>
    /// <param name="role">The role name.</param>
    /// <returns>Task result.</returns>
    /// <exception cref="System.ArgumentNullException">Rolename is null.</exception>
    public Task<Role> FindByNameAsync(String roleName)
    {
        if (String.IsNullOrEmpty(roleName))
        {
            throw new ArgumentNullException("roleName");
        }

        Role result = this._rolesRepository.Table.ToList().FirstOrDefault(x => x.Name == roleName);

        return Task.FromResult<Role>(result);
    }

    /// <summary>
    /// Updates role in asynchronous mode.
    /// </summary>
    /// <param name="role">The role.</param>
    /// <returns>Task result.</returns>
    /// <exception cref="System.ArgumentNullException">Role is null.</exception>
    public Task UpdateAsync(Role role)
    {
        if (role == null)
        {
            throw new ArgumentNullException("user");
        }

        _rolesRepository.Update(role);

        return Task.FromResult<Object>(null);
    }
}

Урезанный пример UserStore, там всё по такому же принципу, что и RoleStore (пример с MySQL тут):

public class UserStore : IUserStore<PhiUser>,
                         IUserClaimStore<PhiUser>,
                         IUserLoginStore<PhiUser>,
                         IUserRoleStore<PhiUser>,
                         IUserPasswordStore<PhiUser>
{
    #region Private fields

    /// <summary>
    /// The _users repository.
    /// </summary>
    private readonly IRepository<PhiUser> _usersRepository;

    ...

    #endregion

    #region Constructor

    /// <summary>
    /// Initializes a new instance of the <see cref="UserStore"/> class.
    /// </summary>
    /// <param name="usersRepository">The users repository.</param>
    /// ...
    public UserStore(IRepository<PhiUser> usersRepository, ... )
    {
        this._usersRepository = usersRepository;
        ...
    }

    #endregion

    #region IUserStore
    
    /// <summary>
    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
    /// </summary>
    public void Dispose()
    {
    }

    /// <summary>
    /// Insert a new PhiUser in the UserTable.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User is null.</exception>
    public Task CreateAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Deletes a user.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    public Task DeleteAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Returns an PhiUser instance based on a userId query.
    /// </summary>
    /// <param name="userId">The user's Id.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User id is null.</exception>
    public Task<PhiUser> FindByIdAsync(String userId)
    {
        ...
    }

    /// <summary>
    /// Returns an PhiUser instance based on a userName query.
    /// </summary>
    /// <param name="userName">The user's nam.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">Username is null.</exception>
    public Task<PhiUser> FindByNameAsync(String userName)
    {
        ...
    }

    /// <summary>
    /// Updates the UsersTable with the PhiUser instance values.
    /// </summary>
    /// <param name="user">PhiUser to be updated.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User is null.</exception>
    public Task UpdateAsync(PhiUser user)
    {
        ...
    }

    #endregion

    #region IUserClaimStore

    /// <summary>
    /// Inserts a claim to the UserClaimsTable for the given user.
    /// </summary>
    /// <param name="user">User to have claim adde.</param>
    /// <param name="claim">Claim to be adde.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or claim is null.</exception>
    public Task AddClaimAsync(PhiUser user, Claim claim)
    {
        ...
    }

    /// <summary>
    /// Returns all claims for a given user.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User is null.</exception>
    public Task<IList<Claim>> GetClaimsAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Removes a claim froma user.
    /// </summary>
    /// <param name="user">User to have claim removed.</param>
    /// <param name="claim">Claim to be removed.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or claim is null.</exception>
    public Task RemoveClaimAsync(PhiUser user, Claim claim)
    {
        ...
    }

    #endregion

    #region IUserLoginStore

    /// <summary>
    /// Inserts a Login in the UserLoginsTable for a given User.
    /// </summary>
    /// <param name="user">User to have login added.</param>
    /// <param name="login">Login to be added.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or login is null.</exception>
    public Task AddLoginAsync(PhiUser user, UserLoginInfo login)
    {
        ...
    }

    /// <summary>
    /// Returns an PhiUser based on the Login info.
    /// </summary>
    /// <param name="login">User login.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">Login is null.</exception>
    public Task<PhiUser> FindAsync(UserLoginInfo login)
    {
        ...
    }

    /// <summary>
    /// Returns list of UserLoginInfo for a given PhiUser.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User is null.</exception>
    public Task<IList<UserLoginInfo>> GetLoginsAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Deletes a login from UserLoginsTable for a given PhiUser.
    /// </summary>
    /// <param name="user">User to have login remove.</param>
    /// <param name="login">Login to be remove.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or login is null.</exception>
    public Task RemoveLoginAsync(PhiUser user, UserLoginInfo login)
    {
        ...
    }

    #endregion

    #region IUserRoleStore

    /// <summary>
    /// Inserts a entry in the UserRoles table.
    /// </summary>
    /// <param name="user">User to have role adde.</param>
    /// <param name="roleName">Name of the role to be added to use.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or rolename is null.</exception>
    public Task AddToRoleAsync(PhiUser user, String roleName)
    {
        ...
    }

    /// <summary>
    /// Returns the roles for a given PhiUser.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User is null.</exception>
    public Task<IList<String>> GetRolesAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Verifies if a user is in a role.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <param name="role">Current role.</param>
    /// <returns>Task with result.</returns>
    /// <exception cref="ArgumentNullException">User or role is null.</exception>
    public Task<Boolean> IsInRoleAsync(PhiUser user, String role)
    {
        ...
    }

    /// <summary>
    /// Removes a user from a role.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <param name="role">Current role.</param>
    /// <returns>Throw exception.</returns>
    /// <exception cref="NotImplementedException">Always thrown.</exception>
    public Task RemoveFromRoleAsync(PhiUser user, String role)
    {
        throw new NotImplementedException();
    }

    #endregion

    #region IUserPasswordStore

    /// <summary>
    /// Returns the PasswordHash for a given PhiUser.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    public Task<String> GetPasswordHashAsync(PhiUser user)
    {
        ...
    }

    /// <summary>
    /// Verifies if user has password.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <returns>Task with result.</returns>
    public Task<Boolean> HasPasswordAsync(PhiUser user)
    {
        ...
    }
    
    /// <summary>
    /// Sets the password hash for a given PhiUser.
    /// </summary>
    /// <param name="user">Current user.</param>
    /// <param name="passwordHash">Password hash.</param>
    /// <returns>Task with result.</returns>
    public Task SetPasswordHashAsync(PhiUser user, String passwordHash)
    {
        ...
    }

    #endregion
}

6.

Далее, если Вы используете IoC Engine, нужно зарегистрировать в нём эти классы, чтобы он мог создавать их объекты. Я использую Simple Injector, и в моём случае это делается так:

...
   _instance.Register<IUserStore<PhiUser>, UserStore>();
   _instance.Register<IUserClaimStore<PhiUser>, UserStore>();
   _instance.Register<IUserLoginStore<PhiUser>, UserStore>();
   _instance.Register<IUserRoleStore<PhiUser>, UserStore>();
   _instance.Register<IUserPasswordStore<PhiUser>, UserStore>();
   _instance.Register<IRoleStore<Role>, RoleStore>();
...

7.

Далее можно полностью удалить или закомментировать код в 3. Models/IdentityModels.cs.

ApplicationUser везде переименовываем в User (ну или как он у вас в модели называется). ApplicationUserManager из 2. App_Start/IdentityConfig.cs тоже может превратиться в MyUserManager, или что-то подобное. В том же файле в вызове конструктора UserManager нужно подменить входной параметр на Ваш. Для Simple Injector синтаксис получения объекта UserStore выглядит так:

...
   var manager = new PhiUserManager(ModelContainer.Instance.GetInstance<UserStore>());
...

8.

Теперь нужно подправить файл 4. Controllers/AccountController.cs таким же образом - правильно прокинуть метод создания объекта UserStore.

... 
    
public AccountController(IUserStore<PhiUser> userStore)
    {
        UserManager = new PhiUserManager(userStore);
    }
...

9.

Также, нужно подменить вызовы метода GenerateUserIdentityAsync на актуальные. Я решил этот вопрос переписав его в Helper класс:

...
    public class MyUserHelpers
    {
        public static async Task<ClaimsIdentity> GenerateUserIdentityAsync(PhiUser user, UserManager<PhiUser> manager)
        {
            // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
            var userIdentity = await manager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
            // Add custom user claims here
            return userIdentity;
        }
    }
...

10.

И последнее, в 1. App_Start/Startup.Auth.cs необходимо указать методы создания менеджера пользователей и контекста базы. Я сделал так:

...
    public static phiContext CreateContext()
    {
        return ModelContainer.Instance.GetInstance<phiContext>();
    }

    // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864
    public void ConfigureAuth(IAppBuilder app)
    {
        // Configure the db context and user manager to use a single instance per request
        app.CreatePerOwinContext(CreateContext);
        app.CreatePerOwinContext<PhiUserManager>(PhiUserManager.Create);
...

11.

Теперь, Ваш проект должен компилироваться. Ну и конечно же, регистрация и аутентификация должны работать, только теперь уже с полностью Вашей БД.

Список ссылок почитать:

1. Что такое проект Katana и Owin

2. Краткое и мощное видео-введение во внутренности Katana (советую посмотреть все уроки, т.к. одним тема не ограничивается)

3. Что вообще за новая такая система

4. Прикручивание MySQL источника данных для ASP.NET Identity

5. Готовые скрипты создания таблиц ASP.NET проекта

Комментарии

  1. подруби, пожалуйста для блоков кода плагин какой нибудь, чтобы как в студии отображалось. а то сплошной текст - тяжеловато для чтения) а так статья отличная, на днях сам попробую по твоему примеру.

    ОтветитьУдалить
  2. Большое спасибо - отличная статья. Вопрос: после этой кастомизации можно будет как и раньше получать Id юзера через User.Identity.GetUserId()? Не могу сообразить, не отвалится ли это.

    ОтветитьУдалить
    Ответы
    1. Конечно, работает у меня на живом проекте. Просто нужно в модели классы User и Role отнаследовать от IUser и IRole из Microsoft. AspNet. Identity, в котором в свою очередь реализован extension GetUserId ().

      Удалить
    2. Понял, еще раз спасибо. Даже не ждал на русском языке эту информацию найти.

      Удалить
  3. Вот здесь пример кастомизации с помощью EF Database-First
    https://github.com/kriasoft/AspNet-Server-Template

    ОтветитьУдалить
  4. Этот комментарий был удален автором.

    ОтветитьУдалить
  5. А можно картинки обновить? А то отвалились

    ОтветитьУдалить
    Ответы
    1. Поправил. Задолбал меня этот BlogSpot, на нём все время картинки отваливаются куда-то... если что копия блога на английском есть http://antonyarkov-001-site2.smarterasp.net/

      Удалить
  6. А как перенести логику asp identity в web api? У меня вся логика работы с БД содержится в web api, и не хочется чтоб клиент на (MVC 5) лез сам в БД, для авторизации. Так как нужно открывать базу для всех IP.

    ОтветитьУдалить
    Ответы
    1. В Visual Studio создайте пустой проект SPA (Single Page Application) приложения, в нём уже по умолчанию есть пример авторизации Web API: происходит она в файле /Scripts/app/app.viewmodel.js в строке dataModel.setAccessToken(fragment.access_token);

      Суть её, как видно из кода, заключается в получении и сохранении в клиентском sessionStorage временного Bearer Access Token и подстановке его в каждом вашем последующем WebAPI вызове, как-то так:
      $.ajax({
      type: 'GET',
      url: '/api/MyApiMethod',
      headers: {
      'Authorization': 'Bearer ' + sessionStorage.getItem('accessToken')
      },
      data: data,
      success: function (data) {
      // Обработка данных
      }

      Получить этот токен пользователь сможет только после авторизации через социальные сети или стандартно введи логин-пароль.

      Лично у меня в проекте MVC и WebAPI собраны воедино, и всё работает описанным образом.

      Удалить

Отправить комментарий

Популярные сообщения из этого блога

Делаем себе бесплатный VPN на Amazon EC2

Читать этот пост в Telegraph. Другие посты в канале в Telegram. Кто только не расписывал уже пошаговые инструкции по этой теме. Однако, время идёт, ПО меняется, инструкции нуждаются в обновлении, а люди в современной России всё больше нуждаются в применении VPN. Я собираюсь описать все шаги для создания бесплатного сервера на Amazon EC2 с операционной системой Linux и необходимые команды для настройки VPN сервера на нём. Чтобы не повторяться о деталях, которые были много раз описаны на русскоязычных и англоязычных ресурсах, по ходу статьи я просто приведу целую кипу ссылок, где можно почерпнуть необходимую информацию, а где информация устарела - опишу подробнее что нужно сдеать. В итоге, сервер будет доступен для вас из любой точки планеты, с любой операционной системы, и бесплатно (с определёнными ограничениями по трафику). Шаг первый - Регистрируемся на Amazon AWS Нужно зайти на сайт https://aws.amazon.com/ru и сразу перейти к Регистрации, нажав одноимённую кнопку. При р

В помощь программисту: инструкции по работе с Ubuntu сервером

Программистам чаще приходится писать код и заботиться о его чистоте, правильных абстракциях в коде, корректных зависимостях и прочих сложностях профессии. При этом, настройка и обслуживание серверов, хоть и связанная область - это отдельный навык, необходимый не каждому, и помнить о котором в деталях сложно. Поэтому, я делаю ряд микро-инструкций, которыми буду пользоваться и сам, когда необходимо. Это не статьи, а пошаговые помощники, которые я буду дополнять и наполнять по мере надобности. Делаем бесплатный VPN на Amazon EC2 Создание ключей SSH Подключение к серверу через SSH Передача файла с Linux сервера наWindows машину Делаем VPN сервер на Ubuntu 20.04 используя OpenVPN и EasyRSA  Отображение GUI с Linux сервера на Windows машине

Выбираем все плюсы из трех парадигм Entity Framework

Между парадигмами разработки с Entity Framework (Code First, Model First, Database First) я выбрал промежуточную, потому что ни одна меня не устраивала полностью. В Code First меня радуют чистые POCO классы, но не устраивает невозможность моделирования базы. В Database First и Model First мне не нравится генерация EDMX и другого всего лишнего. Таким образом, я нашел для себя такое решение: 1. Я моделирую схему в любой удобной программе (тут любая внешняя программа моделирования, генерирующая SQL Server-совместимые скрипты генерации базы) Рис. Смоделированная схема БД. 2. Создаю базу в SQL Management Studio 3. Делаю Reverse Engineering базы в POCO классы (как в Code First) с помощью плагина Entity Framework Power Tools Рис. Установленный плагин для Reverse Engineer. Рис. Вот так делается Reverse Engineer базы данных в POCO классы. Рис. Результат генерации POCO классов на основе базы данных: папочка Models с готовым контекстом, классами объектов и маппинг-классами.