[SignalR] Autenticación JSON Web Token (JWT) en hubs de SignalR

A lo mejor el título no te dice mucho y quizás nunca te hayas encontrado con este caso desarrollando aplicaciones que usan Signalr, así pues voy a tratar de explicar lo mejor posible mi experiencia personal en un caso real con ASP.NET Web Api y Signalr y autenticación JWT.

Si quieres saber que es JWT (JSONWeb Token) te recomiendo este artículo de Atlassian Understanding JWT

 

El escenario

La imagen que a continuación os pongo resume un poco el escenario que me encontré:

image

Un ERP desarrollado en .NET en el cual querían exponer una API REST para que sus clientes pudieran consumir sus servicios. En este caso concreto he puesto un Prestashop que pudiera mostrar el catálogo de productos y poder hacer pedidos a través de esta plataforma desarollada en Php, pero puede ser cualquier otro cliente (SmartPhones, tablets…) o plataforma de terceros (Umbraco, Alfresco…)

Para securizar la API decidimos usar JWT. Para eso expusimos un endpoint llamado /token al cual se le pasa usuario y contraseña, lo valida contra el ERP y devuelve un token JWT como el que os muestro a continuación:

{

    "access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9._N1iZEvnhyE8k9knbwGtJ5IFsPR9Xsv3VXUz8...",

    "token_type":null,

    "expires_in":0,

    "userName":"bsmith",

    "issued":"07/15/2014 07:55:47",

    "expires":"07/29/2014 07:55:47"

}

Con ese token, el cliente puede hacer llamadas a la API añadiendo en cada petición la cabecera Authorization:

image

Hasta aquí todo correcto y funcionando, pero añadimos una capa de complejidad más:

Notificaciones en tiempo real con Signalr

Imaginaros que el cliente da de alta un pedido en Prestashop y para ello hace una llamada a la API. La API a su vez llama al core del ERP para dar de alta el pedido y el ERP contesta con un OK y la API a su vez contesta a Prestashop que OK. Todo esto desata en brackground un proceso/s de negocio en el ERP, pero nuestra API ya contestó a Prestashop y por lo tanto no hay una conexión abierta que indique que está ocurriendo o que ha ocurrido.

El cliente y cuando digo cliente, no me refiero al usuario que está comprando en Prestashop, sino al propietario de Prestashop, quiere poder suscribirse a notificaciones que le permita saber que ocurre con los procesos de negocio del ERP, es decir que el ERP le notifique y no tenga que estar consultando continuamente como se encuentran los pedidos (Por poner un ejemplo) y en base a ello poder actualizar información y actuar.

Para notificar a cada cliente correctamente necesitamos desde la parte servidora saber a quién tenemos que notificar y para ello necesitamos que Signalr asocie las conexiones a cada cliente y aquí es donde entra en juego el token JWT que la API nos emitió. Con este token no sólo sabremos a que cliente tenemos que notificar sino que securizaremos nuestro hub para que nadie sin un token válido pueda acceder a información confifencial de cada cliente.

Mi solución

Lo que os voy a contar ahora, quizás nos sea la mejor manera de hacerlo, pero lo he intentado de otra forma como creando un atributo de autorización personalizado y no he lo he conseguido, por lo menos en este escenario no encajaba. Con Owin y el middleware de de JWT se puede hacer pero en mi caso no usamos este middleware para generar los tokens JWT y por eso tampo encajaba (Sobre esto escribiré otro post más adelante). Por último, intentaré traducir este artículo para recibir feedback del equipo de producto de Signalr por si lo que estoy haciendo está mal y hay mejores maneras de hacerlo.

Lo primero que me puse a investigar es como podía pasar el token JWT a Signalr y gracias a Rui Marinho me enteré que se podía pasar por QueryString usando la propiedad qs del hub:

$(document).ready(function(){

    var hub = $.connection.erpHub;

    $.connection.hub.logging = true;

    $.connection.hub.qs = "Bearer=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1...";

    hub.client.onProccesedOrder = function (order) {

        alert(order.status);

    }

 

    $.connection.hub.start();

});

A través de esa propiedad podéis pasar cualquier parámetro por QueryString a vuestros hubs.

A continuación, necesitamos obtener el token en el hub para validarlo. Para ello sobreescribimos los métodos OnConnected() y OnDisconnected() del hub.

public override Task OnConnected()

{

    return ValidateJwt(Add);

}

 

public override Task OnDisconnected()

{

    return ValidateJwt(Remove);

}

 
La firma del método ValidateJwt recibe un Func<string, Taks> por temas de refactorización y evitar código duplicado porque los métodos de OnConnected() y OnDisconnected() solo difieren en una línea de código como ahora veremos. (Aquí agradezco la ayuda de mi compañero Andrés aunque tenemos pendiente una refactorización usando el patrón estrategia que creo quedará más claro de leer, pero lo importante era evitar código duplicado)
 
private Task ValidateJwt(Func<string, Task> execute)

{

    var bearer = Context.QueryString.Get("Bearer");

 

    if (String.IsNullOrWhiteSpace(bearer))

    {

        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));

    }

 

    var principal = CreateClaimsPrincipalFrom(bearer);

 

    if (principal == null || principal.Identity == null || !principal.Identity.IsAuthenticated)

    {

        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));

    }

 

    SetContext(principal);

 

    var userName = Context.User.Identity.Name;

 

    return execute(userName);

}

Los métodos Add y Remove sólo se encargan de añadir la empresa a un grupo para poder comunicarnos con ella (ahora en el ejemplo completo podréis verlos) pero realmente importante es el siguiente método:

private void SetContext(ClaimsPrincipal principal)

{

    Context.Request.Environment["server.User"] = principal;

    Context = new HubCallerContext(

        new ServerRequest(Context.Request.Environment), Context.ConnectionId);

}

Lo que estamos haciendo básicamente es crear un contexto nuevo con los claims que han sido generadas a partir de la validación del token JWT que pasamos al Hub por QueryString y así cuando accedamos a Context.User.Identity.Name tendremos el nombre de la empresa que venía en los claims.

El código completo del hub es este:

public class ErpHub : Hub

{

    public override Task OnConnected()

    {

        return ValidateJwt(Add);

    }

 

    public override Task OnDisconnected()

    {

        return ValidateJwt(Remove);

    }

 

    private Task Add(string groupName)

    {

        Groups.Add(Context.ConnectionId, groupName);

 

        return base.OnConnected();

    }

 

    private Task Remove(string groupName)

    {

        Groups.Remove(Context.ConnectionId, groupName);

 

        return base.OnDisconnected();

    }

 

    private Task ValidateJwt(Func<string, Task> execute)

    {

        var bearer = Context.QueryString.Get("Bearer");

 

        if (String.IsNullOrWhiteSpace(bearer))

        {

            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));

        }

 

        var principal = CreateClaimsPrincipalFrom(bearer);

 

        if (principal == null || principal.Identity == null || !principal.Identity.IsAuthenticated)

        {

            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));

        }

 

        SetContext(principal);

 

        var userName = Context.User.Identity.Name;

 

        return execute(userName);

    }

 

    private ClaimsPrincipal CreateClaimsPrincipalFrom(string bearer)

    {

        var securityTokenBuilder = new SecurityTokenBuilder();

 

        var tokenValidationParameters = new TokenValidationParameters

        {

            AllowedAudience = Constants.AllowedAudience,

            SigningToken = securityTokenBuilder.CreateFromCertificate(Constants.CertificateSubjectName),

            ValidIssuer = Constants.Issuer

        };

 

        var tokenString = bearer;

        var tokenHandler = CreateTokenHandler();

        var token = CreateToken(tokenString);

        var principal = tokenHandler.ValidateToken(token, tokenValidationParameters);

        return principal;

    }

 

    protected virtual IJwtSecurityToken CreateToken(string tokenString)

    {

        return new JwtSecurityTokenAdapter(tokenString);

    }

 

    protected virtual IJwtSecurityTokenHandler CreateTokenHandler()

    {

        return new JwtSecurityTokenHandlerAdapter();

    }

 

    private void SetContext(ClaimsPrincipal principal)

    {

        Context.Request.Environment["server.User"] = principal;

        Context = new HubCallerContext(

            new ServerRequest(Context.Request.Environment), Context.ConnectionId);

    }

}

 

La demo

Llamamos al hub pasándole el token JWT por QueryString y como podemos observar accedemos a el:

image

Después validarlo y de establecer el contexto nuevo ya podemos acceder al nombre del cliente:

image

Y por último lo añadimos al grupo para poder enviarle notificaciones:

image

Ahora desde el servidor podemos mandar notificaciones a bsmith:

var userName = ClaimsPrincipal.Current.Identity.Name;

var hub = GlobalHost.ConnectionManager.GetHubContext<ErpHub>();

hub.Clients.Group(userName).onProccesedOrder(new Order(){Status = "Cancelado"});

image

Como os he comentado, esta ha sido mi manera de solucionar este caso concreto y quería compartirlo para a ser posible recibir feedback de cualquier tipo así como mejores implementaciones.

Me queda escribir un post comentando como hacer esto mismo usando si usamos el middleware de Owin JwtBearerAuthentication

Un saludo.

Un comentario en “[SignalR] Autenticación JSON Web Token (JWT) en hubs de SignalR”

  1. Hola Luis,

    Respecto a lo que estás haciendo a nivel funcional me parece perfecto y para nada voy a entrar a valorar esto.

    Ahora si me vas a permitir que en cuanto a seguridad se refiere si que te pase mi feedback.

    No me parece apropiado el link de Atlassian por varios motivos.

    1. Porque nunca deberías utilizar un JWT como medio de autorización, sino más bien como medio para obtener un token de sesion que si es el que utilizas para pasar en cabecera o como tu has hecho en SIGNALR en el queryString, esto tiene otras interpretaciones, pero no las voy a tratar.

    2. Por el algoritmo que utiliza HS256, tu crees que no es más fácil de romper que un RS256.

    Con lo cual si recomiendas un sitio donde aprender como funciona JWT yo te invitaría a que tu y tus lectores reviséis estos dos link.

    https://developers.google.com/accounts/docs/OAuth2ServiceAccount?hl=es

    http://salesforce.stackexchange.com/questions/30596/oauth-2-0-jwt-bearer-token-flow

    Fijate en el algoritmo que utilizan ambas empresas y también os invito a hacer todo el proceso de autenticación/autorización para que veáis claramente que nunca se utiliza el JWT para pasarlo en las cabeceras, sino como he dicho antes como mecanismo para hacer una conexion server to server y obtener el token definitivo(sesion) y este si pasarlo en la cabecera.

    Si copio este token de la pagina de Atlassian.

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzODY4OTkxMzEsImlzcyI6ImppcmE6MTU0ODk1OTUiLCJxc2giOiI4MDYzZmY0Y2ExZTQxZGY3YmM5MGM4YWI2ZDBmNjIwN2Q0OTFjZjZkYWQ3YzY2ZWE3OTdiNDYxNGI3MTkyMmU5IiwiaWF0IjoxMzg2ODk4OTUxfQ.uKqU9dTB6gKwG6jQCuXYAiMNdfNRw98Hw_IWuA5MaMo

    Es decir de la url que recomiendas.

    https://developer.atlassian.com/static/connect/docs/concepts/understanding-jwt.html

    Y lo rompo o mejor obtengo la clave privada con la que Atalassian ha firmado ese token ,que me pagas:)

    Por lo pronto te invito a que cojas ese token y te vayas a esta url

    https://developers.google.com/wallet/digital/docs/jwtdecoder

    Lo pegas y verás claro lo que te estoy diciendo.

    1. Vas a obtener un json con la información de cabecera(header).

    {
    “alg”: “HS256”,
    “typ”: “JWT”
    }

    2. Vas a obtener los claims de la segunda parte del token .

    {
    “iss”: “jira:15489595”,
    “iat”: 1386898951,
    “qsh”: “8063ff4ca1e41df7bc90c8ab6d0f6207d491cf6dad7c66ea797b4614b71922e9”,
    “exp”: 1386899131
    }

    3. Vas a obtener este chorizo:)

    uKqU9dTB6gKwG6jQCuXYAiMNdfNRw98Hw_IWuA5MaMo

    Si has pensado lo mismo que yo la dificultad de sacar el chorizo radica en un planteamiento tan sencillo como el despejar la x en esta ecuacion 2x=6.

    Tanto tu mente, la mía y la de cualquiera deduce rápidamente que x=3.

    En el caso de JWT utilizado de esta manera es igual de sencillo, solo tengo que despejar la tercera parte, puesto que base64urlEndoced no es nada encriptado y como has visto se puede codificar/decodificar de forma sencilla.

    Y es ahora donde viene el reto, como se que te acercas por mi tierra en vacaciones te invito a lo siguiente.

    Vamos a ir a comer una buena mariscada con unas cervezas previas y un buen vino, si durante la comida no te digo la clave privada con la que Atalassian ha firmado ese token yo pago la comida, en caso contrario la pagas tu junto con las máquinas que yo elija en Azure para computar(no van a ser muy caras).

    No ves claro que eso no es más que horas de computación:)

    Piénsalo bien y verás que tengo razón y es más ya lo explique en este post mio.

    http://geeks.ms/blogs/phurtado/archive/2014/02/05/jwt-json-web-token-cuando.aspx

    Pero no me importa explicarlo si es necesario 1000 veces.

    USA JWT SOLO Y EXCLUSIVAMENTE PARA OBTENER UN TOKEN DE SESION SERVER TO SERVER, NUNCA COMO MECANISMO DE AUTORIZACION.

    Bueno ya solo falta una cosa y es que la comida la retransmitimos en directo por Hangout:)

    Aceptas el reto?

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *