DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on • Updated on

Autenticación y Autorización con JWT en ASP.NET Core (Parte 5)

Introducción

La autenticación mediante Bearer Tokens está en pleno auge, y a pesar de que ya he abordado este tema previamente en mi blog (ASP.NET Core 6: Autenticación JWT y Identity Core), he decidido retomarlo.

La razón detrás de volver a hablar sobre JSON Web Tokens (JWTs) es mantener la coherencia en nuestra serie de publicaciones acerca de ASP.NET Core y CQRS con MediatR. En publicaciones futuras, necesitaremos contar con un sistema de autenticación y autorización sólido.

El objetivo final es construir una solución completa desde cero, desglosando cada concepto a lo largo de la serie.

Si deseas acceder al código fuente de este artículo, lo encontrarás en la rama correspondiente de mi repositorio en GitHub: branch.

Autenticación con JWT Bearer

Cuando hablamos de JSON Web Tokens (JWT), a menudo se menciona el tema de OpenID Connect, y se vuelve aún más complejo cuando se implementa un Identity Server. Sin embargo, es importante entender que JWT es un mecanismo que se utiliza en el contexto de OpenID Connect, pero podemos aprovecharlo de manera independiente en la autenticación de nuestras aplicaciones.

Nota 👀: Si deseas conocer en detalle qué son los JWT, te invito a revisar mi artículo anterior: ASP.NET Core 6: Autenticación JWT y Identity Core.

Instalación de ASP.NET Identity Core y JWT Bearer

Para comenzar con la implementación de la autenticación, primero debemos agregar tres paquetes NuGet a nuestro proyecto:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Enter fullscreen mode Exit fullscreen mode

ASP.NET Identity Core es un sistema de "Membership" que nos brinda una solución integral para la gestión de usuarios, autenticación y autorización. Es muy útil y altamente recomendado utilizarlo, ya que nos evita tener que reinventar la rueda.

Actualizando el DbContext

Identity Core se basa principalmente en Entity Framework. En nuestro proyecto, ya disponemos de un DbContext, el cual seguiremos utilizando. No obstante, es necesario actualizarlo para que ahora sea compatible con las entidades que ofrece Identity.

using MediatrValidationExample.Domain;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace MediatrValidationExample.Infrastructure.Persistence;
public class MyAppDbContext : IdentityDbContext<IdentityUser> // <-----
{
    public MyAppDbContext(DbContextOptions<MyAppDbContext> options) : base(options)
    { }

    public DbSet<Product> Products => Set<Product>();
}
Enter fullscreen mode Exit fullscreen mode

Lo más importante a destacar aquí es que ahora heredamos de IdentityDbContext<TUser> en lugar de DbContext. El tipo genérico TUser representa al usuario, y la clase IdentityUser es la implementación predeterminada que ofrece Identity. En otro artículo que mencioné previamente, ampliamos la clase IdentityUser para añadir las propiedades necesarias. Sin embargo, en este caso, por simplicidad, la mantendremos con la implementación predeterminada.

Actualizando la Base de Datos

Estamos utilizando una base de datos SQLite, pero este proceso es igualmente aplicable a SQL Server u otros sistemas compatibles con EF Core. Para actualizar la base de datos, necesitamos agregar una migración correspondiente y luego aplicarla:

dotnet ef migrations add AddedIdentityCore -o Infrastructure/Persistence/Migrations
dotnet ef database update
Enter fullscreen mode Exit fullscreen mode

Generando JWTs

Antes de poder autorizar a los usuarios, debemos autenticarlos. Para lograrlo, crearemos un nuevo Command que realizará esta tarea.

Nota 👀: Recuerda que el propósito de esta serie de tutoriales es seguir utilizando el enfoque CQRS.
Nota 2: El enfoque tradicional hubiera sido colocar este archivo en la ruta Features/Auth/Command/TokenCommand.cs, pero por diversión, omitimos la palabra "Command" 🤣.

Autenticación: Features -> Auth -> TokenCommand

Hemos creado este comando para autenticar a los usuarios mediante sus nombres de usuario y contraseñas.

using MediatR;
using MediatrValidationExample.Exceptions;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace MediatrValidationExample.Features.Auth;
public class TokenCommand : IRequest<TokenCommandResponse>
{
    public string UserName { get; set; } = default!;
    public string Password { get set; } = default!;
}

public class TokenCommandHandler : IRequestHandler<TokenCommand, TokenCommandResponse>
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly IConfiguration _config;

    public TokenCommandHandler(UserManager<IdentityUser> userManager, IConfiguration config)
    {
        _userManager = userManager;
        _config = config;
    }

    public async Task<TokenCommandResponse> Handle(TokenCommand request, CancellationToken cancellationToken)
    {
        // Verificamos las credenciales con Identity
        var user = await _userManager.FindByNameAsync(request.UserName);

        if (user is null || !await _userManager.CheckPasswordAsync(user, request.Password))
        {
            throw new ForbiddenAccessException();
        }

        var roles = await _userManager.GetRolesAsync(user);

        // Generamos un token en función de los claims
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Sid, user.Id),
            new Claim(ClaimTypes.Name, user.UserName)
        };

        foreach role in roles
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
        var tokenDescriptor = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.Now.AddMinutes(720),
            signingCredentials: credentials);

        var jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);

        return new TokenCommandResponse
        {
            AccessToken = jwt
        };
    }
}

public class TokenCommandResponse
{
    public string AccessToken { get; set; } = default!;
}
Enter fullscreen mode Exit fullscreen mode

Siguiendo mi artículo anterior:

  • Verificación de credenciales: Utilizamos Identity de ASP.NET para gestionar a los usuarios (aunque tiene más funcionalidades, en este caso solo estamos utilizando esta parte) y sus roles. UserManager proporciona una amplia gama de métodos para la gestión de usuarios, sus contraseñas y sus roles.
  • Generación del JWT: A partir de los claims generados según el usuario autenticado, creamos el JWT. Este código es prácticamente un "boilerplate" y siempre será el mismo. Lo importante es observar que utilizamos la configuración del archivo appsettings, la misma que se empleará para verificar el JWT al realizar solicitudes.

Nota 👀: ForbiddenAccessException es una excepción personalizada que creamos en publicaciones anteriores, pero la estamos utilizando aquí.

La configuración necesaria que debes agregar es la siguiente:

  "Jwt": {
    "Issuer": "WebApiJwt.com",
    "Audience": "localhost",
    "Key": "S3cr3t_K3y!.123_S3cr3t_K3y!.123"
  }
Enter fullscreen mode Exit fullscreen mode

El Issuer y el Audience no son relevantes en este contexto; son más importantes cuando se utiliza OpenID Connect, pero por ahora son requisitos.

La Key, en cambio, es crucial, ya que se trata de nuestro secret para la encriptación simétrica.

Sistemas como Identity Server o OpenIddict utilizan la encriptación asimétrica, utilizando RSA y certificados, lo cual es otro tema interesante que podríamos abordar en otro momento.

AuthController

Para exponer nuestro comando y permitir su uso, utilizaremos este controlador de API:

using MediatR;
using MediatrValidationExample.Features.Auth;
using Microsoft.AspNetCore.Mvc;

namespace MediatrValidationExample.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public Task<TokenCommandResponse> Token([FromBody] TokenCommand command) =>
        _mediator.Send(command);
}
Enter fullscreen mode Exit fullscreen mode

De manera general, estamos invocando nuestro comando de la misma manera que lo hemos hecho en artículos anteriores.

Configuración Final

Ahora que hemos escrito el código para generar JWTs, es hora de completar la configuración de las dependencias y decirle a la Web API que utilice un esquema de autenticación (en este caso, Bearer Tokens).

// código omitido...

[Authorize] // <---
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{

// ...código omitido
Enter fullscreen mode Exit fullscreen mode

Utilizamos el atributo [Authorize] en el controlador para requerir un esquema de autenticación. ASP.NET Core admite uno o varios esquemas de autenticación diferentes. En este caso, estamos utilizando Bearer Tokens, pero es posible combinarlos con autenticación mediante cookies u otras formas según sea necesario. Aunque es común utilizar solo un esquema, en ocasiones puede ser necesario utilizar dos o más, aunque en la mayoría de los casos uno es suficiente.

La configuración se divide en dos partes:

// Identity Core
builder.Services
    .AddIdentityCore<IdentityUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<MyAppDbContext>();
Enter fullscreen mode Exit fullscreen mode

En esta sección configuramos todas las dependencias de Identity, como la implementación de TUser a utilizar, la de TRoles y el contexto que se empleará.

Nota 👀: Como mencioné anteriormente, Identity Core es un framework de autenticación y autorización completo que ofrece funcionalidades como Claims, Roles, Policies, etc.

// Autenticación y autorización
builder.Services
    .AddHttpContextAccessor()
    .AddAuthorization()
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });
Enter fullscreen mode Exit fullscreen mode

En esta sección, configuramos la autenticación y autorización mediante Bearer Tokens.

Nota 👀: Si deseas obtener más información sobre este tema, te invito a revisar mis artículos anteriores sobre JWT y OpenID.

Actualización de Swagger

La plantilla de Web API por defecto incluye una configuración básica de Swagger. Para probar la autenticación con Bearer Tokens, debemos indicar a Swagger que necesitamos incluir un JWT en el encabezado Authorization.

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "My API",
        Version = "v1"
    });
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please insert JWT with Bearer into field",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement {
   {
     new OpenApiSecurityScheme
     {
       Reference = new OpenApiReference
       {
         Type = ReferenceType.SecurityScheme,
         Id = "Bearer"
       }
      },
      new string[] { }
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Esta configuración es un ejemplo básico de Swagger, pero Swashbuckle ofrece muchas más opciones de configuración. Puedes explorarlas según tus necesidades.

Seed de Usuarios

Anteriormente, ya teníamos un método de Seed para datos de prueba. Ahora, lo actualizamos de la siguiente manera:

async Task SeedProducts()
{
    using var scope = app.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<MyAppDbContext>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();

    // código omitido...

    var testUser = await userManager.FindByNameAsync("test_user");
    if (testUser is null)
    {
        testUser = new IdentityUser
        {
            UserName = "test_user"
        };

        await userManager.CreateAsync(testUser, "Passw0rd.1234");
        await userManager.CreateAsync(new IdentityUser
          {
              UserName = "other_user"
          }, "Passw0rd.1234");
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí estamos creando dos usuarios, que serán útiles para las pruebas de autorización que realizaremos más adelante. Ahora estamos listos para probar casi todo 👍🏽.

Probando la Autenticación

Ejecutamos la aplicación y se abrirá Swagger:
Imagen de Swagger
El icono del candado Authorize es una configuración adicional que agregamos en el Program, lo que permite a Swagger incluir un JWT en las solicitudes.

Desde aquí, solo te queda realizar pruebas. Intenta consultar productos o crearlos, y verás que no podrás hacerlo a menos que estés autenticado con tu usuario y contraseña.

Utiliza el endpoint /api/auth/ para generar JWTs basados en las credenciales que establecimos en el método Seed. Utiliza el botón Authorize para agregar el JWT en el encabezado Authorization:

Imagen de Autorización en Swagger

Agregando Autorización

La autorización comienza siendo sencilla, pero puede volverse más compleja. Por lo general, utilizo la autorización basada en roles, especialmente cuando ya estoy usando Identity.

Realmente, ya tenemos todo configurado, solo necesitas agregar los roles en la base de datos y asignarlos a un usuario para probar.

Actualizamos nuestro método de Seed y agregamos lo siguiente al final:

// Código omitido
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var adminRole = await roleManager.FindByNameAsync("Admin");
if (adminRole is null)
{
    await roleManager.CreateAsync(new IdentityRole
    {
        Name = "Admin"
    });
    await userManager.AddToRoleAsync(testUser, "Admin");
}
Enter fullscreen mode Exit fullscreen mode

Aquí creamos un rol llamado Admin y lo asignamos a nuestro usuario de prueba (test_user), que previamente habíamos consultado en este método.

Los roles en Identity deben registrarse en la base de datos, ya que suelen ser fijos. Es común tenerlos en un método de Seed como este.

La clase IdentityRole es la implementación predeterminada de un rol, pero suelo extenderla mediante herencia para agregar más propiedades, como descripción y categoría del rol (aunque esto depende de los requisitos).

La autorización basada en roles tiene como objetivo permitir que solo los usuarios con el rol Admin puedan crear productos. Por lo tanto, actualizamos el método de creación de la siguiente manera:

  /// <summary>
  /// Crea un producto nuevo
  /// </summary>
  /// <param name="command"></param>
  /// <returns></returns>
  [HttpPost]
  [Authorize(Roles = "Admin")] // <----- Autorización por rol
  public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command)
  {
      await _mediator.Send(command);

      return Ok();
  }
Enter fullscreen mode Exit fullscreen mode

Nuevamente, utilizamos el atributo [Authorize], pero esta vez especificamos que este método requiere un rol en particular.

Si ejecutas la solución nuevamente, notarás que se ha creado un rol llamado Admin en la tabla AspNetRoles, y se ha establecido una relación en la tabla AspNetUserRoles:
Imagen de la tabla AspNetRoles
Imagen de la tabla AspNetUserRoles
Al crear el JWT, revisamos esta relación. Es decir, al autenticar a un usuario, consultamos sus roles para agregarlos al JWT de manera que ASP.NET lo interprete como roles válidos.

Cuando se intenta autorizar a un usuario, ASP.NET verifica los Claims del JWT para determinar si tiene acceso o no.

Puedes realizar pruebas con los dos usuarios que hemos creado: test_user tiene el rol Admin, mientras que other_user no lo tiene. Explora con ambos usuarios para ver cómo se comporta la autorización y si funciona correctamente.

Con esta configuración de autenticación, puedes utilizar expresiones como User.IsInRole("Admin") para verificar si el usuario actual tiene un rol específico. Esto es muy útil en la mayoría de los escenarios de autorización.

Acceso al Usuario Actual

Acceder al usuario actual es una parte importante de la autorización y autenticación en una aplicación. Para lograrlo, necesitamos una forma de acceder al contexto actual del usuario. Sin embargo, es esencial que esta funcionalidad esté desacoplada del contexto web, ya que podríamos necesitar acceder a la funcionalidad de Application Core en diferentes contextos, como aplicaciones de consola.

Para lograr esto, creamos una abstracción llamada ICurrentUserService que nos permitirá acceder al usuario actual. Su definición es la siguiente:

namespace MediatrValidationExample.Services;
public interface ICurrentUserService
{
    CurrentUser User { get; }

    bool IsInRole(string roleName);
}

public record CurrentUser(string Id, string UserName);
Enter fullscreen mode Exit fullscreen mode

Aquí estamos definiendo el contrato para acceder al usuario actual. En este momento, solo necesitamos el ID y el nombre de usuario del usuario actual. También estamos abstrayendo la forma en que se determina si un usuario tiene un rol o no. Esto puede ser útil para realizar pruebas unitarias.

La implementación de ICurrentUserService se basa en el HttpContext y se ve así:

using System.Security.Claims;

namespace MediatrValidationExample.Services;
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;

        var id = _httpContextAccessor.HttpContext.User.Claims
            .FirstOrDefault(q => q.Type == ClaimTypes.Sid)
            .Value;

        var userName = _httpContextAccessor.HttpContext.User.Identity.Name;

        User = new CurrentUser(id, userName);
    }

    public CurrentUser User { get; }

    public bool IsInRole(string roleName) =>
        _httpContextAccessor.HttpContext!.User.IsInRole(roleName);
}
Enter fullscreen mode Exit fullscreen mode

Esta implementación se encuentra fuertemente acoplada al HttpContext y está diseñada para entornos de aplicaciones web. HttpContext.User se inicializa automáticamente en ASP.NET cuando usamos Bearer Tokens, ya que hemos indicado que se espera un JWT en el encabezado Authorization. La función HttpContext.User.IsInRole se utiliza para verificar si el usuario tiene un rol, lo que es posible porque hemos incluido los roles en el JWT como Claims.

Nota 👀: En futuras publicaciones, es posible que dividamos esta aplicación en diferentes proyectos siguiendo un enfoque de arquitectura de segmentación vertical (Vertical Slice Architecture).

Actualizando AuthController

Actualizamos el controlador de autorización para incluir el uso de ICurrentUserService:

    [Authorize]
    [HttpGet("me")]
    public IActionResult Me([FromServices] ICurrentUserService currentUser)
    {
        return Ok(new
        {
            currentUser.User,
            IsAdmin = currentUser.IsInRole("Admin")
        });
    }
Enter fullscreen mode Exit fullscreen mode

ICurrentUserService solo es utilizable si el usuario actual está autenticado; probablemente obtendremos errores si se intenta acceder a /me si no hay un JWT 🤭.

Nota 👀: Antes de ejecutar el proyecto, debemos registrar el servicio como una dependencia con builder.Services.AddScoped<ICurrentUserService, CurrentUserService>().

La respuesta que obtendrás al llamarlo con Swagger será la siguiente:

{
  "user": {
    "id": "308e554d-4251-47f9-9617-726dff6562ef",
    "userName": "other_user"
  },
  "isAdmin": false
}
Enter fullscreen mode Exit fullscreen mode

Si pruebas con el usuario Admin, obtendrás:

{
  "user": {
    "id": "f28cf715-2171-4c0e-9ba5-f2bbbb958f63",
    "userName": "test_user"
  },
  "isAdmin": true
}
Enter fullscreen mode Exit fullscreen mode

Puedes ver que la función IsInRole funciona sin problemas.

Con esto, hemos definido una abstracción que nos permite acceder al usuario actual que realiza la solicitud. No importa si en el futuro decidimos cambiar de una aplicación web a una de consola; la funcionalidad de Application Core debería seguir funcionando sin problemas.

Nota: Esta parte se incluye solo para explicar cómo se podría utilizar ICurrentUserService. La propiedad IsAdmin también es un ejemplo.

Conclusión

Hemos agregado autenticación y autorización a nuestra aplicación, la cual hemos construido en estos 5 artículos (hasta ahora). Utilizando ASP.NET Identity Core, hemos simplificado en gran medida la seguridad de usuarios, ya que no es necesario modificar ningún algoritmo de cifrado ni de hash para guardar las contraseñas de manera segura. Identity Core también ofrece funcionalidades adicionales, como la generación de códigos para restablecer contraseñas o confirmar correos electrónicos, pero dejaremos eso para futuras publicaciones.

Espero que esta información te sea útil. Si tienes alguna pregunta, no dudes en contactarme en Twitter, y con gusto te ayudaré con cualquier consulta.

Top comments (5)

Collapse
 
jtempra profile image
Josep Temprà

Muy bueno, grácias!!!

Collapse
 
jecacarvajal profile image
JecaCarvajal

Excelente explicación, muchas gracias

Collapse
 
jtempra profile image
Josep Temprà

Hola Isaac,
He implementado el servicio CurrentAccessUser y me devuelve:
"user": {
"id": "5ab26c6d-2d56-4e8d-87ce-dd455128670c",
"userName": "Desconegut",
"isAuthenticated": true
},
"isAdmin": true
Todo es correcto menos els userName que es nulo y por eso sale desconocido. Pero en la table de usuarios de Identity el UserName esta lleno...
Sabes que puede pasar?
Gracias

Collapse
 
isaacojeda profile image
Isaac Ojeda

Hola!

pasa un JWT ejemplo para ver el contenido, también puedes debuggear el CurrentUserService para ver si algo anda mal por ahí

Collapse
 
jtempra profile image
Josep Temprà

Hola Isaac,
Ya lo solucioné! Muchas gracias por la paciencia.
Josep