Cross-Origin Resource Sharing

Dependiendo del presupuesto, las tecnologías y arquitectura utilizadas es posible que nos hayamos topado con este inconveniente (o no :)) en algún momento. Para explicarlo, creo que lo mejor es ponernos antes en situación: Imaginemos que estamos desarrollando un sitio web y hemos decidido que la parte de acceso a los datos pueda ser reutilizada por distintos clientes, por lo que decidimos utilizar una arquitectura SOA donde nuestros servicios web estén ubicados en servidores distintos a nuestros frontales web y, obviamente, del resto de dispositivos que consumirán los mismos. De esta forma seremos capaces de reutilizar la lógica y mejorar la escalabilidad de nuestros desarrollos. Hasta aquí parece que todo pinta bastante bien, por lo que comenzamos el desarrollo y además decidimos que nuestra aplicación utilizará peticiones AJAX para mejorar la experiencia de usuario.
Desde el lado del cliente, he creado un pequeño marcado y su correspondiente script para manejar los verbos GET, POST, PUT y DELETE de la clase Wish:

<h3>Wishes:</h3>
<ul id="wishes"></ul>
<section>
    <label for="Id">Id:</label>
    <input type="number" id="Id" />
    <label for="Text">Text:</label>
    <input type="text" id="Text" />
</section>
<div id="actions">
    <button id="btnGet">All Wishes</button>
    <button id="btnGetById">Get Wish</button>
    <button id="btnAdd">Add</button>
    <button id="btnUpdate">Update</button>
    <button id="btnDelete">Delete</button>
</div>
@section scripts{
    <script>
        function id(element) {
            return document.getElementById(element);
        }
        function ajaxRequest(url, httpMethod, data) {
            var wishes = id("wishes");
            success = function (result) {
                wishes.innerHTML = "";
                if (result.length > 1) {
                    for (var item in result) {
                        wishes.innerHTML += "<li>Id: " + result[item].Id + " Text: " + result[item].Text + "</li>";
                    }
                }
                else {
                    wishes.innerHTML += "<li>Id: " + result.Id + " Text: " + result.Text + "</li>";
                }
            },
            error = function (jqXR, textStatus, error) {
                wishes.innerHTML = "<li class='error'>Error: " + error + "</li>";
            };
            if (httpMethod == "GET") {
                $.ajax({
                    url: url,
                    type: httpMethod,
                    success: success,
                    error: error
                });
            }
            else {
                $.ajax({
                    url: url,
                    type: httpMethod,
                    contentType: "application/json",
                    data: data,
                    success: success,
                    error: error
                });
            }
        }
        (function () {
            var api = "http://localhost:6958/api/wishes",
            btnGet = id("btnGet"),
            btnGetById = id("btnGetById"),
            btnAdd = id("btnAdd"),
            btnUpdate = id("btnUpdate"),
            btnDelete = id("btnDelete");

            btnGet.addEventListener("click", function () {
                ajaxRequest(api, "GET", null);
            });
            btnGetById.addEventListener("click", function () {
                ajaxRequest(api + "/" + id("Id").value, "GET", null);
            });
            btnAdd.addEventListener("click", function () {
                var wish = {
                    Id: id("Id").value,
                    Text: id("Text").value
                };
                ajaxRequest(api, "POST", JSON.stringify(wish));
            });
            btnUpdate.addEventListener("click", function () {
                var wish = {
                    Id: id("Id").value,
                    Text: id("Text").value
                };
                ajaxRequest(api + "/" + wish.Id, "PUT", JSON.stringify(wish));
            });
            btnDelete.addEventListener("click", function () {
                ajaxRequest(api + "/" + id("Id").value, "DELETE", null);
            });
        })();
    </script>
}

En un proyecto aparte (dentro de la misma solución), he utilizado ASP.NET Web API + Entity Framework para persistir los cambios. Los pasos fueron los siguientes:

  1. Clase Wish la cual únicamente contiene dos propiedades: Id y Text.
    namespace CORS.Models
    {
        public class Wish
        {
            public long Id { get; set; }
            public string Text { get; set; }
        }
    }
  2. Creación de una clase Context, la cual hereda de DbContext para crear mi base de datos a través de Code First:
    using System.Data.Entity;
    namespace CORS.Models
    {
        public class Context : DbContext
        {
            public Context() : base("name=CORSDemo") { }
            
            public DbSet<Wish> Wishes { get; set; }
        }
    }
  3. Clase ContextDbInitializer donde he añadido algunos deseos para no comenzar desde cero 🙂
    using System.Data.Entity;
    namespace CORS.Models
    {
        public class DbContextInitializer : DropCreateDatabaseIfModelChanges<Context>
        {
            protected override void Seed(Context context)
            {
                context.Wishes.Add(new Wish { Id = 0, Text = "Xbox 360" });
                context.Wishes.Add(new Wish { Id = 1, Text = "Kinect for Windows" });
                context.Wishes.Add(new Wish { Id = 2, Text = "Nexus 4" });
                context.Wishes.Add(new Wish { Id = 3, Text = "Surface RT" });
                
                context.SaveChanges();
                base.Seed(context);
            }
        }
    }
  4. A través del menú contextual, he añadido un nuevo controlador utilizando la opción Api controller with read/write actions using Entity Framework donde mi Model class será Wish y mi Data Context class mi clase Context.
  5. Por último, añado Database.SetInitializer en el método Application_Start del archivo Global.asax para hacer uso de DbContextInitializer y conseguir de esta manera datos de prueba :).
    Database.SetInitializer(new DbContextInitializer());

Una vez que tenemos tanto la parte del cliente como la del servidor, es momento de probar nuestras aplicaciones en conjunto: Arranco ambas a través de F5, y hago clic en cada uno de los botones de mi interfaz con las herramientas de desarrollo habilitadas en el apartado Consola:

Access-Control-Allow-Origin

Oops! Este es el punto al que realmente quería llegar ¿Qué es lo que ocurre? ¿Por qué no es posible llevar a cabo las peticiones? El motivo se conoce como Same Origin Policy, política por parte de los navegadores que sólo permite realizar peticiones dentro del mismo sitio (por sitio se entiende mismo scheme, hostname y port). En el caso de JavaScript esta política afecta a XMLHttpRequest, por lo que nos dificulta las llamadas que estamos intentando realizar. En este ejemplo estamos utilizando el método $.ajax de JQuery, el cual utiliza internamente XMLHttpRequest y, como vemos en los errores de la consola, mi aplicación cliente ubicada en http://localhost:7123 está intentando llamar a un recurso de mi API en http://localhost:6958/api/wishes (El puerto no coincide, por lo que no es el mismo sitio).

¿Cómo lo resolvemos?

Muchas de las APIs a día de hoy utilizan la técnica JSONP (JSON with padding/JSON con relleno :P) la cual consiste en utilizar el atributo src de la etiqueta script y desde ahí hacer la llamada a otro dominio. La respuesta es «auto-ejecutable» a través de una función de callback que pasamos como parámetro durante la llamada y en la respuesta se devuelven los datos a procesar como parámetro de la función de retorno. Esta técnica tiene distintas limitaciones, además de algunos puntos de seguridad a tener en cuenta.
Con HTML 5 aparece una especificación conocida como CORS (Cross-Origin Resource Sharing) la cual es realmente el motivo de este post 😀

Cross-Origin Resource Sharing

Se trata de un mecanismo que nos permite realizar peticiones a otros dominios gracias a un conjunto de cabeceras que nos indican si tenemos acceso, a qué verbos, campos, etcétera. Existen dos tipos de peticiones para este mecanismo, las que se conocen como simples y las compejas.

Peticiones simples con CORS

Se tratan de las peticiones de tipo GET y POST, este útimo dependiendo del Content type. Básicamente son aquellas que no necesitan de un body para llevarse a cabo y se realizan de manera inmediata, es decir en la misma petición el cliente obtiene su resultado. Como se ha comentado anteriormente, CORS utiliza un mecanismo de cabeceras donde se indica si es lícito el acceso o no a sus recursos. En el caso de las peticiones simples, únicamente sera necesario añadir la cabecera Access-Control-Allow-Origin, la cual puede tener dos valores: Utilizaremos un asterisco (*) en el caso de que cualquier dominio pueda tener acceso a nuestra API o indicaremos el dominio que nos ha solicitado la información, en el caso de que aceptemos dicha petición, sin permitir acceso a nadie más.

Peticiones complejas con CORS

Las peticiones dentro de este segundo tipo son aquellas que involucran al verbo HTTP OPTIONS para determinar si la petición a enviar va a ser aceptada. Me explico: Para poder realizar una petición de tipo POST, PUT o DELETE, donde el contenido que hay en nuestro dominio se va a alterar (existe un body en estas peticiones), se utilizan dos peticiones para llegar a término: La primera de ellas utiliza OPTIONS donde se indica qué es lo que se quiere hacer (Petición conocida como Preflight) y una segunda que solamente se enviará si a primera es aceptada por el destino. En caso afirmativo, el servidor nos enviará las siguientes cabeceras:

  • Access-Control-Allow-Origin: Vista con las peticiones simples, donde se indica el dominio que hace la solicitud o un * para aceptar cualquier dominio.
  • Access-Control-Allow-Headers: Se trata del nombre de todos los campos que pueden ser utilizado durante la petición. Normalmente coinciden con los mismos que el cliente envió.
  • Access-Control-Allow-Methods: Se indican todos los verbos que se aceptan desde el servidor, incluso puede contener aquellos para los que ni siquiera hemos solicitado el uso.

Estas son las cabeceras requeridas para realizar una petición compleja. Si la misma ha sido satisfactoria, se realizará una segunda petición del tipo simple (sin el uso de OPTIONS y utilizando directamente el Http Verb para el que pidió autorización).

¿Cómo transformamos esta especificación a código?

Una vez que tenemos clara cuales son los pasos de la especificación, modificaremos nuestra API para que sea capaz de interpretar las peticiones de tipo CORS. La opción por la que me decanto es utilizando DelegatingHandler:

using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace CORS.DelegatingHandlers
{
    public class CORSHandler : DelegatingHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var isOrigin = request.Headers.Contains("Origin");
            var preflight = request.Method == HttpMethod.Options;
            if (isOrigin)
            {
                if (preflight)
                {
                    return Task.Factory.StartNew(() =>
                    {
                        var response = new HttpResponseMessage(HttpStatusCode.OK);
                        response.Headers.Add("Access-Control-Allow-Origin", request.Headers.GetValues("Origin")); //TODO: Check allowed domains
                        response.Headers.Add("Access-Control-Allow-Headers", request.Headers.GetValues("Access-Control-Request-Headers"));
                        response.Headers.Add("Access-Control-Allow-Methods", "POST, PUT, DELETE"); //TODOS: Check allowed http Methods for this domain
                        return response;
                    });
                }
            
                return base.SendAsync(request, cancellationToken)
                           .ContinueWith(t =>
                               {
                                   var response = t.Result;
                                   response.Headers.Add("Access-Control-Allow-Origin", request.Headers.GetValues("Origin"));
                                   return response;
                               });
            }
            return base.SendAsync(request, cancellationToken);
        }
    }
}

Para inyectar esta clase agregaríamos la siguiente línea en el Application_Start del archivo Global.asax:

GlobalConfiguration.Configuration.MessageHandlers.Add(new CORSHandler());

Este sería el código mínimo necesario para poder procesar nuestras peticiones CORS. Existen dos cabeceras más, opcionales, que pueden ser de nuestro interés:

  • Access-Control-Max-Age: Se utiliza para indicar cuánto tiempo estará cacheada la autorización concebida por la petición preflight. Se especifíca en segundos.
  • Access-Control-Allow-Credentials: Por defecto, las credenciales del dominio de origen almacenadas en cookies, Http Authentication o certificados para SSL no son enviados junto con la petición, ya que no pertenece al mismo dominio. No obstante podemos forzar este traspaso a través de esta cabecera, donde JQuery nos lo pone bastante fácil:
                    $.ajax({
                        url: url,
                        type: httpMethod,
                        contentType: "application/json",
                        data: data,
                        xhrFields: {
                            withCredentials: true
                        },
                        success: success,
                        error: error
                    });
    

    Desde el lado del servidor debemos añadir además una cabecera adicional para este caso:

     response.Headers.Add("Access-Control-Allow-Credentials", "true");

Espero que haya sido de utilidad 😀

¡Saludos!