En este artículo veremos cómo gestionar las cookies de autenticación de Asp.Net Core en el servidor. En el ejemplo veremos cómo usar una base de datos vía Entity Framework Core para persistir y gestionar las cookies. Os indico que este método es extensible para poder usar en vez de nuestro motor de base de datos favorito, un servicio de caché distribuida externo como Redis Caché.
Asp.Net Core por defecto gestiona las cookies en cliente, pero si necesitamos una mayor seguridad que nos permita realizar un logout remoto, controlar el número de sesiones abiertas que tiene un usuario, inclusive eliminar problemas que pueden provocarse al serializar grandes datos en nuestras cookies de autenticación, es necesario el gestionar estas cookies en el servidor.
En el código de demo usaremos Identity Server para la gestión del login de los usuarios.
¿Cómo implementar esto?
Debemos crearnos una clase que implemente el interfaz ITicketStore cuyos métodos a implementar son los siguientes:
Task RemoveAsync(string key); Elimina la identidad asociada con la clave proporcionada.
Task RenewAsync(string key, AuthenticationTicket ticket); Actualiza la identidad cuya clave se ha proporcionado.
Task<AuthenticationTicket> RetrieveAsync(string key); Devuelve la identidad cuya clave se proporciona.
Task<string> StoreAsync(AuthenticationTicket ticket); Almacena el ticket de autenticación y devuelve una clave asociada.
Bien, una vez visto el interfaz que debemos implementar pasemos a algo de código. Lo primero es definir nuestra tabla de la base de datos
1public class AuthenticationTicket23{45 public Guid Id { get; set; }67 public string UserId { get; set; }89 public byte[] Value { get; set; }1011 public DateTimeOffset? LastActivity { get; set; }1213 public DateTimeOffset? Expires { get; set; }1415}16
Como vemos, guardaremos el identificador del usuario, el contenido del ticket de autenticación, su tiempo de expiración para controlar la caducidad de la cookie y generaremos un identificador que es el que devolveremos al cliente y con el cual gestionaremos las operaciones de eliminación, renovación, devolución e inserción en nuestra base de datos. Una vez definido nuestra tabla o nuestro objeto de base de datos, procederemos a implementar un DbContext.
1public class DataProtectionKeysContext : DbContext, IDataProtectionKeyContext23{45 public DataProtectionKeysContext(DbContextOptions<DataProtectionKeysContext> options)67 : base(options) { }89 public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }1011 public DbSet<AuthenticationTicket> AuthenticationTickets { get; set; }1213 protected override void OnModelCreating(ModelBuilder modelBuilder)1415 {1617 modelBuilder.Entity<AuthenticationTicket>().ToTable("AuthenticationTicket").HasKey(t => new { t.Id });1819 base.OnModelCreating(modelBuilder);2021 }2223}24
Si os fijáis, en este DbContext, implemento un interfaz denominado IDataProtectionKeyContext para poder almacenar las claves usadas por Asp.Net Core DataProtection (Más adelante os explicaré en otro artículo sobre cómo configurar nuestras aplicaciones Asp.Net Core para desplegarlas en una granja de servidores) para cifrar/descifrar el contenido del ticket de autenticación).
En el método ConfigureServices de nuestra clase Startup tendrá el siguiente aspecto.
1/// <summary>23/// ConfigureServices45/// </summary>67/// <param name="services"></param>89public void ConfigureServices(IServiceCollection services)1011{1213 services.AddDbContext<DataProtectionKeysContext>(options =>1415 options.UseSqlite(1617 Configuration.GetConnectionString("DataProtectionKeysConnection")));1819 var encryptionSettings = new AuthenticatedEncryptorConfiguration()2021 {2223 EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,2425 ValidationAlgorithm = ValidationAlgorithm.HMACSHA2562627 };2829 services.AddDataProtection()3031 .PersistKeysToDbContext<DataProtectionKeysContext>()3233 .SetApplicationName("demo")3435 .UseCryptographicAlgorithms(encryptionSettings);36373839 services.AddAuthentication(options =>4041 {4243 options.DefaultScheme = "cookie";4445 options.DefaultChallengeScheme = "oidc";4647 })4849 .AddCookie("cookie", options =>5051 {5253 options.ExpireTimeSpan = TimeSpan.FromMinutes(5);5455 options.SlidingExpiration = true;5657 options.SessionStore = new TicketStore(services, DataProtectionProvider.Create(this.GetType().FullName));5859 })6061 .AddOpenIdConnect("oidc", options =>6263 {6465 options.Authority = "https://localhost:5001";6667 options.ClientId = "mvc.client";6869 options.ClientSecret = "36F742BA-D9BF-49FE-B91A-D25E3A6354A5";70717273 // code flow + PKCE (PKCE is turned on by default)7475 options.ResponseType = "code";7677 options.UsePkce = true;7879 options.Scope.Add("openid");8081 options.Scope.Add("profile");8283 options.Scope.Add("email");8485 options.Scope.Add("scope1");8687 options.Scope.Add("scope2");8889 options.GetClaimsFromUserInfoEndpoint = true;9091 options.SaveTokens = true;9293 });9495 services.AddControllersWithViews(options =>9697 {9899 var policy = new AuthorizationPolicyBuilder()100101 .RequireAuthenticatedUser()102103 .Build();104105 options.Filters.Add(new AuthorizeFilter(policy));106107 });108109}110
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 los servicios de autenticación en los cuales configuramos:
El esquema por defecto de autenticación será el de Cookie de autenticación. Sobre la que configuramos tiempo de expiración y que use nuestro TicketStore para almacenar los tickets en nuestra base de datos, al que le pasamos el IServiceCollection para que dentro de nuestra clase podamos resolver fácilmente las dependencias necesarias y un DataProtectorProvider para realizar el cifrado/descrifrado del ticket.
Configurar OpenIdConnect estableciendo a nuestro Identity Server como proveedor de Autoridad.
Agregamos los servicios para los controladores y vistas.
¿Cómo queda entonces la implementación de nuestro TicketStore?
La implementación quedaría de la siguiente forma:
1/// <summary>23/// TicketStore45/// </summary>67public class TicketStore : ITicketStore89{1011 private readonly IServiceCollection services;1213 private readonly IDataProtector dataProtector;1415 public TicketStore(IServiceCollection services, IDataProtectionProvider dataProtectionProvider)1617 {1819 this.services = services;2021 this.dataProtector = dataProtectionProvider.CreateProtector(GetType().FullName);2223 }2425 /// <summary>2627 /// RemoveAsync2829 /// </summary>3031 /// <param name="key"></param>3233 /// <returns></returns>3435 public async Task RemoveAsync(string key)3637 {3839 using var scope = services.BuildServiceProvider().CreateScope();4041 var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();4243 if (Guid.TryParse(key, out var id))4445 {4647 var authenticationTicket = await context.AuthenticationTickets.SingleOrDefaultAsync(x => x.Id == id);4849 if (authenticationTicket != null)5051 {5253 context.AuthenticationTickets.Remove(authenticationTicket);5455 await context.SaveChangesAsync();5657 }5859 }6061 }6263 /// <summary>6465 /// RenewAsync6667 /// </summary>6869 /// <param name="key"></param>7071 /// <param name="ticket"></param>7273 /// <returns></returns>7475 public async Task RenewAsync(string key, AuthenticationTicket ticket)7677 {7879 using var scope = services.BuildServiceProvider().CreateScope();8081 var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();8283 if (Guid.TryParse(key, out var id))8485 {8687 var authenticationTicket = await context.AuthenticationTickets.FindAsync(id);8889 if (authenticationTicket != null)9091 {9293 authenticationTicket.Value = dataProtector.Protect(SerializeToBytes(ticket));9495 authenticationTicket.LastActivity = DateTimeOffset.UtcNow;9697 authenticationTicket.Expires = ticket.Properties.ExpiresUtc;9899 await context.SaveChangesAsync();100101 }102103 }104105 }106107 /// <summary>108109 /// RetrieveAsync110111 /// </summary>112113 /// <param name="key"></param>114115 /// <returns></returns>116117 public async Task<AuthenticationTicket> RetrieveAsync(string key)118119 {120121 using var scope = services.BuildServiceProvider().CreateScope();122123 var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();124125 if (Guid.TryParse(key, out var id))126127 {128129 var authenticationTicket = await context.AuthenticationTickets.FindAsync(id);130131 if (authenticationTicket != null)132133 {134135 authenticationTicket.LastActivity = DateTimeOffset.UtcNow;136137 await context.SaveChangesAsync();138139 return DeserializeFromBytes(dataProtector.Unprotect(authenticationTicket.Value));140141 }142143 }144145 return null;146147 }148149 /// <summary>150151 /// StoreAsync152153 /// </summary>154155 /// <param name="ticket"></param>156157 /// <returns></returns>158159 public async Task<string> StoreAsync(AuthenticationTicket ticket)160161 {162163 const string principalEmailType = "email";164165 using var scope = services.BuildServiceProvider().CreateScope();166167 var userId = ticket.Principal.FindFirst(t => t.Type == principalEmailType)?.Value;168169 var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();170171 var authenticationTicket = new AspNetCoreMvcClient.Data.AuthenticationTicket()172173 {174175 UserId = userId,176177 LastActivity = DateTimeOffset.UtcNow,178179 Value = dataProtector.Protect(SerializeToBytes(ticket)),180181 Expires = ticket.Properties.ExpiresUtc182183 };184185 context.AuthenticationTickets.Add(authenticationTicket);186187 await context.SaveChangesAsync();188189 return authenticationTicket.Id.ToString();190191 }192193 private byte[] SerializeToBytes(AuthenticationTicket source) => TicketSerializer.Default.Serialize(source);194195 private AuthenticationTicket DeserializeFromBytes(byte[] source) => source == null ? null : TicketSerializer.Default.Deserialize(source);196197}198
¿Dónde puedo encontrar el código de ejemplo?
Podéis descargaros los proyectos de ejemplo en: https://github.com/sparraguerra/compartimoss/tree/master/AspNetCoreManageCookieInServer
Conclusiones
Hemos visto lo sencillo que es implementar la gestión de cookies en servidor utilizando para ello el interfaz ITicketStore. Podríamos ir más allá y poder almacenar por ejemplo desde qué IP ha iniciado sesión el usuario, con qué navegador o lo que se nos ocurra.
Happy coding!
Sergio Parra Guerra
Software & Cloud Architect at Encamina
https://twitter.com/sparraguerra