Время прочтения: 8 мин.

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

Естественно, как это всегда бывает, сопровождение выдало нам список требований, которым должны соответствовать приложения, размещаемые на пром-серверах. Одним из таких требований было реализация авторизации по учетной записи 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 при создании проекта:

  1. Без авторизации;
  2. Авторизация на основе отдельных учётных записей (логин+пароль, классика)
  3. Авторизация с помощью Active Directory, Microsoft Azure или Office 365.
  4. Авторизация с помощью учётной записи 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:

  1. Сделать еще один класс – назовем его 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" }
			});
		}
	}
}

2. Для того, чтобы сделать вариант, предполагающий дополнительную проверку роли, помимо проверки факта наличия пользователя, необходимо в том же или новом классе пронаследоваться от 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 работать из коробки вместе, я переопределил сами фильтры, отредактировав их логику до той, что мне требуется. Надеюсь, вам поможет информация из этого поста, если вы столкнетесь с аналогичной ситуацией. Удачи!