Hoy traigo un tema que a mi, personalmente, me ha traido por el caminio de la amrgura durante 5 o 6 días. La autenticación de usuarios está muy bien en ASP NET CORE si utilizas las herramientes que ellos te dan con el Identitiy, pero ¿qué pasa cuando no quieres, o no puedes usar Identity? Éste era mi caso, ya que la base de datos ya estaba en producción y tienen más de 20 años de antigüedad, y para colmo, no se pueden hacer grandes cambios en los procesos.
En otro proyecto, para para la misma empresa, soluciné ésto desde ASP WebFroms de una forma bastante sencilla con el login básico. Luego se precisó que se almaceran algunos datos del usuario en el inicio de sessión, y lo pude solucionar haciendo una integración personalizada del MembershipProvider. Aquí os dejo el enlace del blog de cómo se hace en ASP WebForms o MVC ya que funciona para los dos.
En este nuevo proyecto con .NET CORE 2.2 quería seguir utilizando algo como éso, ya que la cookie contendría toda la información que se necesita del usuario sin tener que estar consultando la base de datos cada vez que se necesite, por ejemplo, el nombre de usuario, o el mail o el teléfono. Pero en NET CORE se me complicó bastente el tema. Lo que es identificar al usuario es, digamos, sencillo. Pero luego si quieres encapsular ésto en una clase, para luego, poder utilizar los datos de la cookie desde cualquier lugar de tu web, éso, fue lo que me complicó la vida.
Como siempre, no sé si me implementación es la más adecuada, o si se podría haber hecho de otra forma más facíl pero el tema es que funciona, me gusta como ha quedado, y aqui os la presento para si a otros le pueda solucionar sus problemas cuando necesitan una forma personalizada de manejar el autentícación del usuario. Recordar que es una autenticación con cookies para ASP NET CORE 2, pero puede que funcione en otras versiones de NET CORE.
Presentación de la solución
Desde que trabajo con MVC, me gusta la idea de crear un projecto separado para toda la gestión del Modelo e incluso otro para la gestion de las Vistas, por lo que trabajo, casi se puede decir, como MVVM. En mi modelo es donde voy a hacer toda la integración de la clase Identity, que es como la he llamdo, en mi vista modelo es dónde voy a controlar la presentación de la página de inicio de sesión. Finalmente el controlador sólo tendrá que pasar el HttpContext a mi modelo Identity para realizar el proceso de autenticación.
Aclarar que de esta manera también simplificamos la cantidad de Nugets necesarios en el projecto de la web, ya que pasaremos algunos al projecto del modelo. Básicamente en el projecto del modelo necesitaremos un solo paquete el Microsoft.AspNetCore.Authentication.Cookies, y no necesitaremos agregarlo en el projecto de la web.
Para mi implementación necesitaremos 2 clases Identity y ApplicationUser. La primera es la encargada de autenticar el usuario y manejar la cookie, devolviendo los datos del usuario que necesitemos. La segunda es dónde definiremos todas las propiedas, o datos, que queremos almacenar el usuario. Además, si así lo deseamos, es en ésta clase dónde definiremos los métitos para agregar, editar o eliminar usuarios. Éstas dos clases las crearemos en el projecto del modelo, para así poder utilizarlas desde cualquier parte del projecto.
Configuración
En el fichero Startup.cs de nuestra aplicación, en el método Startup.ConfigureServices, crearemos el servicio authentication Middleware con los métodos AddAuthentication y AddCookie.
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
Ésta sería la línea básica, pero yo recomendaría personalizar un poco las opciones de configuración, ya que podemos definir, entre otras cosas, el nombre de la cookie (para que no se la por defecto, y por lo tanto, fácil de localizar en un posible ataque), la página de login, de acceso denegado, de logout, el tiempo de vida de la cookie, entre otras. Aquí os dejo cómo la configuré en mi caso.
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
// authentication
services.Configure(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => {
// Cookie settings
options.Cookie.HttpOnly = true;
options.Cookie.Name = "Growerbox";
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.LoginPath = "/Account/Login";
options.LogoutPath = "/Account/Logout";
options.AccessDeniedPath = "/Account/AccessDenied";
options.SlidingExpiration = false;
options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
});
}
Por último para configurar dentro de startup.Configuration debemos de llamar al método UseAuthentication para invocar al Authentication Middlewere y establecer la propiedad HttpContext.User que es la encargada de acceder a la cookie de autenticación y obtener los datos del usuario que hayamos almacenado ahí.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
});
}
Es importante destacar que app.UseAuthentication(); siempre debe de llamarse antes de app.UseMvc();
Preparando el modelo con los datos del usuario
En el projecto del modelo agregamos una clase llamada ApplicationUser, y definimos en ella todas las propiedades que deseemos del usuario., éste es un ejemplo de la case.
public class ApplicationUser
{
#region properties
public int Id { get; set; }
public string Company { get; set; }
public bool Inactive { get; set; }
public bool SendSMS { get; set; }
public string PaymentMethod { get; set; }
public bool Blocked { get; set; }
public bool VIP { get; set; }
public string FirstName { get; set; }
public string MiddleName { get; set; }
public string LastName { get; set; }
public string ContactName { get { return this.FirstName + " " + this.MiddleName + " " + this.LastName; } private set { } }
public string Email { get; set; }
public string Password { get; set; }
public DateTime RegisterIn { get; set; }
public string Picture { get; set; }
#endregion
#region Constructor
public ApplicationUser(HttpContext context)
{
}
public ApplicationUser(string userid, string passwrod)
{
this.LastError = string.Empty;
this.Picture = string.Empty;
//evitar controlar los valores nulos
if (string.IsNullOrEmpty(userid)) userid = "kkk";
if (string.IsNullOrEmpty(passwrod)) passwrod = "kkk";
//evitar ataques SQL Inyection en el inicio de sesion
if (userid.IndexOf("'") >= 0) userid = "kkk";
if (passwrod.IndexOf("'") >= 0) passwrod = "kkk";
string spSQL = "authenticate_user " + userid + ", '" + passwrod + "'";
DDBB cnn = new DDBB();
DataTable dt = cnn.spConDataTable(spSQL);
cnn.Dispose();
cnn = null;
//inicializar las propiedas del objeto con los datos del usuario
[...]
}
#endregion
}
Como puedes comprobar es dentro de esta clase dónde yo hago la comprobación del usuario, pero si quieres puede hacerlo dentro de la clase Identity que veremos en el siguiente punto.
Crear la autenticación por cookie
Ahora en el projecto Model creamos una clase llamada Identity, que va a ser la encargada de crear el objero ApplicationUser y si es válido, entonces invocar el proceso de inicio de sesión. Es importante aclarar, que como hemos separado de lo que es la web esta clase, necesitamos enviarle como parámetro el HttpContext desde nuestra aplicación web, para así poder manejar el objeto y que se realicen las acciones necesarias. Os la copio la voy a ir poniendo por partes para así is explicando cada parte de la misma.
public class Identity
{
#region properties
public Exception LastError { get; private set; }
#endregion
#region constructor
private HttpContext Context;
public Identity(HttpContext context)
{
this.Context = context;
}
#endregion
He definido una propiedad llamda LastError para capturar o generar una execión cuando se genere y así retornarla de forma un poco controlada o personalizada. Luego tenemos el contrstructor, que como puede ver, se le pasa como parámetro el HttpContext de la página y lo almacenamos en una varible privada para poderlo usar en todo el objeto cuando sea creado.
#region helpers
///
/// Set the claims for the user
///
///
///
private ClaimsIdentity SetUserData(ApplicationUser user)
{
List claims = new List();
//use reflexion to get dynamic the properties about the user object
PropertyInfo[] properties = typeof(ApplicationUser).GetProperties();
foreach (PropertyInfo property in properties)
{
//get property name
string myType = property.Name;
//get value of the property
string myValue = property.GetValue(user).ToString();
claims.Add(new Claim(myType, myValue));
}
ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return claimsIdentity;
}
#endregion
En esta región de helpers sólo tenemos uno privado que es el encargado de leer las propiedades de nuestro objeto ApplicationUser y convertirlas en un objeto ClaimsPrincipal Se me ha olvidado comentar. En .NET CORE para almacenar datos de identidad se utilizan los objetos Claim, ClaimPrincipal y ClaimsIdentity. Éste último es el que deberemos enviar para crear la cookie, es por eso que precisa de CookieAuthenricationDefaults.AuthenticationScheme para conocer cómo deben de ser tratados los datos.
#region login actions
public async Task SignInAsync(string username, string password)
{
bool result;
try
{
ApplicationUser user = new ApplicationUser(username, password, this.Context);
if (user.Id > 0)
{
await this.Context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(SetUserData(user)),
new AuthenticationProperties
{
IsPersistent = false,
ExpiresUtc = DateTime.UtcNow.AddMinutes(60)
});
result = true;
}
else
{
if (user.Blocked) this.LastError = new Exception("This user login are blocked. Please contact us. " + user.LastError);
else this.LastError = new Exception("User name or password is not valid. " + user.LastError);
result = false;
}
}
catch (Exception ex)
{
this.LastError = ex;
result = false;
}
return result;
}
public async Task SignOutAsync()
{
await this.Context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
#endregion
Las acciones están claras, SignInAsync realiza el inicio de sesión. En mi caso controlo que el usuario existe dentro del objeto ApplicationUser, ya que luego necesito de éste para almacenar todas las propiedas, de ésta manera me he ahorrado 2 llamadas a la base de datos, una para identificar al usuario, u otra para obtener los datos del mismo. Fíjate que si el usuario es válido, entonces es cuando realizadmo la llamada asíncrona al objeto HttpContext que es el que tiene el método SignInAsync para registrar que el usuario se ha identificado correctametne, y en éste método es dónde pasamos los Claims creados en el helper anterior y devueltos como ClaimsIdentity necesarios para que se cree el objeto ClaimPrincipal.
El otro métido no necesita explicación. SignOutAsync sencillamente saca al usuario y elimina la cookie. Ésta acción, de hecho, puede quedarse dentro del controlador que tengais en la página web, pero ya que me ponía a hacer una clase para todo lo relaionado con la identificación, pues lo dejé aqui. Así si tengo más de un botón, u opción en la web para hacer el logout del usuario sólo tengo que llamar a mi objeto y listo.
#region async calls
///
/// Get the claims about the active user and return the user model
///
///
public async Task GetUserAsync()
{
AuthenticateResult x = await this.Context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
ApplicationUser user = new ApplicationUser(this.Context);
if (x.Succeeded)
{
//use reflexion to fill the object
Type t = Assembly.GetExecutingAssembly().GetType("Growerbox.Models.ApplicationUser");
foreach (Claim item in x.Principal.Claims)
{
PropertyInfo property = t.GetProperty(item.Type);
//convert to the correct property type
Type tipo = property.PropertyType;
property.SetValue(user, Convert.ChangeType(item.Value, tipo));
}
}
return user;
}
#endregion
}
Por último, pero no por eso menos importante, todo lo contrario, la clase que nos va a devolver los datos del usuario. Como podréis ver, tengo en este método GetUserAsync() como en SetUserData() he utilizado Reflexion, de ésta manera si cambio algo en el objeto ApplicationUser no tengo que cambiar nada en el código. Sólo hay un pequeño problema, y es que no se puede tener una propiedad que sea una clase, o una propiedad que sea un enumerable. Lo del enumerable lo podemos salvar comprobando si es un enumerable y encontes convertir el valor desde el enumerable. Pero como no seía el objeto de almacenar en la cookie demasiados datos o, no muy complicados, os dejo a vosotros buscar como hacerlo, yo lo tengo si lo necesitais hacer un comentario o escribirme, gustosamente os diré como. Yo utilicé 2 enumerados en mi clase.
Yo dentro de Identity tengo varios métidos que me devuelven directamente un valos en concreto, como puede ser la imagen del usuario, o el nombre completo, aqui os dejo una muestra, básicamente es para no tener que cargar todo el usuario y seleccionar sólo la propiedad que necesito.
#region User Data
///
/// Get the user name
///
///
public string GetUserName()
{
return this.GetUserAsync().Result.ContactName;
}
///
/// Get the register date
///
///
public DateTime GetRegisterDate()
{
return this.GetUserAsync().Result.RegisterIn;
}
///
/// Get the user id
///
///
public int GetId()
{
return this.GetUserAsync().Result.Id;
}
#endregion
Como usar el objero Identity
Y para finalizar este post, os dejo un ejemplo del controlador Account que va a manejar las acciones de login y logout, y como usar el objeto para recuperar los datos del usuario y mostrarlos en una vista como modelo de la misma.
[AllowAnonymous]
public class AccountController : Controller
{
[HttpGet]
public IActionResult Login()
{
return View();
}
[HttpPost]
public async Task Login(Login user)
{
if (ModelState.IsValid)
{
Identity identity = new Identity(this.HttpContext);
bool result = await identity.SignInAsync(user.UserName, user.Password);
if (result)
{
return RedirectToAction("Index", "Home", new { area = "admin" });
}
else
{
ModelState.AddModelError(string.Empty, "Invalid Login Attempt");
return View();
}
}
else
{
ModelState.AddModelError("", "User and password is required");
return View();
}
}
public async Task Logout()
{
Identity identity = new Identity(this.HttpContext);
await identity.SignOutAsync();
return RedirectToAction(nameof(Login));
}
}
[Authorize]
public class HomeController : Controller
{
public async Task Index()
{
Identity identity = new Identity(this.HttpContext);
ApplicationUser x = await identity.GetUserAsync();
return View(x);
}
}
Y eso es todo amigos, espero que os haya gustado este post y que sirva de ayuda para muchos. No se olviden de mirar todos los enlaces con la documentación relativa a este post, normalmente en inglés, por ello de este post paso a paso.
Happy Coding