Русский /

Блог

Статья ведущего инженера-программиста First Line Software Александра Фирсова для ХабрХабра. Материал посвящен работе с .NET фреймворком Windows Communication Foundation (WCF).

 

WCF и AJAX
Хотелось бы продемонстрировать один из возможных подходов к решению проблемы работы с WCF сервисами с различных доменов. Найденная мной информация по данной теме была или неполной, или содержала избыточное количество информации, затрудняющей понимание. Хочу рассказать о нескольких способах взаимодействия WCF и AJAX POST запросов, включающих в себя информацию о Cookies и авторизации.

Как известно, просто так AJAX вызов на другой домен не заработает, в силу соображений безопасности. Для решения данной проблемы был придуман и релизован стандарт CORS (wiki, mozilla). Этот стандарт подразумевает использование специфичных HTTP заголовков для разрешения и ограничения доступа. Упрощенный процесс коммуникации с использованием данного протокола подразумевает следующее:

Клиент(браузер) инициирует подключение с HTTP заголовком Origin, сервер должен ответить используя заголовок Access-Control-Allow-Origin. Пример пары запрос/ответ с адреса http://foo.example на сервис http://bar.other/resources/public-data/:

Запрос:
GET /resources/public-data/ HTTP/1.1
Host: bar.other
Origin: http://foo.example
[Другие заголовки]

Ответ:
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Access-Control-Allow-Origin: *
Content-Type: application/xml

[XML Data]

 

Заголовки

  • Access-Control-Allow-Origin — данный заголовок определяет, с каких ресурсов могут приходить запросы. Может использоваться * или конкретный домен, например http://foo.example. Данный заголовок может быть только один, и может содержать только одно значение, т.е. список доменов задать нельзя.
  • Access-Control-Allow-Methods — этот заголовок определяет, какие методы могут использоваться для общения с сервером. Ограничимся следующими: POST,GET,OPTIONS, но так же можно использовать и PUT, и DELETE, и другие.
  • Access-Control-Allow-Headers — этот заголовок определяет список доступных заголовков. Например Content-Type, который позволит задать тип ответа application/json.
  • Access-Control-Allow-Credentials — этот заголовок определяет, разрешается ли передавать Cookie и Authorization заголовки. Возможные значения true и false. Важно: данные будут передаваться, только если в заголовке Access-Control-Allow-Origin будет явно выставлен конкретный домен, если использовать * — заголовок будет проигнорирован и данные передаваться не будут.

В общем случае ограничения накладывает браузер. Если ему что-то не понравится в заголовках, он не отдаст эти данные пользователю (если не вернется необходимый Access-Control-Allow-Headers, или серверу, если не будет указан Access-Control-Allow-Credentials и правильный Access-Control-Allow-Origin). Перед POST запросом на другой домен, браузер предварительно сделает OPTIONS запрос (preflight request) для получения информации о разрешенных методах работы с сервисом.

WCF

По данной теме существует определенное количество информации разнообразного качества. К сожалению, WCF не позволяет стандартными средствами использовать эти заголовки, однако существует несколько вариантов решения этой пробемы. Я предлагаю вашему вниманию некоторые из них.

Решение с использованием web.config.

Данное решение подразумевает добавление необходимых заголовков прямо в web.config.

<system.webServer>
 <httpProtocol>
  <customHeaders>
   <add name="Access-Control-Allow-Origin" value="http://foo.example" />
   <add name="Access-Control-Allow-Headers" value="Content-Type" />
   <add name="Access-Control-Allow-Methods" value="POST, GET, OPTIONS" />
   <add name="Access-Control-Allow-Credentials" value="true" />
  </customHeaders>
 </httpProtocol>
</system.webServer>

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

Решение с использованием Global.asax

Данное решение подразумевает написание в Global.asax.cs кода, добавляющего необходимые заголовки в каждый запрос.

protected void Application_BeginRequest(object sender, EventArgs e) {
    var allowedOrigins = new [] { "http://foo.example", "http://bar.example" };
    var request = HttpContext.Current.Request;
    var response = HttpContext.Current.Response;
    var origin = request.Headers["Origin"];

    if (origin != null && allowedOrigins.Any(x => x == origin)) {
        response.AddHeader("Access-Control-Allow-Origin", origin);
        response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
        response.AddHeader("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
        response.AddHeader("Access-Control-Allow-Credentials", "true");
        if (request.HttpMethod == "OPTIONS") {
            response.End();
        }
    }
}

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

Решение с добавлением заголовков в коде WCF сервиса

Данное решение отличается от предыдущего лишь тем, что заголовки добавляются для конкретного сервиса или метода. В общем случа решение выглядит так:

[ServiceContract]
public class MyService { 
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    public string DoStuff() {
         AddCorsHeaders();
         return "<Data>";
     }

    private void AddCorsHeaders() {
        var allowedOrigins = new [] { "http://foo.example", "http://bar.example" };
        var request = WebOperationContext.Current.IncomingRequest;
        var response = WebOperationContext.Current.OutgoingResponse;
        var origin = request.Headers["Origin"];

        if (origin != null && allowedOrigins.Any(x => x == origin)) {
            response.AddHeader("Access-Control-Allow-Origin", origin);
            response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
            response.AddHeader("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
            response.AddHeader("Access-Control-Allow-Credentials", "true");
            if (request.HttpMethod == "OPTIONS") {
                response.End();
            }
        }
    }
}

Данный подход позволяет ограничить использование CORS в рамках сервиса или даже метода. Основной минус — вызов AddCorsHeaders необходим в каждом методе сервиса. Плюс — простота использования.

Решение с использованием собственных EndPointBehavior и DispatchMessageInspector

Данный подход использует возможности WCF по расширение функциональности.
Создаются 2 класса EnableCorsBehavior:

using System;
using System.ServiceModel.Channels;
using System.ServiceModel.Configuration;
using System.ServiceModel.Description;
using System.ServiceModel.Dispatcher;

namespace My.Web.Cors {
    public class EnableCorsBehavior : BehaviorExtensionElement, IEndpointBehavior {
        public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
        public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime) { }
        public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) {
            endpointDispatcher.DispatchRuntime.MessageInspectors.Add(new EnableCorsMessageInspector());
        }
        public void Validate(ServiceEndpoint endpoint) { }
        public override Type BehaviorType {
            get { return typeof(EnableCorsBehavior); }
        }
        protected override object CreateBehavior() {
            return new EnableCorsBehavior();
        }
    }
}

и EnableCorsMessageInspector:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.ServiceModel;
using System.ServiceModel.Channels;
using System.ServiceModel.Dispatcher;

namespace My.Web.Cors {
    public class EnableCorsMessageInspector : IDispatchMessageInspector {
        public object AfterReceiveRequest(ref Message request, IClientChannel channel, InstanceContext instanceContext) {
            var allowedOrigins = new [] { "http://foo.example", "http://bar.example" };
            var httpProp = (HttpRequestMessageProperty)request.Properties[HttpRequestMessageProperty.Name];
            if (httpProp != null) {
                string origin = httpProp.Headers["Origin"];
                if (origin != null && allowedOrigins.Any(x => x == origin)) {
                  return origin;
                }
            }
            return null;
        }
        public void BeforeSendReply(ref Message reply, object correlationState) {
            string origin = correlationState as string;
            if (origin != null) {
                HttpResponseMessageProperty httpProp = null;
                if (reply.Properties.ContainsKey(HttpResponseMessageProperty.Name)) {
                  httpProp = (HttpResponseMessageProperty)reply.Properties[HttpResponseMessageProperty.Name];
                } else {
                  httpProp = new HttpResponseMessageProperty();
                  reply.Properties.Add(HttpResponseMessageProperty.Name, httpProp);
                }
                httpProp.Headers.Add("Access-Control-Allow-Origin", origin);
                httpProp.Headers.Add("Access-Control-Allow-Credentials", "true");
                httpProp.Headers.Add("Access-Control-Request-Method", "POST,GET,OPTIONS");
                httpProp.Headers.Add("Access-Control-Allow-Headers", "X-Requested-With,Content-Type");
            }
        }
    }
}

Добавляем в web.config созданный EnableCorsBehavior:

<system.serviceModel>
...
    <extensions>
        <behaviorExtensions>        
            <add name="crossOriginResourceSharingBehavior" type="My.Web.Cors.EnableCorsBehavior, My.Web, Version=1.0.0.0, Culture=neutral" />        
        </behaviorExtensions>      
    </extensions>
...
</system.serviceModel>

Находим и добавляем созданное для EnableCorsBehavior расширение в конфигурацию Behavior нашего Endpoint‘a

<system.serviceModel>
    <services>
        <service name="My.Web.Services.MyService">
            <endpoint address="" behaviorConfiguration="My.Web.Services.MyService" binding="webHttpBinding" contract="My.Web.Services.MyService" />
        </service>
    </services>
    ...
    <behaviors>
        ...
        <endpointBehaviors>
            ...
            <behavior name="My.Web.Services.MyService">
                <webHttp/>
                <crossOriginResourceSharingBehavior /> <!-- нужно добавить эту строчку -->
            </behavior>
            ...
        </endpointBehaviors>
        ...
    </behaviors>
    ...
</system.serviceModel>

Нам осталось только обработать предварительный запрос с методом OPTIONS. В моем случае я использовал самый простой вариант: в теле сервиса добавляется метод-обработчик OPTIONS запросов.

[OperationContract]
[WebInvoke(Method = "OPTIONS", UriTemplate = "*")]
public void GetOptions()
{
    // Заголовки обработаются в EnableCorsMessageInspector 
}

Разумеется, существует и аналогичное WCF расширение для работы с preflight запросами, об одном из них можно будет прочитать по ссылке из списка литературы в конце статьи. Основной минус — необходимость добавления метода GetOptions в тело сервиса и немалое количество дополнительного кода. С другой стороны, данный подход позволяет практически полностью разделить логику сервиса и логику коммуникации.

Пара слов о Javascript и браузерах

Для поддержки отправки авторизационных и Cookie данных, необходимо, чтобы в XmlHttpRequest был выставлен в true флаг withCredentials. Думаю, что многие используют jQuery для работы c AJAX, поэтому приведу пример для него:

 $.ajax({
            type: 'POST',
            cache: false,
            dataType: 'json',
            xhrFields: {
                withCredentials: true
            },
            contentType: 'application/json; charset=utf-8',
            url: options.serviceUrl + '/DoStuff'
        });

К сожалению, функциональность, связанная с отправкой авторизационных данных, стала доступна для IE только с 10 версии (в котором стал доступен полноценный XMLHttpRequest), браузеры IE8/9 не поддерживают отправку данной информации, и способны работать только с GET и POST, используя JS объект XDomainRequest (об его особенностях можно почитать тут.)

Авторизация

Во всех подходах выше неявно используются аутентификационные данные с основного сайта. Имея авторизацию на основном сайте http://bar.other, мы имеем возможность вернуть данные пользователя по Ajax запросу с сайта http://foo.example. В моём случае это использовалось для того, чтобы дать возможность пользователю получать уведомления и реагировать на события, находясь на одном из сайтов, живущих в рамках одного бизнес проекта, но расположенных на разных доменах и платформах. Как уже было сказано выше, ключевыми моментами тут являются заголовок Access-Control-Allow-Credentials и выставления для XmlHttpRequest флага withCredentials=true.

Список источников

 

Александр Фирсов