Enmilocalfunciona

Thoughts, stories and ideas.

Construyendo una Web API REST segura con JSON Web Token en .NET (Parte II)

Publicado por Santi Macias el

MicrosoftJWT.NETRESTWEB API

En el artículo anterior, vimos la teoría sobre seguridad basada en JWT, pero cómo implementarlo en .NET de forma correcta, es lo que crea más confusión, ya que en Internet, encontraremos cientos de tutoriales, algunos confusos y otros incompletos con implementaciones totalmente distintas que nos llevan a tener muchas dudas.

Requerimientos Previos

Para implementar JWT son necesarios conocimientos de ASP.NET y el siguiente software instalado:

  • Visual Studio 2017 IDE
  • Gestor de paquetes Nuget
  • Servidor web IIS Express
  • Web HTTP Debugger: Fiddler, Postman, etc
  • ASP.NET MVC y sistema de routing, atributos y filtros
  • ASP.NET WEB API Framework y filosofía REST/API

Implementación de JWT

Entrando en materia, partiendo de la base que conocemos JWT y su ciclo de vida, vamos a realizar un tutorial paso a paso que sea fácil de entender creando un proyecto desde cero.

Recordemos que el ciclo de vida de un Token JWT esta representado en este diagrama:

Creando nuestro proyecto WebAPI

Abrimos Visual Studio 2015/2017 y vamos a crear una aplicación Web ASP.NET, en nuestro caso para .NET Framework:

Le damos el nombre de WebApiSegura y la ubicación por defecto de Visual Studio.

Ahora aparecerá la pantalla donde elegiremos la plantilla de ASP.NET que vamos a utilizar:

Aquí, seleccionamos proyecto Vacío, check en WebAPI y pulsamos botón Aceptar para crear la solución, en este momento empezará a crearse nuestro proyecto.

Una vez generado el proyecto, lo veremos en el Explorador de soluciones de Visual Studio:

Ya tenemos el proyecto creado, nos fijamos que la carpeta "Controllers" y "Models" estan sin nada, esto es así, porque al escoger la plantilla vacía, nos crea el esqueleto del proyecto y las referencias, pero no inserta ningún código extra que pueda ensuciar nuestra WebAPI.

Ejecutando el Proyecto WebAPI

Ejecutamos el proyecto, para ver que todo esta correcto y veremos la siguiente pantalla:

WTF!! tenemos un mensaje "HTTP Error - Forbidden", pero no os preocupéis, ¡está todo correcto!

Entonces, ¿qué representa el "HTTP Error - Forbidden"?: la explicación es sencilla, Visual Studio ejecuta IIS Express local con la aplicación WebAPI (una aplicación web sin vistas) y no tenemos ningún controlador definido, el web.config lo tenemos sin activar mostrar los contenidos y en nuestro proyecto no existe una página index.html o default.html.

En este punto, tenemos un escenario concreto, un IIS Express local, configurado por defecto. Recordar que existen varias formas de configurar el arranque de WebAPI y veréis muchos tutoriales que pueden elegir otros escenarios para ello, por ejemplo OWIN - Open Web Interface for .NET.

Configurando el puerto para WebAPI

Por defecto, nuestra WebAPI se configura en un puerto al crear la solución, pero desde Visual Studio podemos cambiarlo, pulsando botón derecho, opción propiedades sobre WebApiSegura:

En nuestro ejemplo, vamos a trabajar en el puerto 49220 y ahora sabemos dónde cambiar la configuración de IISExpress para levantar nuestra WebAPI y tenerla funcionando.

Como nota, esta configuración del puerto es solo para nuestro entorno de desarrollo, en entornos de producción se publicará en un IIS empresarial y con un puerto diferente.

Descargando la librería oficial para Tokens

Ya podemos detener la ejecución en Visual Studio y cerrar el navegador porque vamos a escribir código en nuestro proyecto.

Antes de empezar, necesitamos la librería oficial: System.IdentityModel.Tokens.Jwt que instalaremos mediante la consola Nuget:

PM> Install-Package System.IdentityModel.Tokens.Jwt

A la hora de escribir este artículo, se han instalado los siguientes packages:

  • Microsoft.IdentityModel.Tokens version 5.2.1
  • Microsoft.IdentityModel.Tokens.Jwt version 5.2.1
  • Microsoft.IdentityModel.Logging version 5.2.1
Creando la estructura WebAPI

Vamos a crear la estructura del API, se recomienda conocer la guía de buenas prácticas API REST y uso de Visual Studio.

En nuestro explorador de soluciones realizar los siguientes pasos:

  1. Sobre la carpeta controllers: botón derecho / agregar / controlador...
  2. Se abrirá una nueva ventana de dialogo para elegir controlador.
  3. Seleccionamos "Controlador WebApi 2 - en blanco".
  4. Nos pedirá el nombre y lo llamaremos "LoginController".
  5. Crear un segundo Controlador como los pasos 1,2,3.
  6. Nos pedirá el nombre y lo llamaremos "CustomersController".
  7. Dentro de carpeta Controller crear una nueva clase "TokenGenerator".
  8. Dentro de carpeta Controller crear una nueva clase "TokenValidationHandler".
  9. Dentro de carpeta Models crear una nueva clase "LoginRequest".

En este punto, ya hemos creado las clases para nuestro proyecto, evidentemente vacías, como vemos en la imagen:

Recordar: los controladores WebAPI heredan de "ApiController" y los controladores MVC de "Controller" en .NET Framework (ojo, en NET Core es diferente).

Implementación del código

Ahora, toca poner código a nuestras clases. Hay muchas formas de realizar esta implementación y esto es lo que más confusión genera entre los desarrolladores, en nuestra caso, trabajaremos con estas premisas:

  • LoginRequest: clase donde recibiremos las credenciales del usuario.
  • IHttpActionResult: para las respuestas HTTP StatusCode al cliente siguiendo filosofía RESTful.
  • [AttributeRoutes]: para decorar las rutas de los controladores del API en cada acción.
  • [Authorize]:Decorador para autorizar peticiones válidas al API (necesitará un JWT válido).
  • [AllowAnonymous]: Decorador para permitir peticiones anónimas al API (no necesitará un JWT)
  • web.config: Definimos los settings necesarios para nuestro Token JWT.

LoginRequest.cs

using System;  
namespace WebApiSegura.Models  
{
    public class LoginRequest
    {
        public string Username { get; set; }
        public string Password { get; set; }
    }
}

LoginController.cs

using System;  
using System.Net;  
using System.Threading;  
using System.Web.Http;  
using WebApiSegura.Models;

namespace WebApiSegura.Controllers  
{
    /// <summary>
    /// login controller class for authenticate users
    /// </summary>
    [AllowAnonymous]
    [RoutePrefix("api/login")]
    public class LoginController : ApiController
    {
        [HttpGet]
        [Route("echoping")]
        public IHttpActionResult EchoPing()
        {
            return Ok(true);
        }

        [HttpGet]
        [Route("echouser")]
        public IHttpActionResult EchoUser()
        {
            var identity = Thread.CurrentPrincipal.Identity;
            return Ok($" IPrincipal-user: {identity.Name} - IsAuthenticated: {identity.IsAuthenticated}");
        }

        [HttpPost]
        [Route("authenticate")]
        public IHttpActionResult Authenticate(LoginRequest login)
        {
            if (login == null)
                throw new HttpResponseException(HttpStatusCode.BadRequest);

            //TODO: Validate credentials Correctly, this code is only for demo !!
            bool isCredentialValid = (login.Password == "123456");
            if (isCredentialValid)
            {
                var token = TokenGenerator.GenerateTokenJwt(login.Username);
                return Ok(token);
            }
            else
            {
                return Unauthorized();
            }
        }
    }
}

CustomersController.cs

using System.Web.Http;

namespace WebApiSegura.Controllers  
{
    /// <summary>
    /// customer controller class for testing security token
    /// </summary>
    [Authorize]
    [RoutePrefix("api/customers")]
    public class CustomersController : ApiController
    {
        [HttpGet]
        public IHttpActionResult GetId(int id)
        {
            var customerFake = "customer-fake";
            return Ok(customerFake);
        }

        [HttpGet]
        public IHttpActionResult GetAll()
        {
            var customersFake = new string[] { "customer-1", "customer-2", "customer-3" };
            return Ok(customersFake);
        }
    }
}

TokenGenerator.cs

using System;  
using System.Configuration;  
using System.Security.Claims;  
using Microsoft.IdentityModel.Tokens;

namespace WebApiSegura.Controllers  
{
    /// <summary>
    /// JWT Token generator class using "secret-key"
    /// more info: https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html
    /// </summary>
    internal static class TokenGenerator
    {
        public static string GenerateTokenJwt(string username)
        {
            // appsetting for Token JWT
            var secretKey = ConfigurationManager.AppSettings["JWT_SECRET_KEY"];
            var audienceToken = ConfigurationManager.AppSettings["JWT_AUDIENCE_TOKEN"];
            var issuerToken = ConfigurationManager.AppSettings["JWT_ISSUER_TOKEN"];
            var expireTime = ConfigurationManager.AppSettings["JWT_EXPIRE_MINUTES"];

            var securityKey = new SymmetricSecurityKey(System.Text.Encoding.Default.GetBytes(secretKey));
            var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);

            // create a claimsIdentity
            ClaimsIdentity claimsIdentity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, username) });

            // create token to the user
            var tokenHandler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
            var jwtSecurityToken = tokenHandler.CreateJwtSecurityToken(
                audience: audienceToken,
                issuer: issuerToken,
                subject: claimsIdentity,
                notBefore: DateTime.UtcNow,
                expires: DateTime.UtcNow.AddMinutes(Convert.ToInt32(expireTime)),
                signingCredentials: signingCredentials);

            var jwtTokenString = tokenHandler.WriteToken(jwtSecurityToken);
            return jwtTokenString;
        }
    }
}

TokenValidationHandler.cs

using System;  
using System.Collections.Generic;  
using System.Configuration;  
using System.Linq;  
using System.Net;  
using System.Net.Http;  
using System.Threading;  
using System.Threading.Tasks;  
using System.Web;  
using Microsoft.IdentityModel.Tokens;

namespace WebApiSegura.Controllers  
{
    /// <summary>
    /// Token validator for Authorization Request using a DelegatingHandler
    /// </summary>
    internal class TokenValidationHandler : DelegatingHandler
    {
        private static bool TryRetrieveToken(HttpRequestMessage request, out string token)
        {
            token = null;
            IEnumerable<string> authzHeaders;
            if (!request.Headers.TryGetValues("Authorization", out authzHeaders) || authzHeaders.Count() > 1)
            {
                return false;
            }
            var bearerToken = authzHeaders.ElementAt(0);
            token = bearerToken.StartsWith("Bearer ") ? bearerToken.Substring(7) : bearerToken;
            return true;
        }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            HttpStatusCode statusCode;
            string token;

            // determine whether a jwt exists or not
            if (!TryRetrieveToken(request, out token))
            {
                statusCode = HttpStatusCode.Unauthorized;
                return base.SendAsync(request, cancellationToken);
            }

            try
            {
                var secretKey = ConfigurationManager.AppSettings["JWT_SECRET_KEY"];
                var audienceToken = ConfigurationManager.AppSettings["JWT_AUDIENCE_TOKEN"];
                var issuerToken = ConfigurationManager.AppSettings["JWT_ISSUER_TOKEN"];
                var securityKey = new SymmetricSecurityKey(System.Text.Encoding.Default.GetBytes(secretKey));

                SecurityToken securityToken;
                var tokenHandler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
                TokenValidationParameters validationParameters = new TokenValidationParameters()
                {
                    ValidAudience = audienceToken,
                    ValidIssuer = issuerToken,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    LifetimeValidator = this.LifetimeValidator,
                    IssuerSigningKey = securityKey
                };

                // Extract and assign Current Principal and user
                Thread.CurrentPrincipal = tokenHandler.ValidateToken(token, validationParameters, out securityToken);
                HttpContext.Current.User = tokenHandler.ValidateToken(token, validationParameters, out securityToken);

                return base.SendAsync(request, cancellationToken);
            }
            catch (SecurityTokenValidationException)
            {
                statusCode = HttpStatusCode.Unauthorized;
            }
            catch (Exception)
            {
                statusCode = HttpStatusCode.InternalServerError;
            }

            return Task<HttpResponseMessage>.Factory.StartNew(() => new HttpResponseMessage(statusCode) { });
        }

        public bool LifetimeValidator(DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters)
        {
            if (expires != null)
            {
                if (DateTime.UtcNow < expires) return true;
            }
            return false;
        }
    }
}

Global.asax

using System;  
using System.Web.Http;

namespace WebApiSegura  
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            GlobalConfiguration.Configure(WebApiConfig.Register);
        }
    }
}

WebApiConfig.cs

using System;  
using System.Web.Http;  
using WebApiSegura.Controllers;

namespace WebApiSegura  
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Configuración de rutas y servicios de API
            config.MapHttpAttributeRoutes();

            config.MessageHandlers.Add(new TokenValidationHandler());

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
        }
    }
}

Web.config

<?xml version="1.0" encoding="utf-8"?>  
<configuration>  
  <appSettings>
    <add key="JWT_SECRET_KEY"     value="clave-secreta-api"/>
    <add key="JWT_AUDIENCE_TOKEN" value="http://localhost:49220"/>
    <add key="JWT_ISSUER_TOKEN"   value="http://localhost:49220"/>
    <add key="JWT_EXPIRE_MINUTES" value="30"/>   
  </appSettings>

  <system.web>
    <compilation debug="true" targetFramework="4.6.1" />
    <httpRuntime targetFramework="4.6.1" />
  </system.web>

  <system.webServer>
    <handlers>
      <remove name="ExtensionlessUrlHandler-Integrated-4.0" />
      <remove name="OPTIONSVerbHandler" />
      <remove name="TRACEVerbHandler" />
      <add name="ExtensionlessUrlHandler-Integrated-4.0" path="*." verb="*" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" />
    </handlers>
  </system.webServer>

  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <dependentAssembly>
        <assemblyIdentity name="System.Web.Helpers" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Web.Mvc" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-5.2.3.0" newVersion="5.2.3.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Web.WebPages" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-10.0.0.0" newVersion="10.0.0.0" />
      </dependentAssembly>
    </assemblyBinding>
  </runtime>

  <system.codedom>
    <compilers>
      <compiler language="c#;cs;csharp" extension=".cs" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.CSharpCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=1.0.7.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" warningLevel="4" compilerOptions="/langversion:default /nowarn:1659;1699;1701" />
      <compiler language="vb;vbs;visualbasic;vbscript" extension=".vb" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.VBCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=1.0.7.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" warningLevel="4" compilerOptions="/langversion:default /nowarn:41008 /define:_MYTYPE=\&quot;Web\&quot; /optionInfer+" />
    </compilers>
  </system.codedom>
</configuration>  
Comprobando que todo funciona

Una vez tenemos el codigo listo, vamos a verificar que realmente nuestra API funciona y hace lo que debe.

Vamos a llamar al LoginController, este encarga de validarnos y enviarnos el Token JWT, y luego, hacer peticiones al CustomerController enviando el Token JWT.

Recordar que es importante tener los controllers "decorados" correctamente.

  • LoginController: no requiere Token porque lo tenemos definido con [AllowAnonymous].
  • CustomerController: si requiere Token porque lo tenemos definido con [Authorize].

Pasos para realizar la prueba:

  • Ejecutamos la solución para levantar WebAPI en IIS Express
  • Abrimos Fiddler, mi herramienta web favorita, descargarla aquí: Fiddler Web Debugger Proxy

Paso-1: Realizamos petición GET .../api/login/echoping para verificar que el controlador responde OK:

Paso-2: Realizamos petición GET .../api/login/echouser para ver si hay algún usuario autenticado:

Paso-3: Realizamos petición POST .../api/login/authenticate para enviar las credenciales y obtener el Token JWT:

Paso-4:Obtenemos la respuesta POST .../api/login/authenticate con el Token JWT, recordar "codificado".

Como vemos, ya hemos generado el JWT para el cliente, el cliente deberá enviarnos este token JWT en las cabeceras de cada petición, con el formato Authorization: Bearer TOKEN_STRING.

Paso-5: Realizamos petición GET .../api/customers para pedir datos de clientes, sin indicar Token JWT:

En esta caso, nuestro API responde con 401 Unauthorized, ya que no hemos recibido ningún Token:

Paso-6: Realizamos petición GET .../api/customers para pedir datos de clientes, incluyendo Token JWT:

En esta caso, nuestro API responde con 200 OK, ya que hemos enviado el Token JWT:

Recordar, debemos enviar desde el cliente el JWT en las cabeceras de esta forma: Authorization: Bearer TOKEN_STRING como vemos en la imagen.

Conclusiones Finales

Un Token JWT, es especialmente útil para autenticar y autorizar usuarios para consumir nuestro servicios del API, ya que, permite la transferencia segura de datos utilizando el poder de la firma digital y el hash, así como, especificar un tiempo de validez para que el Token caduque.

Además, al trabajar con un protocolo sin estado como REST, tenemos un ahorro de memoria y acceso a datos considerable, en comparación con el enfoque tradicional: cookies y sesiones para mantener el estado el cliente y acceso a datos para obtener la información de usuario, roles y claims.

Es importante remarcar, que la aplicación cliente (web, desktop, móvil), tiene la responsabilidad de almacenar el JWT en algún sitio seguro,  para utilizarlo en todas las llamadas al API, usando las mejores prácticas según cada plataforma y guías de seguridad.

Por último, no olvidar nunca, siempre debemos publicar nuestro API mediante un certificado HTTPS para encriptar el contenido entre el servidor y el cliente.

Proyecto en GitHub

La guía de referencia sobre JWT la podéis encontrar aquí: https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html

Si queréis ver el proyecto completo, descargarlo y ver cómo funciona paso a paso, aquí os dejo el enlace: https://github.com/santimacnet/WebAPI-Segura-JWT

Espero que esta información sea de utilidad y ayude en la seguridad de vuestras APIs. No dudéis en hacer cualquier consulta en los comentarios. Y si queréis estar al día de más posts, no olvidéis seguirnos en Twitter.

Happy Coding!!

Autor

Santi Macias

Microsoft Tech Lead en knowmad mood, +20 años trabajando con tecnologías Microsoft actualmente centrado sobretodo en Azure, Cloud Native, DevOps, Docker, Kubernetes, Microservicios y Serverless.