DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

[Parte 5] ASP.NET: Identity Core y JWT

Introducción

Autenticación con Bearer Tokens es el tema del momento y aunque ya he hablado anteriormente de eso aquí en mi blog(ASP.NET Core 6: Autenticación JWT y Identity Core) he decidido a volver a tocar el tema.

La intención de volver a hablar sobre JWTs es darle continuidad a la serie de posts que estamos haciendo sobre ASP.NET Core y CQRS con MediatR, ya que en temas posteriores necesitaremos tener autenticación y autorización.

El camino final será terminar con una solución completa construida totalmente por nosotros, concepto por concepto.

El código fuente de este post lo encuentras en este branch de mi github.

Autenticación con JWT Bearer

Cuando hablamos de JWT generalmente también viene el tema OpenID Connect y este se vuelve más complicado cuando se crea un Identity Server. Pero como ya lo hemos de saber, JWT es un mecanismo que se usa en OpenID Connect y podemos usarlo independientemente de cómo hacemos la autenticación en nuestra aplicación.

Nota 👀: Si quieres saber a profundidad que son los JWTs, visita mi post anterior -> ASP.NET Core 6: Autenticación JWT y Identity Core

Instalando ASP.NET Identity Core y JWT Bearer

Para comenzar con la codificación de la autenticación, primero necesitamos tres paquetes NuGet:

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

Identity Core es este sistema de "Membership" que nos ayuda administrar usuarios, autenticación y autorización. Es bien útil y 100% recomendado usarlo para no reinventar la rueda.

Actualizando el DbContext

Identity Core funciona principalmente por medio de Entity Framework. En el proyecto ya contamos con un DbContext y seguiremos usando el mismo, pero hay que actualizarlo para que ahora conozca los Entities que Identity ofrece.

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 relevante aquí, es que ahora no heredamos de DbContext pero sí de IdentityDbContext<TUser>.

El tipo genérico TUser representa el usuario y la clase IdentityUser es la implementación default que Identity ofrece. En el otro post que menciono, extendemos el IdentityUser para agregar las propiedades que se necesiten, pero en este caso por simplicidad lo dejaremos con la implementación default.

Actualizando DB

Estamos usando una base de datos SQLite, pero sin ningún problema puede ser SQL Server o cualquiera soportado por EF Core.

Para actualizar la DB tenemos que agregar su migración correspondiente y así actualizamos:

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

Generando JWTs

Para poder autorizar usuarios, primero hay que autenticarlos. Para eso crearemos un nuevo Command que haga la tarea:

Nota 👀: Recuerden que la intención de esta serie de tutoriales es seguir usando CQRS
Nota 2: El path tradicional hubiera sido Features/Auth/Command/TokenCommand.cs pero me comí el Command 🤣

Autenticación: Features -> Auth -> TokenCommand

Crearemos este comando para autenticar usuarios con usuario y contraseña.

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 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 según los claims
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Sid, user.Id),
            new Claim(ClaimTypes.Name, user.UserName)
        };

        foreach (var 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

Citando mi post anterior:

  • Verificación de credenciales: Utilizamos Identity de ASP.NET para guardar usuarios (tiene más funcionalidad, pero por ahora solo usaremos esta parte) y roles. UserManager cuenta ya con muchos métodos para manejar usuarios, sus contraseñas y sus roles.
  • Generación del JWT: Según el listado de claims que se generaron según el usuario autenticado, generamos el JWT. Esto es un boilerplate, siempre será el mismo código. Lo importante es ver que estamos utilizando la configuración del appsettings, los mismos que se utilizarán para verificar el JWT al hacer solicitudes.

Nota 👀: ForbiddAccessException es una excepción custom que hicimos desde los primeros posts, pero apenas la estamos usando.

La configuración agregada que se necesita:

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

Issuer y Audience realmente no tienen relevancia aquí, cuando utilizamos OpenID Connect en forma es muy importante, pero aquí por ahora solo es un requisito.

Key sí es importante, es nuestro secret para encriptar de forma simétrica.

Soluciones como Identity Server o OpenIddict utilizan encriptación asimétrica utilizando RSA y certificados, otro tema muy bueno que puedo tomar después.

AuthController

Para exponer nuestro comando y permitir su uso, utilizaremos este Api Controller:

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 forma general, estamos invocando nuestro comando tal como lo hemos hecho en posts anteriores.

Configuración final

Ya podemos generar JWTs con el código que hemos escrito, pero tenemos que terminar de configurar las dependencias y decirle a 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

Usamos el atributo [Authorize] para que el controlador pida un esquema de autenticación. ASP.NET Core admite uno o más esquemas de autenticación distintos. Es decir, podemos combinar JWTs con Cookie authentication o cualquier otra forma que queramos. Es común tener solo uno, pero sin problema se podrían tener dos o más (aunque no sabría para qué, pero se puede).

La configuración se divide en dos:

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

Aquí configuramos todas las dependencias de Identity, tanto que implementación de TUser usar y de TRoles, también el contexto a utilizar.

Nota 👀: Mencioné anteriormente que Identity Core es un framework de autenticación y de autorización (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

Aquí configuramos la autenticación y autorización con Bearer Tokens.

Nota 👀: Posts que te pueden interesar sobre este tema: JWT y OpenID

Actualizando Swagger

La plantilla de Web API por default agrega una configuración básica de Swagger. Para poder probar la autenticación con Bearer Tokens, debemos de decirle a Swagger que debemos de ingresar un JWT en el header 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

Esto es una receta, Swashbuckle tiene mucha más configuración, pero pues ese es un tema que te dejo de tarea.

Seed Users

Anteriormente ya contabamos con un método Seed para datos de prueba, este mismo método lo actualizamos de la siguiente forma:

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

Estamos creando dos usuarios, para pruebas de autorización que haremos más adelante. Mientras tanto, ya estamos listos para probar casi todo 👍🏽.

Probando la Autenticación

Corremos la aplicación y se nos abrirá Swagger:
Image description
El candado Authorize es la configuración adicional que indicamos en el Program, así swagger nos deja anexar JWTs.

Aquí solo resta que hagas pruebas, intenta consultar productos o crearlos, y no podrás por que necesitas estar autenticado con tu usuario y contraseña.

Utiliza el endpoint /api/auth/ para generar JWTs según las credenciales que pusimos en el método Seed. Utiliza el botón Authorize para agregar el JWT al header Authorization:

Image description
## Agregando Autorización

La autorización empieza por ser sencilla, pero puede complicarse. Siempre suelo hacer autorización basada en roles, más si ya estoy usando Identity.

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

Actualizamos nuestro método 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í estamos creando un rol llamado Admin y se lo asignamos a nuestro usuario de prueba (test_user) que previamente se consultó en este método.

Los roles en Identity se tienen que registrar en la base de datos, como estos suelen ser fijos, es normal tenerlos en un método Seed como este.

La clase IdentityRole es la implementación default de un Rol, pero también yo suelo extenderlos usando herencia para agregar más propiedades, como descripción y categoría del rol (pero bueno, aquí es según el requerimiento).

La intención de usar la autorización basada en roles, es permitir que solo usuarios con el rol Admin sean los que pueden crear productos. Por lo tanto, actualizamos el método create:

  /// <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

Volvemos usar el atributo [Authorize] pero ahora indicando de que este método necesita de un rol en particular.

Si corremos nuevamente la solución veremos un par de cosas importantes. En el método Seed se acaba de crear un AspNetRole llamado Admin y también se creó una relación en AspNetUserRoles:
Image description
Image description
Al crear el JWT nosotros revisamos esta relación. Es decir, al autenticar un usuario, consultamos los roles del usuario para anexarlos al JWT de una forma que ASP.NET entienda que son roles.

Al momento de querer autorizar usuarios, ASP.NET revisará esos Claims del JWT para verificar si tiene autorización de ese método o no.

Puedes hacer pruebas con los dos usuarios que hemos creado: test_user cuenta con el rol Admin pero other_user no, explora con ambos usuarios para ver cómo se comporta y si la autorización está funcionando o no.

Con esta configuración de autenticación, podemos hacer cosas estilo User.IsInRole("Admin") para verificar si el usuario actual tiene cierto rol. Es muy útil siempre.

Accesando al Usuario actual.

Y pues no hemos terminado.

La idea de autorizar usuarios es también poder saber quiénes son al momento de que realizan solicitudes, por lo tanto, debemos de tener un mecanismo para acceder al usuario actual.

El usuario actual se determina según el JWT que se está mandando en la solicitud, por lo cual debemos de poder tener acceso al HttpContext.

El acceso al HttpContext se hace por medio del IHttpContextAccessor y este solo está disponible cuando existe una solicitud HTTP real.

¿Qué significa eso? pues cuando estemos haciendo Unit Testing, existe la posibilidad (o más bien, es un hecho) de que no existirá un HttpContext, por lo cual, este mecanismo de acceder a usuarios debe de ser una abstracción y así poder testear en dado caso.

En futuros posts haremos Integration Tests y posiblemente Unit Tests, por lo que debemos de ser capaces de adaptarnos a esos requisitos.

Services -> ICurrentUserService

ICurrentUserService será la abstracción que nos permitirá el acceso al usuario actual.

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 que necesitamos para poder acceder al usuario actual, por ahora solo necesitamos un objeto con el ID y su UserName.

También estamos abstrayendo el cómo se define si un usuario tiene un rol o no. Para Unit Testing podría ser importante hacer Mocks de esto, por ahora así lo dejamos. La idea principal es no hacer uso del HttpContext directamente, ya que este solo está disponible cuando hablamos de una aplicación Web, pero si el día de mañana necesitamos cambiar el UI y crear una aplicación de consola (esto sí me ha pasado), como por ejemplo herramientas para exportar/importar datos.

Podríamos necesitar acceder a la funcionalidad de Application Core (features), pero ya no desde una aplicación web, por eso creamos esta abstracción.

Nota 👀: CurrentUser podría (o debería) de ir en otro archivo, por simplicidad lo pongo junto con la definición de la interfaz.

Su implementación queda 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

Esto es lo que estaría fuertemente acoplado al HttpContext, pertenece a la presentación directamente.

HttpContext.User es inicializado automaticamente por ASP.NET, ya que con Bearer Tokens hemos indicado que se espera un JWT en el header de Authorization. De la misma forma, con HttpContext.User.IsInRole podemos hacer la comprobación de si el usuario actual cuenta con algún rol. Todo esto es posible porque en el JWT hemos indicado con claims, los roles que tiene el usuario.

Nota 👀: Próximamente, probablemente dividamos esta aplicación en distintos proyectos siguiendo un estilo Vertical Slice Architecture

Actualizando AuthController

Actualizamos el controlador de autorización para explicar 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 usable si el usuario actual está autenticado, probablemente tendremos errores si se intenta acceder a /me si no existe un JWT 🤭.

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

La respuesta que se obtendrá al llamarlo con Swagger:

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

Si probamos con el usuario Admin:

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

Podemos ver que el método IsInRole funciona sin problema.

Ya con esto definimos una abstracción para poder acceder al usuario actual que hace la solicitud. No importa si después decidimos cambiar de Web a CLI, el Application Core deberá seguir funcionando sin problemas.

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

Conclusión

Hemos agregado autenticación y autorización a nuestra aplicación que hemos construido en estos 5 posts (hasta ahora) y utilizando ASP.NET Identity Core nos hemos ahorrado mucho trabajo en la cuestión de seguridad de usuarios.

No tenemos que tocar ningún algoritmo de encriptación ni de hash para poder guardar usuarios con contraseñas de manera segura. Identity Core cuenta con mucha más funcionalidad, como generar códigos de reinicio de contraseña o de confirmación de correo electrónico, pero lo dejaremos para otro post.

Espero que te sea de utilidad, cualquier pregunta no dudes en contactarme en twitter y con gusto te ayudo con cualquier cosa.

Discussion (0)