Для начала расскажу, что приложение, которое я разрабатывал, долго существовало на небольшом «подстольном» сервере в виде прототипа, которым в работе пользовалось небольшое число сотрудников. По прошествии некоторого времени, руководство приняло решение тиражировать это приложение в пром – с переносом на пром-сервер и организацией доступов к нему сотрудникам всего структурного подразделения.
Естественно, как это всегда бывает, сопровождение выдало нам список требований, которым должны соответствовать приложения, размещаемые на пром-серверах. Одним из таких требований было реализация авторизации по учетной записи 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 работать из коробки вместе, я переопределил сами фильтры, отредактировав их логику до той, что мне требуется. Надеюсь, вам поможет информация из этого поста, если вы столкнетесь с аналогичной ситуацией. Удачи!
mvv-rus
Это все интересно. Но я не вижу в вашей статье, как у вас подключается авторизация к конкретным действиям конкретного класса контроллера. Предполагаю (ибо это логично), что заменой атрибута [Authorize] на [CustomAuthorize] с теми же параметрами конструктора атрибута (или без них) для контроллеров и их методов действий. Но лучше про это (или про реально использованный альтернативый вариант авторизации подключения — такие тоже есть, помню, минимум, один, но ЕМНИП их больше) было бы написать в статье.
А ещё я бы попробовал использовать в качестве хранилища ролей саму Windows — в качестве ролей там выступают имена групп, в которые входит пользователь. Для этого в раздел <system.web> надо включить элемент
Имена ролей при этом будут иметь вид имя_домена\имя_группы (или вид имя_компьютера\имя_группы для локальных групп). Плюсами является то, не надо мучить EF — список груп пользователя приписывается ему при аутентификации Windows, что в Authorize надо поменять имена ролей (или дописывать к ним имя компьютера/домена в CustomAuthorizeAttribute), и что если сервер находится в домене, то можно, скорее всего, использовать доменную аутентификацию (IE это точно умеет, Chrome AFAIK тоже, насчет FF не скажу совсем), т.е. пользователями из домена, скорее всего, вообще не нужно будет вводить логин/пароль.
Правда, в вашем тексте нет ничего про таблицу Procedures и связку ролей с ней, и если это играет какую-то роль в авторизации пользователя (я эту роль не понял), то через членство в группах Windows в качестве ролей ее использовать будет, наверное, не сильно удобно.