Asp.Net Core - Gestionando Cookies De Autenticacion Server Side

Escrito por  Sergio Parra Guerra

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 AuthenticationTicket
2
3{
4
5    public Guid Id { get; set; }
6
7    public string UserId { get; set; }
8
9    public byte[] Value { get; set; }
10
11    public DateTimeOffset? LastActivity { get; set; }
12
13    public DateTimeOffset? Expires { get; set; }
14
15}
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, IDataProtectionKeyContext
2
3
4
5    public DataProtectionKeysContext(DbContextOptions<DataProtectionKeysContext> options)
6
7        : base(options) { } 
8
9    public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
10
11    public DbSet<AuthenticationTicket> AuthenticationTickets { get; set; }
12
13    protected override void OnModelCreating(ModelBuilder modelBuilder)
14
15    {
16
17        modelBuilder.Entity<AuthenticationTicket>().ToTable("AuthenticationTicket").HasKey(t => new { t.Id });
18
19        base.OnModelCreating(modelBuilder);
20
21    }
22
23}
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>
2
3/// ConfigureServices
4
5/// </summary>
6
7/// <param name="services"></param>
8
9public void ConfigureServices(IServiceCollection services)
10
11{            
12
13    services.AddDbContext<DataProtectionKeysContext>(options =>
14
15        options.UseSqlite(
16
17            Configuration.GetConnectionString("DataProtectionKeysConnection")));
18
19    var encryptionSettings = new AuthenticatedEncryptorConfiguration()
20
21    {
22
23        EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC,
24
25        ValidationAlgorithm = ValidationAlgorithm.HMACSHA256
26
27    };
28
29    services.AddDataProtection()
30
31        .PersistKeysToDbContext<DataProtectionKeysContext>()
32
33        .SetApplicationName("demo")
34
35        .UseCryptographicAlgorithms(encryptionSettings);
36
37            
38
39    services.AddAuthentication(options =>
40
41    {
42
43        options.DefaultScheme = "cookie";
44
45        options.DefaultChallengeScheme = "oidc";
46
47    })
48
49    .AddCookie("cookie", options =>
50
51    {
52
53        options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
54
55        options.SlidingExpiration = true;
56
57        options.SessionStore = new TicketStore(services, DataProtectionProvider.Create(this.GetType().FullName));
58
59    })
60
61    .AddOpenIdConnect("oidc", options =>
62
63    {
64
65        options.Authority = "https://localhost:5001";
66
67        options.ClientId = "mvc.client";
68
69        options.ClientSecret = "36F742BA-D9BF-49FE-B91A-D25E3A6354A5";
70
71                 
72
73        // code flow + PKCE (PKCE is turned on by default)
74
75        options.ResponseType = "code";
76
77        options.UsePkce = true;
78
79        options.Scope.Add("openid");
80
81        options.Scope.Add("profile");
82
83        options.Scope.Add("email");
84
85        options.Scope.Add("scope1");
86
87        options.Scope.Add("scope2");
88
89        options.GetClaimsFromUserInfoEndpoint = true;
90
91        options.SaveTokens = true;
92
93    });
94
95    services.AddControllersWithViews(options =>
96
97    {
98
99        var policy = new AuthorizationPolicyBuilder()
100
101            .RequireAuthenticatedUser()
102
103            .Build();
104
105        options.Filters.Add(new AuthorizeFilter(policy));
106
107    });
108
109}
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>
2
3/// TicketStore
4
5/// </summary>
6
7public class TicketStore : ITicketStore
8
9{
10
11    private readonly IServiceCollection services;
12
13    private readonly IDataProtector dataProtector;
14
15    public TicketStore(IServiceCollection services, IDataProtectionProvider dataProtectionProvider)
16
17    {
18
19        this.services = services;
20
21        this.dataProtector = dataProtectionProvider.CreateProtector(GetType().FullName);
22
23    }
24
25    /// <summary>
26
27    /// RemoveAsync
28
29    /// </summary>
30
31    /// <param name="key"></param>
32
33    /// <returns></returns>
34
35    public async Task RemoveAsync(string key)
36
37    {
38
39        using var scope = services.BuildServiceProvider().CreateScope();
40
41        var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();
42
43        if (Guid.TryParse(key, out var id))
44
45        {
46
47            var authenticationTicket = await context.AuthenticationTickets.SingleOrDefaultAsync(x => x.Id == id);
48
49            if (authenticationTicket != null)
50
51            {
52
53                context.AuthenticationTickets.Remove(authenticationTicket);
54
55                await context.SaveChangesAsync();
56
57            }
58
59        }
60
61    }
62
63    /// <summary>
64
65    /// RenewAsync
66
67    /// </summary>
68
69    /// <param name="key"></param>
70
71    /// <param name="ticket"></param>
72
73    /// <returns></returns>
74
75    public async Task RenewAsync(string key, AuthenticationTicket ticket)
76
77    {
78
79        using var scope = services.BuildServiceProvider().CreateScope();
80
81        var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();
82
83        if (Guid.TryParse(key, out var id))
84
85        {
86
87            var authenticationTicket = await context.AuthenticationTickets.FindAsync(id);
88
89            if (authenticationTicket != null)
90
91            {
92
93                authenticationTicket.Value = dataProtector.Protect(SerializeToBytes(ticket));
94
95                authenticationTicket.LastActivity = DateTimeOffset.UtcNow;
96
97                authenticationTicket.Expires = ticket.Properties.ExpiresUtc;
98
99                await context.SaveChangesAsync();
100
101            }
102
103        }
104
105    }
106
107    /// <summary>
108
109    /// RetrieveAsync
110
111    /// </summary>
112
113    /// <param name="key"></param>
114
115    /// <returns></returns>
116
117    public async Task<AuthenticationTicket> RetrieveAsync(string key)
118
119    {
120
121        using var scope = services.BuildServiceProvider().CreateScope();
122
123        var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();
124
125        if (Guid.TryParse(key, out var id))
126
127        {
128
129            var authenticationTicket = await context.AuthenticationTickets.FindAsync(id);
130
131            if (authenticationTicket != null)
132
133            {
134
135                authenticationTicket.LastActivity = DateTimeOffset.UtcNow;
136
137                await context.SaveChangesAsync();
138
139                return DeserializeFromBytes(dataProtector.Unprotect(authenticationTicket.Value));
140
141            }
142
143        }
144
145        return null;
146
147    }
148
149    /// <summary>
150
151    /// StoreAsync
152
153    /// </summary>
154
155    /// <param name="ticket"></param>
156
157    /// <returns></returns>
158
159    public async Task<string> StoreAsync(AuthenticationTicket ticket)
160
161    {
162
163        const string principalEmailType = "email";
164
165        using var scope = services.BuildServiceProvider().CreateScope();
166
167        var userId = ticket.Principal.FindFirst(t => t.Type == principalEmailType)?.Value;
168
169        var context = scope.ServiceProvider.GetService<DataProtectionKeysContext>();
170
171        var authenticationTicket = new AspNetCoreMvcClient.Data.AuthenticationTicket()
172
173        {
174
175            UserId = userId,
176
177            LastActivity = DateTimeOffset.UtcNow,
178
179            Value = dataProtector.Protect(SerializeToBytes(ticket)),
180
181            Expires = ticket.Properties.ExpiresUtc
182
183        };
184
185        context.AuthenticationTickets.Add(authenticationTicket);
186
187        await context.SaveChangesAsync();
188
189        return authenticationTicket.Id.ToString();
190
191    }
192
193    private byte[] SerializeToBytes(AuthenticationTicket source) => TicketSerializer.Default.Serialize(source);
194
195    private AuthenticationTicket DeserializeFromBytes(byte[] source) => source == null ? null : TicketSerializer.Default.Deserialize(source);
196
197}
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

Siguemos en LinkedInSiguemos en Twitter
Powered by  ENCAMINA