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"
}
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
.
- Por ejemplo, un claim que guarda los roles de ASP.NET Core tendría el siguiente nombre:
Un ejemplo de un payload sería el siguiente:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
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)
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:
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>
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:
- 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. - Cuando se autoriza el acceso, el servidor de autorización regresa el access token a la aplicación cliente
- 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
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>
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)
{
}
}
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!;
}
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>();
-
AddSqlite
: Registra elDbContext
, es un atajo del método habitualAddDbContext
-
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 claseIdentityRole
) -
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"]))
};
});
-
AddHttpContextAccessor
: Registra elIHttpContextAccessor
que nos permite acceder elHttpContext
de 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": "*"
}
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
Y contaríamos con algo similar a lo siguiente:
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();
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
});
});
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);
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();
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");
}
}
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...
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"
}
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"
}
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}}
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"
}
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)
Top comments (5)
Quizá la solución que busco no es viable pero, ahora en una web consigo el jwt token a través de una API (en la que puedo añadirle Roles), pero quiero controlar en cada Page de la web si el usuario que ha hecho login está autorizado ([Authorize(Roles = "??")]). Esto es viable o si genero el jwt en la API sólo puedo controlar Roles en la API y para hacerlo en las Pages de la web debería generar el jwt en la misma web?
Gracias
A ver si entendí,
Tienes una aplicación web (digamos Razor Pages) que esta se comunica con una API que tiene autenticación por JWT? Si sí, lo que se tiene que hacer es generar Cookies en tu aplicacion de Razor Pages, dentro de las cookies tienes que poner la info que el JWT te da. Incluso el mismo JWT debe de ir en una Cookie para persistirlo y usarlo con la API.
Generar Cookies con roles es sencillo, los roles deberán venir en el JWT o habrá que preguntarlos a la API.
Aquí puedes ver más sobre las cookies
Entiendo, entonces, que no podré usar el Authorization y sus Roles como
[Authorize(Roles="Admin")]
por ejemplo, ya que tendré que hacer la comprobación manual en cada Page de los roles del usuario guardados en las Cookies, no?No, sí puedes usar roles, al crear la cookie le asignas los que desees dentro de los Claims:
La config de cookie authentication entenderá que el Claim
ClaimTypes.Role
son los roles, así que al usar el[Authorize(Roles = "Admin")]
deberá funcionar.Saludos!
Gracias por el buen tutorial. Como haría para agregar a esta estructura autenticación con LDAP? Solo obtendría el nombre y usuario, los roles serían de mi BD
GRacias