ASP.NET Core - Cómo configurar tu aplicación para despliegue en una granja de servidores

Escrito por  Sergio Parra Guerra

En este artículo, continuación de ASP.NET Core - Gestionando Cookies De Autenticación Server Side, veremos cómo hay que configurar tu aplicación ASP.NET Core para que se pueda desplegar en una granja de servidores de una forma exitosa.

Lo primero que necesitamos saber es qué es una granja de servidores:

Una granja de servidores es un conjunto de dos o más servidores web (nodos) que pueden albergar varias instancias de una aplicación web.

Una granja siempre se encuentra detrás de un load balancer o balanceador de carga, que se encarga de distribuir todas las peticiones recibidas a los diferentes nodos que componen dicha granja.

Ventajas de emplear una granja de servidores

  • Fiablidad. Cuando un nodo falla, el balanceador de carga distribuye las peticiones a aquellos que están disponibles.

  • Rendimiento. Tener varios nodos permite manejar muchas más peticiones.

  • Escalabilidad. El sistema puede escalar a mayor o menor número de nodos según la carga de trabajo.

Si no se realiza correctamente la configuración de nuestra aplicación para poder desplegar en una granja de servidores, obtendremos seguro, unas excepciones en muchas peticiones. ¿Por qué? Porque tomemos un ejemplo de encriptar una cookie desde una instancia de ASP.NET Core, al mandar ese valor de vuelta y el load balancer mande la petición a otra, ésta no será capaz de desencriptar dicha cookie. Esto es muy común en el middleware de autenticación de OpenId.

Las partes fundamentales que debemos tener en cuenta para tener éxito en el despliegue en una granja de servidores son:

  • Data Protection.

  • Caching.

Data Protection

Al activar Data Protection, por defecto las claves se almacenan en disco, por lo que un nodo no puede descifrar algo cifrado por otro ya que las claves son distintas. Para ello es necesario establecer un punto común de claves que se compartan a través de todos los nodos de la granja.

Caching

En una granja nuestro sistema de cacheo debe gestionar elementos de todos los nodos de la granja, como se indicó en el punto anterior, buscamos un punto común compartido. La ventaja de tener el sistema de cacheo de forma distribuida es que no consume recursos de nuestro nodo y que por ejemplo al reiniciarlo, la caché sigue persistida.

En el ejemplo veremos cómo usar una base de datos vía Entity Framework Core para persistir y gestionar las claves de protección, así como establecer un sistema de caché distribuido, empleando para ello SQL Server como motor de base de datos.

También en el código de demo usaremos Identity Server para la gestión del login de los usuarios.

¿Cómo implementar esto?

Para configurar Data Protection en este ejemplo lo que se procede a implementar un DbContext.

1public class DataProtectionKeysContext : DbContext, IDataProtectionKeyContext
2
3    public DataProtectionKeysContext(DbContextOptions\<DataProtectionKeysContext\> options)
4        : base(options) { } 
5
6    public DbSet\<DataProtectionKey\> DataProtectionKeys { get; set; }
7    public DbSet\<Customer\> Customers { get; set; }
8
9    protected override void OnModelCreating(ModelBuilder modelBuilder)
10    {
11        modelBuilder.Entity\<Customer\>().ToTable(\"Customers\").HasKey(t =\> new { t.Id });
12        base.OnModelCreating(modelBuilder);
13    }
14}
15

Si os fijáis, en este DbContext, implemento un interfaz denominado IDataProtectionKeyContext para poder almacenar las claves usadas por ASP.NET Core DataProtection.

Data Protection permite guardar las claves tanto en ficheros como blobs de Azure Storage, base de datos, Azure Key Vault, etc... así como utilizar certificados para firmar y añadir un plus de protección a nuestras claves.

Os recomiendo echéis un vistazo a Configure ASP.NET Core Data Protection para que veáis varias opciones.

Para configurar el servicio de caché distribuido es necesario que os descarguéis el paquete Microsoft.Extensions.Caching.SqlServer.

Ejecutar el siguiente comando dotnet para generar la tabla de base datos que actuará para almacenar los elementos cacheados

PM> dotnet sql-cache create \"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=aspnet-AspNetCoreHostInWebFarm;Integrated Security=True;\" dbo DistributedCache

En este caso se va a configurar la tabla denominada DistributedCache en el servidor indicado en la cadena de conexión.

La tabla también la podremos crear con el siguiente script de SQL:

1USE [aspnet-AspNetCoreHostInWebFarm]
2GO
3SET ANSI_NULLS ON
4GO
5SET QUOTED_IDENTIFIER ON
6GO
7CREATE TABLE [dbo].[DistributedCache](
8 [Id] [nvarchar](449) NOT NULL,
9 [Value] [varbinary](max) NOT NULL,
10 [ExpiresAtTime] [datetimeoffset](7) NOT NULL,
11 [SlidingExpirationInSeconds] [bigint] NULL,
12 [AbsoluteExpiration] [datetimeoffset](7) NULL,
13 PRIMARY KEY CLUSTERED
14 (
15 [Id] ASC
16 ) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY =
17 OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON \[PRIMARY\]
18 ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
19GO
20

Una vez hecho esto sólo queda configurar la caché agregando el servicio en el motor de inyección de dependencias de Net Core.

No se me olvida comentaros que el interfaz a usar en nuestra caché distribuida es IDistributedCache.

En el método ConfigureServices de nuestra clase Startup tendrá el siguiente aspecto.

1///<summary>
2///ConfigureServices
3///</summary>
4/// <param name="services"></param>
5// This method gets called by the runtime. Use this method to add services to the container.
6public void ConfigureServices(IServiceCollection services)
7{
8    services.AddControllersWithViews();
9    // Configure Data Protection
10    var encryptionSettings = new AuthenticatedEncryptorConfiguration()
11    {
12        EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
13        ValidationAlgorithm = ValidationAlgorithm.HMACSHA256
14    }; 
15    services.AddDbContext\<DataProtectionKeysContext\>(options =\>
16        options.UseSqlServer(
17            Configuration.GetConnectionString(\"DataProtectionKeysConnection\")));
18             
19    services.AddDataProtection()
20        .PersistKeysToDbContext\<DataProtectionKeysContext\>()
21        .SetApplicationName(\"demo\")
22        .UseCryptographicAlgorithms(encryptionSettings);
23    // Configure Distributed Cache
24    services.AddDistributedSqlServerCache(options =\>
25    {
26        options.ConnectionString = Configuration.GetConnectionString(\"DataProtectionKeysConnection\");
27        options.SchemaName = \"dbo\";
28        options.TableName = \"DistributedCache\";
29    });
30    services.AddAuthentication(options =\>
31    {
32        options.DefaultScheme = \"cookie\";
33        options.DefaultChallengeScheme = \"oidc\";
34    })
35    .AddCookie(\"cookie\")
36    .AddOpenIdConnect(\"oidc\", options =\>
37    {
38        options.Authority = \"https://localhost:5001\";
39        options.ClientId = \"mvc.client\";
40        options.ClientSecret = \"36F742BA-D9BF-49FE-B91A-D25E3A6354A5\";
41                 
42        // code flow + PKCE (PKCE is turned on by default)
43        options.ResponseType = \"code\";
44        options.UsePkce = true;
45        options.Scope.Add(\"openid\");
46        options.Scope.Add(\"profile\");
47        options.Scope.Add(\"scope1\");
48        options.Scope.Add(\"scope2\");
49        options.GetClaimsFromUserInfoEndpoint = true;
50        options.SaveTokens = true;
51    });
52}
53

Procedo a explicar el código para que no os perdáis:

  • Incluimos nuestro contexto de base de datos en el motor de inyección de dependencias.

  • Agregamos los servicios de DataProtection e indicamos que las claves se deben incluir en el contexto de base de datos del punto anterior.

  • Agregamos el servicio de caché distribuida de SQL Server que empleará la base de datos indicada en su cadena de conexión, puede ser una tabla de nuestra base de datos de la aplicación u otra externa.

  • Agregamos los servicios de autenticación en los cuales configuramos.

  • Agregamos los servicios para los controladores y vistas.

¿Cómo verificamos que todo esto funciona?

Para verificar que las claves de Data Protection se han generado correctamente, con tan solo iniciar nuestra aplicación y realizar la siguiente consulta veremos resultados:

1SELECT TOP (1000) [Id]
2,[FriendlyName]
3,[Xml]
4FROM [aspnet-AspNetCoreHostInWebFarm].[dbo].[DataProtectionKeys]
5

image1

El XML generado con la clave tendría más o menos el siguiente aspecto:

1\<key id=\"b137d8b7-30d5-4fac-84e6-f0dde9ecce7a\" version=\"1\"\>
2
3  \<creationDate\>2020-12-06T23:22:00.3016934Z\</creationDate\>
4
5  \<activationDate\>2020-12-06T23:21:57.6285509Z\</activationDate\>
6
7  \<expirationDate\>2021-03-06T23:21:57.6285509Z\</expirationDate\>
8
9  \<descriptor deserializerType=\"Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=3.1.9.0, Culture=neutral, PublicKeyToken=adb9793829ddae60\"\>
10
11    \<descriptor\>
12
13      \<encryption algorithm=\"AES_256_CBC\" /\>
14
15      \<validation algorithm=\"HMACSHA256\" /\>
16
17      \<masterKey p4:requiresEncryption=\"true\" xmlns:p4=\"http://schemas.asp.net/2015/03/dataProtection\"\>
18
19        \<!\-- Warning: the key below is in an unencrypted form. \--\>
20
21        \<value\>oWvh4tBJ8LqoCexLNWPx0cf4CJftdaI667N5ztM1+JdcOa4afKhRgWWUvG6Cty5KLaCW8OK32xVgk34gYxxdQA==\</value\>
22
23      \</masterKey\>
24
25    \</descriptor\>
26
27  \</descriptor\>
28
29\</key\>
30

Como se puede observar, se ven los algoritmos configurados AES_256_CBC y HMACSHA26.

Veamos ahora ejemplos de cacheo.

1[Authorize]
2public class HomeController : Controller
3{
4    private readonly IDistributedCache distributedCache;
5    private readonly DataProtectionKeysContext dbContext;
6    public HomeController(IDistributedCache distributedCache, DataProtectionKeysContext dbContext)
7    {
8        this.distributedCache = distributedCache;
9        this.dbContext = dbContext;
10    }
11    public IActionResult Index()
12    {
13        return View();
14    }
15    public IActionResult Privacy()
16    {
17        return View();
18    }
19    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
20    public IActionResult Error()
21    {
22        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
23    }
24    public async Task\<IActionResult\> AddCacheItem()
25    {
26        string key = \"customerKey:1\";
27        var customer = new Customer()
28        {
29            Id = Guid.NewGuid(),
30            Name = \"Test Name\",
31            Surname = \"Test Surname\",
32            BirthDay = DateTime.UtcNow.AddYears(-18)
33        };
34        await dbContext.AddAsync\<Customer\>(customer);
35        await dbContext.SaveChangesAsync();
36        await distributedCache.SetStringAsync(key, JsonConvert.SerializeObject(customer));
37        return Ok(\"success\");
38    }
39    public async Task\<IActionResult\> GetCacheItem()
40    {
41        string key = \"customerKey:1\";
42        var cachedItem = await distributedCache.GetStringAsync(key);
43        if (!string.IsNullOrWhiteSpace(cachedItem))
44        {
45            return Ok(JsonConvert.DeserializeObject\<Customer\>(cachedItem));
46        }
47        else
48        {
49            var customer = await dbContext.Customers.FirstOrDefaultAsync(t =\> t.Name == \"Test Name\");
50            await distributedCache.SetStringAsync(key, JsonConvert.SerializeObject(customer));
51            return Ok(customer);
52        }
53    }
54    public async Task\<IActionResult\> DeleteCacheItem()
55    {
56        string key = \"customerKey:1\";
57        await distributedCache.RemoveAsync(key);
58        return Ok(\"success\");
59    }
60}
61

Al ejecutar por ejemplo el método AddCacheItem() podemos ejecutar la consulta para ver el elemento cacheado.

1SELECT TOP (1000) [Id]
2,[Value]
3,[ExpiresAtTime]
4,[SlidingExpirationInSeconds]
5,[AbsoluteExpiration]
6FROM [aspnet-AspNetCoreHostInWebFarm].[dbo].[DistributedCache]
7

image2

¿Dónde puedo encontrar el código de ejemplo?

Podéis descargaros los proyectos de ejemplo en https://github.com/sparraguerra/compartimoss/tree/master/AspNetCoreHostInWebFarm

Conclusiones

Hemos visto lo sencillo que es implementar la configuración de una aplicación para que funcione en una granja de servidores, y hemos comprobado la importancia que tiene Data Protection y nuestro sistema de caching para ello.

Happy coding!

Sergio Parra Guerra
Software & Cloud Architect at Encamina
https://twitter.com/sparraguerra