DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

ASP.NET Core 6: Autenticación JWT y Identity Core

Introducción

En este artículo exploraremos a fondo las características de los JSON Web Tokens, su composición y su implementación utilizando Minimal APIs y ASP.NET Identity.

El código de ejemplo lo podrás encontrar en este repositorio en mi github. Espero les sea de utilidad.

Autenticación JWT Bearer

¿Qué es un Json Web Token?

Un JSON Web Token (JWT) es un estándar (RFC 7519) que define una forma segura y compacta de transmitir información entre dos entidades en forma de un objeto JSON.

Esta información puede ser verificada y es confiable ya que está firmada digitalmente. Los JWTs pueden ser firmados utilizando una llave privada (con un algoritmo HMAC) o con llaves públicas y privadas utilizando RSA o ECDSA.

¿Cuando deberías utilizar Json Web Tokens?

Aquí veremos un par de escenarios donde es útil y recomendable utilizar los JWTs:

  • Autorización: Este es el caso de uso más común de los JWTs. Una vez que un usuario ha iniciado sesión, cada llamada subsecuente al servicio incluirá el JWT, permitiendo al usuario acceder a rutas, servicios o recursos que solo están permitidos con su debido token. SSO (Single Sign On) es una funcionalidad que hoy en día usa los JWTs ampliamente, por que son de tamaño reducido y por su habilidad de ser usado entre diferentes dominios.
  • Intercambio de Información: Los JWTs son útiles también para transmitir información entre dos entidades. Debido a que los JWTs pueden estar firmados — por ejemplo, utilizando una llave pública/privada — podemos estar seguros que quien manda la información es verdaderamente él quien lo manda. Adicionalmente, la firma es calculada utilizando el encabezado del JWT y el contenido (payload) por lo que también estamos seguros que el contenido del JWT no fue alterado.

¿Qué estructura tiene un JWT?

Un JWT está separado por puntos ( . ) en tres partes, las cuales son:

  • Encabezado (header)
  • Contenido (payload)
  • Firma (signature)

Un JWT comúnmente tiene la siguiente forma.

xxxxx.yyyyy.zzzzz

Veamos que significa cada una de estas partes.

Header

El encabezado típicamente consiste de dos partes: el tipo de token (que será JWT) y el algoritmo que se está usando en la firma, que puede ser HMAC SHA256 o RSA.

Por ejemplo:

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Después, este JSON se codifica en Base64URL para formar parte del primer segmento del JWT.

Payload

La segunda parte del JWT es el contenido que se transmite o certifica (payload), el cual contiene la serie de claims. Claims son afirmaciones sobre una entidad (usualmente, el usuario) e información adicional. Hay tres tipos de claims: registrados, públicos y privados.

  • Claims registrados: Son un conjunto de claims predefinidos que no son obligatorios pero sí recomendados, para proveer un conjunto de claims interoperables. Algunos de ellos son: iss (issuer), exp (tiempo de expiración), sub (subject), aud (audience), entre otros.

💡 Nótese que los nombres de los claims son de tres letras por la misma intención de mantener el JWT de tamaño reducido.

  • Claims públicos: Estos pueden ser definidos como cada quien desee, pero para evitar colisiones de nombres y mantener un estándar (ya que puede usarse en distintos servicios), se utiliza la siguiente lista llamada IANA JSON Web Token Registry.
  • Claims privados: Estos claims son personalizados por cada quien que implemente los JWTs y al igual que los públicos, para evitar colisiones es recomendable utilizar un formato URL con algún namespace y así asegurar que son únicos
    • Por ejemplo, un claim que guarda los roles de ASP.NET Core tendría el siguiente nombre: http://schemas.microsoft.com/ws/2008/06/identity/claims/role.

Un ejemplo de un payload sería el siguiente:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
Enter fullscreen mode Exit fullscreen mode

Y al igual que el header, este segmento se codifica en Base64Url.

💡Nota: Aunque los JWT estén firmados, solo están protegidos para evitar falsificaciones (editar el payload) pero de igual forma, toda la información en el payload es visible para cualquiera. NO INCLUYAS información sensible en el payload al menos que esté encriptada*.*

Signature

Para crear la firma debemos de tomar el header codificado, el payload codificado, una llave secreta, el algoritmo especificado en el header y firmar todo eso.

Por ejemplo, si vamos a utilizar el algoritmo de encripción HMAC SHA256, la firma será creada de la siguiente forma:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Enter fullscreen mode Exit fullscreen mode

La firma se usará para verificar que el mensaje no ha cambiado mientras viaja por la red, y en caso de ser tokens firmados por una llave privada de un certificado, también se puede verificar el emisor.

Juntando todo

Al final, tendremos tres cadenas de texto codificadas en Base64-URL separadas por puntos y se podrán incluir en solicitudes HTTP o contenido HTML sin ningún problema. Esto es una forma mucho más compacta comparado a otros estándares como SAML que utiliza XML.

Al final, tendríamos un JWT de la siguiente forma:

JWT

Si quieres jugar y generar tus propios JWT de prueba, puedes visitar jwt.io.

¿Cómo funcionan los JWT?

Cuando un usuario ha sido autenticado, el servicio deberá regresar un JSON Web Token para ser usado como sus credenciales. Dado que esto es usado para autorizar el usuario, debes de considerar cuidar muy bien donde guardas el token, y eliminarlo lo más pronto posible si ya no se requiere.

Cuando un usuario quiere acceder a contenido restringido en una ruta protegida, se debe de incluir el token en el HTTP Header Authorization y utilizando el esquema Bearer.

Ejemplo:

Authorization: Bearer <token>
Enter fullscreen mode Exit fullscreen mode

Generalmente en Web APIs (y como lo haremos más adelante) que son aplicaciones stateless, siempre requerirá que el token vaya incluido en el encabezado Authorization. El servicio verificará lo necesario para determinar si es un token válido o no, y si este es válido. leerá su información (los claims) y lo usará en la solicitud de ser necesario.

Esto también reduce las consultas a bases de datos para leer información del usuario, ya que el token puede contener información común para poder operar (como username, email, roles, etc).

Dado que el token va incluido en el header, no habrá problemas con el Cross-Origin Resource Sharing (CORS) ya que no se utilizan cookies (las cookies son por dominio).

El siguiente diagrama muestra como se podría utilizar una autorización y autenticación por medio de JWT:

Image description

  1. La aplicación cliente solicita autorización al Identity Server (como Auth0 o Azure AD B2C). Esto se puede hacer por medio de distintos flujos de autorización definidos en el estándar OpenID Connect (pero no estamos obligados a seguirlos). De igual forma, si seguimos OpenID, típicamente se utilizaría el endpoint /oauth/authorize utilizando el flujo de code flow.
  2. Cuando se autoriza el acceso, el servidor de autorización regresa el access token a la aplicación cliente
  3. La aplicación cliente usa el access token para acceder a recursos protegidos (como una API)

¿Y el código? Probemos con ASP.NET y Minimal APIs

En este ejemplo utilizaremos herramientas production-ready y trataré de mantenerlo simple, sin embargo, cada quien podrá decidir como estructurarlo e implementarlo.

Anteriormente mencionamos el estándar OpenId, que especifica como realizar estos flujos de autenticación, pero para fines prácticos y didácticos, realizaremos nuestro propio servidor de autorización (será el mismo que la API protegida) pero es muy recomendable delegar este proceso a servicios (como Auth0) o frameworks (como IdentityServer) certificados para una mayor seguridad y compliance.

En este proyecto utilizaremos:

  • Entity Framework Core con SQLite para persistencia (para fines del ejemplo, en producción deberías de usar un servicio como SQL Azure o similares)
  • ASP.NET Identity para el manejo de credenciales.
  • Minimal APIs por su sencilles, pero podrán usar Controllers, Carter, ApiEndpoints o cualquier endpoint que deseen.

Para comenzar, crearemos un proyecto Web vacío:

dotnet new web -o WebApiJwt
Enter fullscreen mode Exit fullscreen mode

Y necesitamos los siguientes paquetes registrados en el WebApiJwt.csproj:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
  <PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
  <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />
  <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.1">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

Persistencia

Crearemos una carpeta llamada “Persistence” y aquí pondremos las migraciones y el DbContext con tablas preestablecidas por Identity:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using WebApiJwt.Entities;

namespace WebApiJwt.Persistence;

public class MyDbContext : IdentityDbContext<User>
{
    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Para lo cual, necesitaremos nuestra definición custom de la clase Usuario:

using Microsoft.AspNetCore.Identity;

namespace WebApiJwt.Entities;

public class User : IdentityUser
{
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
}
Enter fullscreen mode Exit fullscreen mode

Aquí estamos usando un DbContext con tablas preestablecidas y IdentityUser es parte de ellas, solo lo estamos extendiendo para agregar campos personalizados (nombre y apellidos).

Configuración de Identity y JWT

Para configurar Identity y EntityFramework, registramos las siguientes dependencias en nuestro archivo Program.cs:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using WebApiJwt.Entities;
using WebApiJwt.Models;
using WebApiJwt.Persistence;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddSqlite<MyDbContext>(builder.Configuration.GetConnectionString("Default"))
    .AddIdentityCore<User>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<MyDbContext>();
Enter fullscreen mode Exit fullscreen mode
  • AddSqlite: Registra el DbContext, es un atajo del método habitual AddDbContext
  • AddIdentityCore: Registra las dependencias que necesita Identity, como generador de contraseñas, manejo de usuarios, etc
  • AddRoles: Registra todo lo necesario para poder usar roles (en este caso, con la implementación default de la clase IdentityRole)
  • AddEntityFrameworkStores: Vincula nuestro contexto de EntityFramework con todas sus dependencias que Identity necesita respecto a persistencia

Después de esto, agregamos la configuración que necesitamos para poder autenticar por medio de JWTs:

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
  • AddHttpContextAccessor: Registra el IHttpContextAccessor que nos permite acceder el HttpContextde cada solicitud (la usaremos más adelante para acceder al usuario actual autenticado)
  • AddAutorization: Dependencias necesarias para autorizar solicitudes (como autorización por roles)
  • AddAuthentication: Agrega el esquema de autenticación que queramos usar, en este caso, queremos usar por default la autenticación por Bearer Tokens
  • AddJwtBearer: Configura la autenticación por tokens, especificando que debe de validar y que llave privada utilizar
    • Por supuesto, esta configuración la va a leer del appsettings.json

Quedando el archivo de configuración de la siguiente manera:

{
  "ConnectionStrings": {
    "Default": "Data Source=Identity.db"
  },
  "Jwt": {
    "Issuer": "WebApiJwt.com",
    "Audience": "localhost",
    "Key": "S3cr3t_K3y!.123_S3cr3t_K3y!.123"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}
Enter fullscreen mode Exit fullscreen mode

En este punto, deberíamos de poder crear las migraciones de la base de datos (en este caso, SQLite) y actualizar el esquema con todo lo predefinido por Identity:

dotnet ef migrations add FirstMigration -o Persistence/Migrations
Enter fullscreen mode Exit fullscreen mode

Y contaríamos con algo similar a lo siguiente:

Image description

Para finalizar la configuración y antes de implementar la autenticación, debemos de usar dos middlewares que nos ayudarán a decodificar automáticamente el JWT y agregarlo (en caso de ser válido) a la solicitud HTTP.

var app = builder.Build();

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

app.MapGet("/", () => "Hello World!");

app.Run();
Enter fullscreen mode Exit fullscreen mode

Endpoints

Implementaremos dos endpoints, uno para autenticación y uno para simular un acceso restringido

Authorization endpoint (/token):

app.MapPost("/token", async (AuthenticateRequest request, UserManager<User> userManager) =>
{
    // Verificamos credenciales con Identity
    var user = await userManager.FindByNameAsync(request.UserName);

    if (user is null || !await userManager.CheckPasswordAsync(user, request.Password))
    {
        return Results.Forbid();
    }

    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),
        new Claim(ClaimTypes.GivenName, $"{user.FirstName} {user.LastName}")
    };

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

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

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

    return Results.Ok(new
    {
        AccessToken = jwt
    });
});
Enter fullscreen mode Exit fullscreen mode

El código de arriba se divide en dos partes:

  • 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.

Por parámetro se recibe el usuario y contraseña, este es el siguiente record:

namespace WebApiJwt.Models;
public record AuthenticateRequest(string UserName, string Password);
Enter fullscreen mode Exit fullscreen mode

Protected endpoint (/me)

Este endpoint lo único que hará es regresar la información del usuario (claims) según el JWT que se mandó:

app.MapGet("/me", (IHttpContextAccessor contextAccessor) =>
{
    var user = contextAccessor.HttpContext.User;

    return Results.Ok(new
    {
        Claims = user.Claims.Select(s => new
        {
            s.Type,
            s.Value
        }).ToList(),
        user.Identity.Name,
        user.Identity.IsAuthenticated,
        user.Identity.AuthenticationType
    });
})
.RequireAuthorization();
Enter fullscreen mode Exit fullscreen mode

Utilizamos IHttpContextAccessor para acceder al usuario decodificado automáticamente por el middleware y simplemente regresamos esa información como prueba.

Usamos la extensión RequireAuthorization para indicar al endpoint que se necesita un esquema de autorización y como no se específica lo contrario, utilizará el esquema default, que es Bearer Tokens.

Probando la solución

Para poder probar esto, necesitamos usuarios de prueba, para eso crearemos un método SeedData dentro del Program.cs

async Task SeedData()
{
    var scopeFactory = app!.Services.GetRequiredService<IServiceScopeFactory>();
    using var scope = scopeFactory.CreateScope();

    var context = scope.ServiceProvider.GetRequiredService<MyDbContext>();
    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
    var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
    var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();

    context.Database.EnsureCreated();

    if (!userManager.Users.Any())
    {
        logger.LogInformation("Creando usuario de prueba");

        var newUser = new User
        {
            Email = "test@demo.com",
            FirstName = "Test",
            LastName = "User",
            UserName = "test.demo"
        };

        await userManager.CreateAsync(newUser, "P@ss.W0rd");
        await roleManager.CreateAsync(new IdentityRole
        {
            Name = "Admin"
        });
        await roleManager.CreateAsync(new IdentityRole
        {
            Name = "AnotherRole"
        });

        await userManager.AddToRoleAsync(newUser, "Admin");
        await userManager.AddToRoleAsync(newUser, "AnotherRole");
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí simplemente nos aseguramos que la base de datos exista y si previamente no hay usuarios, se crearán los roles y un usuario de prueba utilizando las clases de Identity.

Los roles se pueden utilizar para autorizar endpoints según el rol del usuario. En este ejemplo solo muestro como incluirlos en el JWT pero asp.net lo entenderá sin problema.

// ...Más código

var app = builder.Build();

await SeedData();

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

// Más código...
Enter fullscreen mode Exit fullscreen mode

Corremos la aplicación y hacemos nuestras primeras pruebas utilizando HTTP Rest de VS Code (o puedes usar Postman o cualquier cliente http que gustes):

Solicitud:

POST {{host}}/token
Content-Type: application/json

{
    "userName": "test.demo",
    "password": "P@ss.W0rd"
}
Enter fullscreen mode Exit fullscreen mode

Respuesta:

HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Sun, 02 Jan 2022 22:32:30 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "accessToken": "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9zaWQiOiJhMWNhODMxZC1iMTIzLTQ0ZDgtYjViOC1iNjNlYWZiYzZlNDciLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidGVzdC5kZW1vIiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvZ2l2ZW5uYW1lIjoiVGVzdCBVc2VyIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIkFub3RoZXJSb2xlIiwiQWRtaW4iXSwiZXhwIjoxNjQxMjA1OTUwLCJpc3MiOiJXZWJBcGlKd3QuY29tIiwiYXVkIjoibG9jYWxob3N0In0.CtTkO7JVmFl6ASRv1v7OuZhCrOHUy-AiMfNUzQbYByc"
}
Enter fullscreen mode Exit fullscreen mode

Puedes hacer pruebas con usuarios o contraseñas incorrectas.

Para verificar el endpoint protegido llamamos el endpoint /me:

GET {{host}}/me
Content-Type: application/json
Authorization: Bearer {{jwt}}
Enter fullscreen mode Exit fullscreen mode

Respuesta:

HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Sun, 02 Jan 2022 22:33:56 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "claims": [
    {
      "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/sid",
      "value": "a1ca831d-b123-44d8-b5b8-b63eafbc6e47"
    },
    {
      "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
      "value": "test.demo"
    },
    {
      "type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
      "value": "Test User"
    },
    {
      "type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
      "value": "AnotherRole"
    },
    {
      "type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
      "value": "Admin"
    },
    {
      "type": "exp",
      "value": "1641205950"
    },
    {
      "type": "iss",
      "value": "WebApiJwt.com"
    },
    {
      "type": "aud",
      "value": "localhost"
    }
  ],
  "name": "test.demo",
  "isAuthenticated": true,
  "authenticationType": "AuthenticationTypes.Federation"
}
Enter fullscreen mode Exit fullscreen mode

Puedes hacer pruebas modificando el token manualmente desde JWT.io o modificando cualquier dato y explora como se comporta.

Conclusión

Los JSON Web Tokens se han convertido en el esquema default de autenticación de las aplicaciones modernas. Saber como se forman y como implementarlas es un must have al diseñar una aplicación web hoy en día.

El uso de asp.net Identity es la forma recomendada de emplear este mecanismo (o cualquier mecanismo de autenticación) ya que el manejo de seguridad y contraseñas a nivel código ya no sería de nuestra preocupación y utilizamos un framework enterprise ready en lugar de reinventar la rueda.

Referencias

JSON Web Token Introduction - jwt.io

Implementing JWT Authentication in ASP.NET Core 5 (codemag.com)

Discussion (0)