Корректная обработка проблем аутентификации AJAX-запросов для приложений ASP.NET MVC

Для современных веб-приложений стало уже нормой использование AJAX при создании пользовательских интерфейсов. Однако, из-за этого, порой, возникают дополнительные сложности. Часто эти сложности связаны с аутентификацией и процессом обработки таких запросов на клиенте.

Проблема

Предположим, что наше веб-приложение при помощи jQuery обращается к серверу и получает оттуда данные в виде JSON.

Сервер:

[HttpPost]
public ActionResult GetData()
{
  return Json(new
  {
    Items = new[]
    {
      "Li Chen",
      "Abdullah Khamir",
      "Mark Schrenberg",
      "Katy Sullivan",
      "Erico Gantomaro",
    }
  });
}

Клиент:

var $list = $("#list");
var $status = $("#status");
$list.empty();
$status.text("Loading...");

$.post("/home/getdata")
  .always(function() {
    $status.empty();
  })
  .success(function(data) {
    for (var i = 0; i < data.Items.length; i++) {
      $list.append($("<li/>").text(data.Items[i]));
    }
  });

Логика предельно простая и понятная. Теперь добавим в наше приложение механизмы аутентификации. С этим опять-таки все довольно просто – пользуемся старым-добрым механизмом FormsAuthentication и атрибутом Authorize в ASP.NET MVC. Код нашего контроллера при этом поменяется так:

[HttpPost]
[Authorize]
public ActionResult GetData()
{
  return Json(new
  {
    Items = new[]
    {
      "Li Chen",
      "Abdullah Khamir",
      "Mark Schrenberg",
      "Katy Sullivan",
      "Erico Gantomaro",
    }
  });
}

Опять же – проблем с этим кодом нет. После аутентификации пользователь видит страницу и может получать данные как и раньше. Однако, проблема в том, что если время аутентификации истечёт (из-за неактивности пользователя) или пользователь выйдет из системы (например, в другой вкладке браузера), то сервер в ответ на обращение к действию контроллеру вернет.. HTTP 302 Found.

Очевидно, что клиентский код в нашем случае не ожидал такого поворота событий и дальше приложение будет работать некорректно.

Причины

Прежде чем решать проблему, давайте разберемся с причинами – откуда появилось HTTP 302? Казалось бы - причем здесь 302? Было бы логично допустить, что там должно быть HTTP 401. Дело в том, что когда мы пользуемся FormsAuthentication за сценой действует FormsAuthenticationModule (который добавляется в список HTTP-модулей в глобальном конфигурационном файле). Заглянув под капот этого модуля можно легко понять, что если текущий код для HTTP есть 401, то он выполняет редирект, т.е. заменяет его на 302:

Нетрудно догадаться, что сделано это было с целью перенаправить пользователя на страницу ввода пароля, если запрос не обработан успешно и требуется указать пароль (код возврата - 401). В этом случае пользователь увидит приветливую форму ввода пароля, а не страницу IIS с кодом ошибки. Имеет смысл, не правда ли?

С точки зрения нашего ASP.NET MVC приложения цепочка получается такой:

  1. Запрос приходит в приложение, где натыкается на фильтр AuthorizeAttribute.
  2. Поскольку пользователь не аутентифицирован, то этот фильтр отдаст HTTP 401, что логично (убедиться в этом достаточно легко, взглянув рефлектором на реализацию этого фильтра).
  3. Ну а дальше работает наш FormsAuthenticationModule, который подменяет 401 на редирект.

В итоге – при обращении к защищенной паролем странице мы видим форму ввода пароля (что хорошо), но при обращении к аналогичным ресурсам через AJAX этот ответ не информативен (что плохо).

Решение

Итак, что нужно для решения проблемы —

  1. Чтобы сервер отдавал 401/403 для AJAX-запросов, и 302 для обычных запросов.
  2. Обрабатывать на клиенте 401/403.

Честно говоря, FormsAuthenticationModule можно заставить не заменять запросы с 401. Для этого в HttpResponse есть специальное свойство – SuppressFormsAuthenticationRedirect:

Вопрос только в том, в каких случаях изменять это свойство и, что не менее важно, кто именно это будет делать?

Прежде чем ответить на этот вопрос, давайте обратим внимание на то, как клиент должен реагировать, когда получает HTTP-ответ с ошибкой в ответ на AJAX-запрос. Сценария может быть два:

  1. Пользователь вообще не аутентифицирован в системе (401) и его нужно отправить на страницу с вводом логина пароля.
  2. Пользователь аутентифицирован, но данное действие ему все равно не доступно (403). Например, мы можем разрешить какое-то действие для определенных ролей, в которые пользователь не входит. Тогда отправлять его на страницу ввода пароля, пожалуй, глупо – в этом случае будет достаточным просто сообщить ему о том, что у него недостаточно полномочий.

Таким образом нам нужно обрабатывать две ситуации по-разному. Взглянем рефлектором ещё раз на AuthorizeAttribute.

...т.е. он всегда возвращает 401.

Нехорошо. Поэтому придется немного поправить стандартное поведение. Итак, начнем.

Первое — определим, является ли текущий запрос AJAX-запросом и если да, то отключаем редирект на страницу ввода пароля:

public class ApplicationAuthorizeAttribute : AuthorizeAttribute
{
  protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
  {
    var httpContext = filterContext.HttpContext;
    var request = httpContext.Request;
    var response = httpContext.Response;

    if (request.IsAjaxRequest())
      response.SuppressFormsAuthenticationRedirect = true;

    base.HandleUnauthorizedRequest(filterContext);
  }
}

Второе – добавляем условие: если пользователь аутентифицирован, то отдаем 403, если нет, то 401:

public class ApplicationAuthorizeAttribute : AuthorizeAttribute
{
  protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
  {
    var httpContext = filterContext.HttpContext;
    var request = httpContext.Request;
    var response = httpContext.Response;
    var user = httpContext.User;

    if (request.IsAjaxRequest())
    {
      if (user.Identity.IsAuthenticated == false)
        response.StatusCode = (int)HttpStatusCode.Unauthorized;
      else
        response.StatusCode = (int)HttpStatusCode.Forbidden;

      response.SuppressFormsAuthenticationRedirect = true;
      response.End();
    }

    base.HandleUnauthorizedRequest(filterContext);
  }
}

Новый фильтр готов. Теперь нам следует использовать только что созданный фильтр в нашем приложении, вместо стандартного AuthorizeAttribute. Эстетам это может показаться огромным недостатком, но другого пути я здесь не вижу. Если есть решение, то буду рад увидеть его в комментариях.

Ну и последнее, что нужно сделать – добавить обработку 401/403 на клиенте. Чтобы это не делать этого для каждого запроса можно воспользоваться обработчиком ajaxError в jQuery:

$(document).ajaxError(function (e, xhr) {
  if (xhr.status == 401)
    window.location = "/Account/Login";
  else if (xhr.status == 403)
    alert("You have no enough permissions to request this resource.");
});

В итоге

  • Если пользователь не аутентифицирован (таймаут истёк, например), то после любого AJAX-запроса он будет отправлен на страницу ввода пароля.
  • Если пользователь аутентифицирован, но не имеет полномочий выполнить действие, то он увидит соответствующее сообщение об ошибке.
  • Если пользователь аутентифицирован и прав достаточно, то действие будет выполнено.

Из минусов – необходимо использовать новый фильтр ApplicationAuthorizeAttribute, вместо стандартного. Соответственно, если в коде стандартный уже использовался, то этот код придется поменять во всех местах.