Portada » Preguntas Frecuentes de .NET 6 » Cómo añadir autenticación y almacenarla en Cookies con .NET 6 MVC

Cómo añadir autenticación y almacenarla en Cookies con .NET 6 MVC

Casi todas las plataformas tienen usuarios que pueden registrarse e iniciar sesión para hacer uso de las funcionalidades que el sistema ofrece. En este artículo voy a explicar cómo implementar la autenticación con .NET 6, así como almacenar el estado en una cookie para mantener la sesión iniciada durante un tiempo determinado.

Si solo necesitas copiar el código, aquí enlazo los archivos que debemos modificados en un proyecto ejemplo de Github:

  • Configuración del Program.cs: Configuración inicial para permitir el sistema de autenticación.
  • Modelo (ViewModel): Para el ModelBinding
  • Vista y Layout: Estilos, diseños e integración del ViewModel en un formulario para el inicio de sesión
  • Controlador: Para las rutas del inicio de sesióin y métodos GET y POST.

La explicación que daré asume un proyecto recién creado de tipo MVC sin ningún tipo de autenticación seleccionado durante la creación. Es por esto que es poisible que hayan algunas diferencias si estás integrándolo con un proyecto creado.

Pese a esto, debería poder servir de referencia, pues los componentes que mencionaré seguramente tengan nombres parecidos.

Configuración del Program.cs

Si entras en el archivo Program.cs de tu proyecto MVC, seguramente ya veas que existe un middleware en el pipeline de la construcción de la app llamado «app.UseAuthorization()».

var builder = WebApplication.CreateBuilder(args);

// Añadir servicios al contenedor
builder.Services.AddControllersWithViews();

var app = builder.Build();

// Configuración del pipeline de la petición HTTP 
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

Pese a que forma parte del sistema de verificación de la sesión, la función principal de la «Autorización» va a ser determinar los permisos que un usuario posee para una determinada característica / acción. Nosotros necesitamos implementar un sistema que verifique la identidad de un usuario, o dicho de otro modo, necesitamos la «Autenticación«.

Para ello, introducimos una nueva parada dentro del pipeline mediante una función que nativamente contiene nuestro IApplicationBuilder, que es «app.UseAuthentication()«.

/* ...Configuraciones previas del pipeline */
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

/* Configuraciones posteriores del pipeline... */

app.Run();

¡El orden es importante! La Autenticación siempre debe ir previo a la Autorización. Piénsalo de este modo, la petición tiene que validar la identidad del usuario para, dependiendo del rol que tenga el usuario, saber si tiene permiso para acceder a la funcionalidad creada. Si se hiciera al revés, nunca tendrías acceso a dichas funciones porque lo primero que vería en la petición es si está autorizado un usuario que no está identificado.

Por otro lado, una vez iniciada la sesión, queremos que el sistema «recuerde» los datos de acceso del usuario, porque sino cada vez que pase por el pipeline, va a ver que no está identificado y por tanto le va a mandar a iniciar sesión. Para ello vamos a añadir el servicio de Autenticación mediante Cookies.

var builder = WebApplication.CreateBuilder(args);

// Añadir servicios al contenedor
builder.Services.AddControllersWithViews();

// Añadimos las Cookies de autenticación
builder.Services.AddAuthentication("AuthCookie").AddCookie("AuthCookie", opts =>
{
    // Nombre para identificar la Cookie (recomendado)
    opts.Cookie.Name = "AuthCookie"; 
    // Tiempo de sesión en (horas, minutos, segundos) (recomendado)
    opts.Cookie.MaxAge = new TimeSpan(00, 30, 00); 
    // URL de redirección en caso de no tener sesión activa (recomendado)
    opts.LoginPath = "/Identity/Login"; 
    // URL de redirección al cerrar sesión (opcional)
    //opts.LogoutPath = "/Identity/Logout";

    // URL de redirección si no tiene permisos (opcional)
    //opts.AccessDeniedPath = "/403";
    // Política de envío de nuestra cookie (opcional)
    //opts.Cookie.SameSite = SameSiteMode.Strict; 
});

var app = builder.Build();

/* Configuraciones posteriores del pipeline... */

app.Run();

Asignarle el nombre «AuthCookie» es algo opcional y personal. Sencillamente lo hago porque la opción que te suelen sugerir en la documentación me parece menos legible, pero también es una opción.

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(opts =>
    {
        // La configuración de antes
    });

Respecto a la política de SameSite a elegir, depende del tipo de proyecto que estes realizando, aunque por defecto está establecida como SameSiteMode.Lax.

Si no conocéis la diferencia entre las dos políticas de Cookies, echadle un ojo a este artículo:

¿Qué debo elegir, Cookies con SameSite Strict o SameSite Lax?

Modelo para el inicio de sesión

El modelo va a actuar como DTO, pero como solo lo vamos a usar para la capa de UI y la convención del ModelBinding lo indica, lo llamaré ViewModel.

public class LoginViewModel
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; }
}

Vista para el inicio de sesión

La vista y el diseño es, evidentemente, opcional. Lo que muestro a continuación es el código que suelo usar como plantilla para una pantalla de inicio de sesión. Siéntete libre de utilizarlo o modificarlo a tu gusto.

Resultado del diseño de Login.cshtml y _LoginLayout.cshtml
Simple pero funcional, ¿no?

Este es el código para la vista Login.cshtml:

@model LoginViewModel

@{
    Layout = "_LoginLayout";
}

<div class="row vh-100 m-0">
    <div class="login-wrap p-5 col-lg-6 d-flex flex-column justify-content-center align-items-center">
        <div class="d-flex w-75">
            <div class="w-100">
                <h3 class="mb-4">Sign in</h3>
            </div>
        </div>
        @using(Html.BeginForm("Login", "Identity", ViewBag.ReturnUrl, FormMethod.Post, true, htmlAttributes: new { @class="w-75" }))
        {
            @Html.ValidationSummary(false, "", htmlAttributes: new{ @class="text-danger"});
            <div class="form-group mb-3">
                @Html.LabelFor(x => x.Email, new { @class="form-label" })
                @Html.TextBoxFor(x => x.Email, new { @class="form-control" })
            </div>
             <div class="form-group mb-3">
                @Html.LabelFor(x => x.Password, new { @class="form-label" })
                @Html.PasswordFor(x => x.Password, new { @class="form-control" })
            </div>
            <div class="form-group">
                <button id="login-submit" type="submit" class="form-control btn btn-primary px-3 my-3">Sign in</button>
            </div>
        }
    </div>
    <div class="text-wrap d-flex align-items-center col-lg-6 bg-primary bg-gradient p-0">
        <img src="~/login-img.jpg" class="w-100 vh-100" style="object-fit:cover; object-position:50% 50%"/>
    </div>
</div>

Y este para el _LoginLayout.cshtml

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"]</title>
    <link rel="stylesheet" href="~/lib/bootstrap-5.1.3/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/AuthCookiesWithMVCExample.styles.css" asp-append-version="true" />
</head>
<body class="m-0">
    <main role="main">
        @RenderBody()
    </main>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap-5.1.3/js/bootstrap.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>
⚠ ¡Importante! ⚠

Si copias y pegas el código y ves que los estilos no se aplican, es posible que tengas que cambiar la carpeta de Bootstrap que viene predeterminada por la que puedes descargar gratuitamente de la página oficial.

No olvides cambiar también tus rutas en los <head> y los <script> para que encagen con tu nueva carpeta. En proyecto de Github es la que encontrarás en wwwroot/lib/bootstrap-5-1-3.

Controlador para el inicio de sesión

En el GET validamos que el usuario no haya iniciado sesión ya, en cuyo caso le redirigiríamos a la pantalla principal.

[HttpGet]
[Route("/Login")]
public ActionResult Login()
{
    try
    {
        if (User.Identity == null)
            return View();

        if (!User.Identity.IsAuthenticated)
            return View();

        TempData["Error"] = "You have already logged in.";
    }
    catch (Exception ex)
    {
        TempData["Error"] = "There was an error trying to login.";
        _dbExceptionLogger.Add(ex);
        return View();
    }
            
    return RedirectToAction(actionName: "Index", controllerName: "Home");
}

Mientras que en el POST tenemos que validar los datos del usuario, contrastarlos con los que tenemos en la base de datos y luego añadir todas las configuraciones necesarias para establecer roles y perfiles.

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Login(LoginViewModel loginViewModel, string? returnUrl = null)
{
    if (!ModelState.IsValid) return View(loginViewModel);

    // Datos de ejemplo, aquí la idea sería obtener el usuario de la bbdd con el email
    LoginViewModel user = new LoginViewModel()
    {
        Email = "xyz@example.com",
        Password = "H3110w0r1d!",
    };

    if (loginViewModel == null)
    {
        ModelState.AddModelError("IncorrectData", "Credenciales incorrectas. Por favor inténtelo de nuevo.");
        return View(loginViewModel);
    }

    if (loginViewModel.Password != user.Password || loginViewModel.Email != user.Email)
    {
        ModelState.AddModelError("IncorrectData", "Credenciales incorrectas. Por favor inténtelo de nuevo.");
        return View(loginViewModel);
    }

    try
    {
        // Contexto de seguridad, roles, perfiles, permisos, etc.
        List<Claim> claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, "1"),
            new Claim(ClaimTypes.Name, "Victor Pérez Asuaje"),
            new Claim(ClaimTypes.Email, loginViewModel.Email),
        };

        ClaimsIdentity identity = new ClaimsIdentity(claims, "AuthCookie");

        // Contenedor para el contexto de seguridad
        ClaimsPrincipal principal = new ClaimsPrincipal(identity);

        // Encriptación y serialización de la AuthCookie
        await HttpContext.SignInAsync("AuthCookie", principal);
    }
    catch (Exception ex)
    {
        ModelState.AddModelError("AuthError", "Ha ocurrido un error durante el inicio de sesión.");
        return View();
    }

    return RedirectToAction(actionName: "Index", controllerName: "Home");
}

¡Y esto sería todo! Realmente no son tantos pasos para tener un mínimo sistema de identificación con .NET 6. A partir de este punto es evidente que se puede expandir y profundizar mucho más en los diferentes niveles de seguridad, cómo se hacen las conexiones a la base de datos para buscar al usuario, cómo encriptar y desencriptar los datos del usuario para validar el login, etc. Pero todas esas configuraciones dependerán de cada proyecto.