Для начала расскажу, что приложение, которое я разрабатывал, долго существовало на небольшом «подстольном» сервере в виде прототипа, которым в работе пользовалось небольшое число сотрудников. По прошествии некоторого времени, руководство приняло решение тиражировать это приложение в пром – с переносом на пром-сервер и организацией доступов к нему сотрудникам всего структурного подразделения.

Естественно, как это всегда бывает, сопровождение выдало нам список требований, которым должны соответствовать приложения, размещаемые на пром-серверах. Одним из таких требований было реализация авторизации по учетной записи Windows, а старую авторизацию по логину/паролю использовать было нельзя. О том, с какими подводными камнями мы столкнулись в ходе реализации такой, казалось бы, простой фичи, и как мы их решили, и пойдет речь в этом посте. Как я и упомянул ранее, в начальной точке этой истории у нас было классическое MVC-приложение. Информация о пользователях, их ролях (Admin, Common) и доступах к определенным действиям и процедурам хранилась в БД MS SQL. Упрощенно структуру этого сегмента БД можно представить вот так:

По названию таблиц можно догадаться, что в самом приложении эта связка таблиц захватывалась Entity Framework 6, а после использовалась подсистемой ASP.NET Identity. В начале сессии пользователю выводилась форма для входа, в которую он вводил свои учетные данные, после чего происходил редирект на домашнюю страницу приложения. Далее, исходя из того, какие доступы у данного пользователя прописаны в БД, и какими привилегиями он обладает, система подстраивала UI под эти данные.
Авторизация была реализована с помощью HTML-форм путём применения стандартного хелпера Html.BeginForm, отсылающего введенные данные по нажатию кнопки Submit. Вот как это выглядело с точки зрения кода:
@using (Html.BeginForm("Login", "Auth", FormMethod.Post, new { @class = "form-signin" })) { @Html.AntiForgeryToken() <div class="form-group form-ie"> <span class="oi oi-person"></span> @Html.TextBoxFor(x => x.Login, new { @class = "form-control", @placeholder = "Логин", @id = "username" }) @Html.ValidationMessageFor(x => x.Login) </div> <div class="form-group form-ie"> <span class="oi oi-lock-locked"></span> @Html.PasswordFor(x => x.Password, new { @class = "form-control", @placeholder = "Пароль", @id = "inputPassword" }) @Html.ValidationMessageFor(x => x.Password) </div> <input type="submit" class="btn btn-mybtn-lg btn-my btn-block text-uppercase" value="Войти" /> }
Далее логин с паролем передавались в контроллер авторизации AuthController, который в себе хранил UserManager, SignInManager и AppDbContext (пронаследованный от IdentityDBContext) из ASP.NET Identity. Вот как выглядел код этого контроллера.
[AllowAnonymous] [RoutePrefix("Auth")] public class AuthController : Controller { private AppDbContext _dbContext; private ApplicationSignInManager _signInManager; private ApplicationUserManager _userManager; public ApplicationSignInManager SignInManager { get { return _signInManager ?? HttpContext.GetOwinContext().Get<ApplicationSignInManager>(); } private set { _signInManager = value; } } public ApplicationUserManager UserManager { get { return _userManager ?? HttpContext.GetOwinContext().GetUserManager<ApplicationUserManager>(); } private set { _userManager = value; } } public AppDbContext DbContext { get { return _dbContext ?? HttpContext.GetOwinContext().Get<AppDbContext>(); } private set { _dbContext = value; } } public AuthController() { } [HttpGet] public ActionResult Index() { return View(new AuthViewModel()); } [HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Login(AuthViewModel model) { var result = await SignInManager.PasswordSignInAsync(model.Login, model.Password, false, false); if (result == SignInStatus.Success) { return RedirectToAction("Index", "Home"); } Log.Warning("Ошибка авторизации: Неправильный логин или пароль"); ModelState.AddModelError("Password", "Неправильный логин или пароль"); return View("Index", model); } private IAuthenticationManager AuthenticationManager { get { return HttpContext.GetOwinContext().Authentication; } } [HttpGet] [ValidateAntiForgeryToken] public ActionResult LogOff() { AuthenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie); return RedirectToAction("Index", "Auth"); } }
Сам факт авторизации в системе в других контроллерах проверялся посредством применения фильтра-нотации [Authorize], а принадлежность к роли – посредством применения [Authorize(Roles = “role1”)].
[Authorize] public class HomeController : Controller { private AppDbContext _dbContext; public AppDbContext DbContext { get { return _dbContext ?? HttpContext.GetOwinContext().Get<AppDbContext>(); } private set { _dbContext = value; } } public HomeController() { } [Authorize(Roles = "Common, Admin")] public ActionResult Index() { ///something is happening return View(); } }
Как заметит знакомый с вышеописанным стеком человек, не происходит вообще ничего необычного – это базовые элементы, знакомые каждому ASP.NET-разработчику.
Итак, после получения требования об изменении порядка авторизации, мы стали менять его. Для тех, кто с этим не знаком — в ASP.NET существуют следующие типы авторизации, которые можно поставить как с конфига, так и с помощью шаблона Visual Studio при создании проекта:
Без авторизации;
Авторизация на основе отдельных учётных записей (логин+пароль, классика)
Авторизация с помощью Active Directory, Microsoft Azure или Office 365.
Авторизация с помощью учётной записи Windows.
Так как у нас нет возможности использовать Active Directory ввиду требований сопровождения, остаётся один вариант – авторизация с помощью УЗ Windows.
Поигравшись немного со сменой способа авторизации в пустых приложениях и убедившись, что в них всё работает, я сделал то же самое с нашим приложением, заменив authentication mode на «Windows» в web.config.
Итак, настало время прогона. Изначально я предполагал, что после изменения авторизации можно будет подгонять логин пользователя в SignInManager, после чего проводить авторизацию по-старому (только без пароля) – т.е., что SignInManager будет маппить логин с таблицей AspNetUsers и вносить в контекст текущей пользовательской сессии соответствующий AspNetIdentity. Для чистоты эксперимента я удалил себя из таблицы с пользователями. Иии…я все равно спокойно авторизовался. Покопавшись в переменных, я понял, что при смене authentication mode на «Windows» используется другой вид Identity: не AspNetIdentity, а WindowsIdentity. При использовании WindowsIdentity любой пользователь, который вошёл в Windows – априори авторизован, причем автономно – никакой связи с БД и EF не наблюдалось. Это означало, что если ничего не исправить, то…

Ну вы поняли ?
Так как Active Directory мы использовать не могли, текущий вариант не работал, а опыта в написании и модификации систем авторизации у меня не было – плюс, на эту фичу было отведено мало времени – я закопался в документацию по ASP.NET Identity и Windows Identity. Как оказалось – это было правильное решение.
Итак, как можно подружить ASP.NET Identity + EF и Windows Identity:
Сделать еще один класс – назовем его CustomAuthenticationFilter — и пронаследовать его от ActionFilterAttribute и IAuthenticationFilter.
В AuthorizeAttribute содержится метод OnAuthentication который можно переопределить в дочернем классе. В нём мы захватываем логин пользователя из Windows Identity, прикрепленного к контексту AuthenticationContext – затем с помощью ��онтекста Entity Framework получаем доступ к таблице с пользователями и проверяем, есть ли пользователь в списке. Если его нет – в методе вернуть false.
Затем из AuthorizeAttribute в нашем классе необходимо переопределить обработчик событий OnAuthenticationChallenge, который позволяет задать реакцию системы в случае, если метод OnAuthentication, переопределенный ранее выдаст false. В нашем случае мы будем перенаправлять пользователя на страницу, где сообщим ему, что к приложению необходимо получить доступ (401).
public class CustomAuthenticationFilter : ActionFilterAttribute, IAuthenticationFilter { public void OnAuthentication(AuthenticationContext filterContext) { var dbContext = filterContext.HttpContext.GetOwinContext().Get<AppDbContext>(); var username = filterContext.HttpContext.User.Identity.Name; var userMatches = dbContext.Users.Where(x => x.UserName == username); if (string.IsNullOrEmpty(username) || userMatches.Count() != 1) { filterContext.Result = new HttpUnauthorizedResult(); } } public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext) { if (filterContext.Result == null || filterContext.Result is HttpUnauthorizedResult) { filterContext.Result = new RedirectToRouteResult( new RouteValueDictionary{ { "controller", "Error" }, { "action", "NotAuthorized" } }); } } }
Для того, чтобы сделать вариант, предполагающий дополнительную проверку роли, помимо проверки факта наличия пользователя, необходимо в том же или новом классе пронаследоваться от AuthorizeAttribute. Для упрощения чтения я сделал новый класс.
Идеология здесь следующая:
Делаем конструктор, в который извне передаем список разрешенных ролей, например, { “Admin”, “Common”}.
Переопределяем метод AuthorizeCore, в котором реализуем поиск пользователя по образцу предыдущего класса, а потом через тот же контекст EF достаем список ролей пользователя и матчим его с тем списком, который прилетает через конструктор. Если матч есть – пользователь «достоин».

Далее переопределяем обработчик HandleUnauthorizedRequest, где мы выдаем пользователю стилизованную ошибку 403.
public class CustomAuthorizeAttribute : AuthorizeAttribute { private readonly string[] allowedRoles; public CustomAuthorizeAttribute(params string[] roles) { allowedRoles = roles; } protected override bool AuthorizeCore(HttpContextBase httpContext) { var dbContext = httpContext.GetOwinContext().Get<AppDbContext>(); var username = httpContext.User.Identity.Name; var userMatches = dbContext.Users.Where(x => x.Name == username); if (!string.IsNullOrEmpty(username) && userMatches.Count() == 1) { var userId = userMatches.First().Id; var userRole = (from u in dbContext.Users join r in dbContext.Roles on u.Roles.FirstOrDefault().RoleId equals r.Id where u.Id == userId select new { r.Name }).FirstOrDefault(); foreach(var role in allowedRoles) { if (role == userRole.Name) return true; } } return false; } protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) { filterContext.Result = new RedirectToRouteResult( new RouteValueDictionary { { "controller", "Home" }, { "action", "AccessDenied" } }); } }
А теперь магия – я думаю, вы уже догадались, что с помощью этих двух классов мы разработали фильтры, аналогичные [Authorize] и [Authorize(Roles = “role1”)].
Таким образом, изначально столкнувшись с невозможностью ASP.NET Identity и Windows Identity работать из коробки вместе, я переопределил сами фильтры, отредактировав их логику до той, что мне требуется. Надеюсь, вам поможет информация из этого поста, если вы столкнетесь с аналогичной ситуацией. Удачи!
