JWT significa JSON Web Token y es un mecanismo de autorización, no de autenticación. Entonces, averigüemos cuál es la diferencia entre esos dos.
La autenticación es el mecanismo que permite verificar que el usuario es exactamente quien dice ser. Es un proceso de inicio de sesión en el que un usuario proporciona un nombre de usuario y contraseña, y el sistema los verifica. Entonces la autenticación responde a la pregunta: ¿quién es el usuario?
La autorización es el mecanismo que permite verificar qué derechos de acceso tiene el usuario a un determinado recurso. Es un proceso de otorgar a los usuarios algunos roles y un conjunto de permisos que tiene un rol en particular. Entonces, la autorización responde a esa pregunta: ¿qué derechos tiene el usuario en el sistema?
Es importante comprender que la autenticación siempre es lo primero y la autorización lo segundo. En otras palabras, no puede obtener permiso antes de verificar su identidad. ¿Pero cuáles son los métodos de autorización más populares? Hay dos enfoques principales para manejar la autorización de la aplicación web.
Un enfoque tradicional en la web para autorizar a los usuarios es una sesión del lado del servidor basada en cookies. El proceso comienza cuando un usuario inicia sesión y un servidor lo autentica. Después de eso, el servidor crea una sesión con un ID de sesión y la almacena en algún lugar de la memoria del servidor. El servidor devuelve el ID de sesión al cliente y el cliente almacena el ID de sesión en cookies. Para cada solicitud, el cliente envía un ID de sesión como parte de la solicitud y el servidor verifica el ID de sesión en su memoria y los permisos del usuario relacionados con esta sesión.
Otro enfoque popular es utilizar tokens de autorización. El proceso comienza de manera similar cuando un usuario ingresa el inicio de sesión y las contraseñas y un cliente envía una solicitud de inicio de sesión a un servidor. En lugar de crear una sesión, el servidor genera un token firmado con el token secreto. Luego, el servidor devuelve el token al cliente y el cliente debe almacenarlo en un almacenamiento local. De manera similar al enfoque basado en sesiones, el cliente debe enviar un token al servidor para cada solicitud. Sin embargo, el servidor no almacena ninguna información adicional sobre la sesión del usuario. El servidor debe validar que el token no haya cambiado desde que fue creado y firmado con la clave secreta.
El enfoque de autorización basado en sesiones puede ser vulnerable a un ataque conocido como falsificación de solicitudes entre sitios (CSRF). Es un tipo de ataque cuando el atacante apunta a un sitio en el que ha iniciado sesión para realizar acciones que no tenía intención de realizar, como realizar un pago o cambiar una contraseña.
Otra cosa es que cuando se utiliza un enfoque de autorización basado en sesiones se crea una sesión con estado entre un cliente y un servidor. El problema es que si un cliente quiere acceder a diferentes servidores en el ámbito de la misma aplicación, esos servidores tienen que compartir un estado de sesión. En otro caso, será necesario autorizar al cliente en cada servidor ya que la sesión va a ser diferente.
Por otro lado, el enfoque de autorización basado en tokens no requiere almacenar datos de sesión en el lado del servidor y puede simplificar la autorización entre múltiples servidores.
Sin embargo, un atacante aún puede robar los tokens y también puede resultar difícil invalidarlos. Veremos los detalles y cómo manejar la invalidación más adelante en este artículo.
JSON Web Token (JWT) es un estándar abierto que define una forma compacta y autónoma de transmitir información de forma segura entre partes como un objeto JSON. Esta información se puede verificar y confiar porque está firmada digitalmente. Los JWT se pueden firmar usando un secreto (con el algoritmo HMAC ) o un par de claves pública/privada usando RSA o ECDSA .
Los JSON Web Tokens constan de tres partes separadas por puntos .
{ "alg": "HS256", "typ": "JWT" }
El encabezado normalmente consta de dos partes: el tipo de token y el algoritmo de firma que se utiliza.
{ "sub": "1234567890", "name": "John Doe", "admin": true }
La carga útil contiene los reclamos, que son declaraciones sobre el usuario. Luego, la carga útil se codifica en Base64Url para formar la segunda parte del token web JSON. Puede encontrar una descripción de los campos estándar que se utilizan como reclamos aquí .
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Para crear la parte de la firma, debe tomar el encabezado codificado, la carga útil codificada, un secreto y el algoritmo especificado en el encabezado y firmarlo.
El token suele tener el siguiente aspecto:
xxxxx.yyyyy.zzzzz
Puede navegar a jwt.io y depurar un token de muestra o el suyo propio. Simplemente pegue su token en el campo Codificado y seleccione el Algoritmo de firma del token.
Ahora que tenemos conocimientos teóricos sobre cómo funciona JWT, podemos aplicarlos al proyecto de la vida real. Supongamos que tenemos una API simple que representa operaciones CRUD para la entidad cafetera. Vamos a crear un proyecto ASP.NET Core API que represente Coffee API. Después de eso, crearemos otro proyecto API de ASP.NET Core que representaría una API de identidad que podría generar JWT. En la vida real, probablemente usaría Identity Server , Okta o Auth0 para fines de autenticación/autorización. Sin embargo, crearíamos nuestra propia API de identidad para demostrar cómo generar JWT. Cuando finaliza la API de identidad, podemos llamar a su controlador y generar JWT en función de los datos del usuario. Además, podemos proteger la API de Coffee con una configuración de autorización que requiere pasar JWT con cada solicitud.
Primero, vamos a crear un proyecto API ASP.NET Core simple que represente Coffee API. Aquí está la estructura de este proyecto:
Comencemos con Coffee.cs
en la carpeta Model
. Es una entidad simple con propiedades de Id
y Name
.
namespace Hackernoon.Coffee.API.Model; public class Coffee { public int Id { get; set; } public string Name { get; set; } }
Necesitamos almacenar nuestras entidades mientras trabajamos con la API. Entonces, introduzcamos un almacenamiento en memoria simple. Se encuentra en el archivo Storage.cs
en la carpeta Data
.
namespace Hackernoon.Coffee.API.Data; public static class Storage { private static readonly List<Model.Coffee> Data = new(); public static List<Model.Coffee> GetAll() { return Data; } public static bool Create(Model.Coffee model) { if (Data.Any(c => c.Id == model.Id || c.Name == model.Name)) return false; Data.Add(new Model.Coffee { Id = model.Id, Name = model.Name }); return true; } public static bool Delete(int id) { if (Data.All(c => c.Id != id)) return false; Data.Remove(Storage.Data.First(c => c.Id == id)); return true; } public static bool Update(Model.Coffee model) { if (Data.All(c => c.Id != model.Id)) return false; Data.First(c => c.Id == model.Id).Name = model.Name; return true; } }
Necesitamos una clase que represente solicitudes a la API de Coffee. Entonces, creemos CoffeeRequest.cs
en la carpeta Contracts
.
namespace Hackernoon.Coffee.API.Contracts; public class CoffeeRequest { public int Id { get; set; } public string Name { get; set; } }
Cuando termine, podemos implementar CoffeeController.cs
en la carpeta Controller
que representa las operaciones CRUD para la entidad de café.
using Hackernoon.Coffee.API.Contracts; using Hackernoon.Coffee.API.Data; using Microsoft.AspNetCore.Mvc; namespace Hackernoon.Coffee.API.Controllers; [Route("coffee")] [ApiController] public class CoffeeController : ControllerBase { [HttpGet] public IList<Model.Coffee> GetAll() { return Storage.GetAll(); } [HttpPost] public IActionResult Create([FromBody]CoffeeRequest request) { var model = new Model.Coffee { Id = request.Id, Name = request.Name }; if (!Storage.Create(model)) return new BadRequestResult(); return new OkResult(); } [HttpDelete] public IActionResult Delete(int id) { if (!Storage.Delete(id)) return new BadRequestResult(); return new OkResult(); } [HttpPut] public IActionResult Update([FromBody] CoffeeRequest request) { var model = new Model.Coffee() { Id = request.Id, Name = request.Name }; if (!Storage.Update(model)) return new BadRequestResult(); return new OkResult(); } }
La API de Coffee está lista y podemos ejecutar el proyecto y ver la interfaz de usuario de Swagger de la siguiente manera:
Creemos otro proyecto de API de ASP.NET Core que represente la API de identidad. Aquí está la estructura de este proyecto:
Comencemos con TokenGenerationRequest.cs
en la carpeta Contracts
, que representa la solicitud para la generación de un nuevo JWT con propiedades Email
y Password
.
namespace Hackernoon.Identity.API.Contracts; public class TokenGenerationRequest { public string Email { get; set; } public string Password { get; set; } }
Necesitamos implementar solo TokenController.cs
que representa la lógica de generación JWT. Pero antes de hacerlo, es necesario instalar el paquete Microsoft.AspNetCore.Authentication.JwtBearer
NuGet.
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Hackernoon.Identity.API.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; namespace Hackernoon.Identity.API.Controllers; [Route("token")] public class TokenController : ControllerBase { private const string SecretKey = "VerySecretAndLongKey-NeedMoreSymbolsHere-123"; private const string Issuer = "IdentityServerIssuer"; private const string Audience = "IdentityServerClient"; private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(20); [HttpPost] public string Create([FromBody]TokenGenerationRequest request) { var claims = new List<Claim> {new Claim(ClaimTypes.Email, request.Email) }; var jwt = new JwtSecurityToken( issuer: Issuer, audience: Audience, claims: claims, expires: DateTime.UtcNow.Add(Lifetime), signingCredentials: CreateSigningCredentials()); return new JwtSecurityTokenHandler().WriteToken(jwt); } private static SigningCredentials CreateSigningCredentials() { return new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)), SecurityAlgorithms.HmacSha256); } }
Tenga en cuenta que las constantes sensibles como SecretKey
, Issuer
y Audience
deben colocarse en algún lugar de la configuración. Están codificados solo para simplificar este proyecto de prueba. El campo Lifetime
está configurado en 20 minutos, lo que significa que el token será válido durante ese tiempo. También puede configurar este parámetro.
Ahora podemos ejecutar el proyecto y ver la interfaz de usuario de Swagger de la siguiente manera:
Hagamos una llamada al punto final /token
y generemos un nuevo JWT. Pruebe la siguiente carga útil:
{ "email": "[email protected]", "password": "password" }
La API de identidad generará el JWT correspondiente:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJqb2huLmRvZUBnbWFpbC5jb20iLCJJc0dvdXJtZXQiOiJmYWxzZSIsImV4cCI6MTcwNzc4Mzk4MCwiaXNzIjoiSWRlbnRpdHlTZXJ2ZXJJc3N1ZXIiLCJhdWQiOiJJZGVudGl0eVNlcnZlckNsaWVudCJ9.4odXsbWak1C0uK3Ux-n7f58icYQQwlHjM54OjgMCVPM
Ahora, cuando la API de identidad esté lista y nos proporcione tokens, podremos proteger la API de Coffee con autorización. Nuevamente es necesario instalar el paquete Microsoft.AspNetCore.Authentication.JwtBearer
NuGet.
Necesitamos registrar los servicios requeridos mediante servicios de autenticación. Agregue el siguiente código al archivo Program.cs
justo después de crear un generador.
var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = "IdentityServerIssuer", ValidateAudience = true, ValidAudience = "IdentityServerClient", ValidateLifetime = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes("VerySecretAndLongKey-NeedMoreSymbolsHere-123")), ValidateIssuerSigningKey = true, }; }); builder.Services.AddAuthorization();
Es importante recordar que el orden en el middleware es importante. Habilitamos la autenticación llamando al método AddAuthentication()
y especificando JwtBearerDefaults.AuthenticationScheme
como esquema de autenticación. Es una constante que contiene un valor Bearer
.
namespace Microsoft.AspNetCore.Authentication.JwtBearer { /// <summary>Default values used by bearer authentication.</summary> public static class JwtBearerDefaults { /// <summary> /// Default value for AuthenticationScheme property in the JwtBearerAuthenticationOptions /// </summary> public const string AuthenticationScheme = "Bearer"; } }
Necesitamos especificar TokenValidationParameters
que describe qué parámetros de JWT se validarán durante la autorización. También especificamos IssuerSigningKey
similar a signingCredentials
en Identity API para verificar la firma JWT. Consulte más detalles sobre TokenValidationParameters
aquí .
El siguiente fragmento de código agrega middleware al constructor que habilita capacidades de autenticación y autorización. Debe agregarse entre los métodos UseHttpsRedirection()
y MapControllers()
.
app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
Ahora podemos usar el atributo Authorize
sobre el controlador o sus acciones. Al aplicar este código, ahora todas las acciones en CoffeeController
están protegidas con un mecanismo de autorización y JWT debe enviarse como parte de la solicitud.
[Route("coffee")] [ApiController] [Authorize] public class CoffeeController : ControllerBase { ..
Si realizamos una llamada a cualquier punto final de la API de Coffee, podemos depurar HttpContext.User
y ver que está completo y tiene una Identity
con las reclamaciones que hemos especificado en JWT. Es algo importante para comprender cómo ASP.NET Core maneja la autorización internamente.
Hicimos un gran trabajo para proteger Coffee API con la autorización. Pero si ejecuta el proyecto Coffee API y abre Swagger UI, no podrá enviar JWT como parte de la solicitud. Para solucionarlo, necesitamos actualizar el archivo Program.cs
con el siguiente código:
builder.Services.AddSwaggerGen(option => { option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, Description = "Please enter a valid token", Name = "Authorization", Type = SecuritySchemeType.Http, BearerFormat = "JWT", Scheme = "Bearer" }); option.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type=ReferenceType.SecurityScheme, Id="Bearer" } }, new string[]{} } }); });
Después de eso, podremos ver el botón Autorizar en la esquina superior derecha:
Al hacer clic en el botón Autorizar podrá ingresar JWT de la siguiente manera:
No puede limitarse a utilizar Swagger UI y puede realizar pruebas de la API a través de la herramienta Postman. Primero llamemos al punto final /token
de la API de identidad. Necesitamos especificar el encabezado Content-Type
con el valor application/json
en la sección Encabezados ya que usaremos JSON como carga útil.
Después de eso, podemos llamar al punto final /token
y obtener un nuevo JWT.
Ahora podemos copiar JWT y usarlo para llamar a Coffee API. Necesitamos especificar un encabezado Content-Type
similar a la API de identidad si queremos probar, crear y actualizar puntos finales. El encabezado Authorization
también debe configurarse con el valor Bearer [your JWT value]
. Después de eso, simplemente presione el botón Enviar y vea el resultado.
Como recordará, la parte de carga útil de JWT es un conjunto de reclamos con valores que son exactamente pares clave-valor. La autorización basada en roles permite diferenciar el acceso a los recursos de la aplicación según el rol al que pertenece el usuario.
Si actualizamos el método Create()
en el archivo TokenController.cs
en Identity API con el código que agrega un nuevo reclamo para el rol; Podemos manejar la autenticación basada en roles en la API de Coffee. ClaimTypes.Role
es un nombre predefinido de la reclamación de rol.
var claims = new List<Claim> { new Claim(ClaimTypes.Email, request.Email), new Claim(ClaimTypes.Role, "Barista") };
Actualice el atributo Authorize
en el archivo CoffeeController.cs
especificando el nombre del rol:
[Authorize(Roles = "Barista")]
Ahora, todos los usuarios que realicen una llamada a Coffee API deben tener el rol de reclamo con el valor Barista
. De lo contrario, obtendrán el código de estado 403 Forbidden
.
Un atributo Authorize
puede manejar fácilmente la autenticación basada en roles. Pero ¿qué pasa si no es suficiente y queremos diferenciar el acceso en función de algunas propiedades del usuario como la edad o cualquier otra? Probablemente ya haya adivinado que puede agregar sus reclamos a JWT y usarlos para crear una lógica de autorización. La autorización basada en roles en sí misma es un caso especial de autorización basada en notificaciones, del mismo modo que una función es el mismo objeto de notificación de un tipo predefinido.
Actualicemos el método Create()
en el archivo TokenController.cs
en Identity API con el código que agrega un nuevo reclamo IsGourmet
.
var claims = new List<Claim> { new Claim(ClaimTypes.Email, request.Email), new Claim("IsGourmet", "true") };
En el archivo Program.cs en Coffee API, necesitamos crear una política que verifique un reclamo y pueda usarse en el atributo Authorize
. El siguiente código debe agregarse justo después de la llamada al método AddAuthentication()
.
builder.Services.AddAuthorization(opts => { opts.AddPolicy("OnlyForGourmet", policy => { policy.RequireClaim("IsGourmet", "true"); }); });
Actualice el atributo Authorize
en el archivo CoffeeController.cs
especificando el nombre de la política:
[Authorize(Policy = "OnlyForGourmet")]
¡Felicidades! Hiciste un gran esfuerzo al aprender JWT en .NET. Ahora, debe tener un conocimiento sólido de los principios de JWT y de por qué es importante utilizarlo para realizar la autorización en aplicaciones .NET. Pero apenas hemos arañado la superficie en el área de autenticación y autorización en aplicaciones ASP.NET Core.
Sugiero consultar la documentación de Microsoft sobre los temas que analizamos en este artículo. También hay muchas capacidades integradas para autorización y gestión de roles en la plataforma .NET. Una buena adición a este artículo podría ser la documentación de Microsoft sobre autorización.