Autenticación JWT con Identity

Lo que pretendo con este post, es implementar la autenticación con JWT en una API creada con ASP.NET. Para ello, voy a exponer endpoints para el registro de un usuario, su identificación y el refresco de un token.
Hay muchas maneras de realizar esta implementación pero, para este ejemplo, me voy a apoyar en Identity. A continuación iré detallando todos los pasos para una completa implementación usando Identity, como he comentado antes, y EF con una base de datos SQL Server.
Configurando nuestra base de datos
Lo primero que tenemos que hacer es dar a nuestra aplicación la capacidad de guardar usuarios, para realizar el registro e identificación de estos. Para ello, antes de nada tenemos que añadir el nuget Microsoft.AspNetCore.Identity.EntityFrameworkCore.
Una vez añadido tendremos acceso a las clases que identity nos proporciona, pudiendo sobre escribirlas para añadir funcionalidad extra.
Para el ejemplo, voy a sobre escribir la clase IdentityUser para añadir funcionalidad relacionada con el refresco de tokens. Por lo que la clase quedaría de la siguiente manera.
public sealed class UserEntity : IdentityUser
{
public string RefreshToken { get; set; }
public DateTime RefreshTokenExpiration { get; set; }
}
El siguiente paso es modificar nuestro contexto de base de datos para que herede de IdentityDbContext.
public class TasksDbContext : IdentityDbContext<UserEntity>
{
...
}
public TasksDbContext(DbContextOptions options) : base(options)
{
...
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
...
}
En este momento, estamos en condiciones de hacer una migración y aplicarla para que se generen todas las tablas asociadas a identity en nuestra base de datos.

Para el ejemplo en el que estoy trabajando, he utilizado el patrón repositorio, pero no es necesario añadir ningún repositorio para trabajar con las tablas creadas por identity ya que podemos utilizar los managers que nos ofrece el nuget.
Añadiendo valores por defecto
Una de las cosas que he hecho para el ejemplo, es crear un rol y un usuario por defecto.
Para la creación del rol he implementado IEntityTypeConfiguration
public sealed class RoleConfiguration : IEntityTypeConfiguration<IdentityRole>
{
public void Configure(EntityTypeBuilder<IdentityRole> entityBuilder)
{
entityBuilder.HasData(
new IdentityRole
{
Name = Roles.SUPERADMIN,
NormalizedName = Roles.SUPERADMIN.ToUpperInvariant()
});
}
}
public class TasksDbContext : IdentityDbContext<UserEntity>
{
...
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
...
modelBuilder.ApplyConfiguration(new RoleConfiguration());
}
}
Para crear el usuario, prefiero hacerlo con un seed al iniciar la aplicación, así puedo utilizar UserManager, que me facilita la tarea de crearlo y asociarlo a un rol, sin necesidad de conocer la estructura de datos interna de las tablas de la base de datos.
public class UsersSeed
{
private readonly UserManager<UserEntity> _userManager;
public UsersSeed(UserManager<UserEntity> userManager)
{
_userManager = userManager;
}
public async Task SeedAsync(TasksDbContext context, CancellationToken cancellationToken = default)
{
await AddDefaultUsersAsync(context, cancellationToken);
}
private async Task AddDefaultUsersAsync(TasksDbContext context, CancellationToken cancellationToken = default)
{
if (await context.Users.AnyAsync(cancellationToken))
return;
var superAdmin = new UserEntity
{
UserName = "admin",
Email = "admin@dicres.com"
};
await _userManager.CreateAsync(superAdmin, "123abcABC-_!");
await _userManager.AddToRoleAsync(superAdmin, Roles.SUPERADMIN);
}
}
Este seed lo ejecuto desde un hosted service, para que se lance al iniciar mi aplicación.
public class HostedService : IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
public HostedService(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var services = scope.ServiceProvider;
var context = services.GetService<TasksDbContext>();
await context!.Database.MigrateAsync(cancellationToken);
var userManager = services.GetService<UserManager<UserEntity>>();
await new UsersSeed(userManager).SeedAsync(context, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
Creando servicio para operar con usuario
Ya tenemos la estructura de base de datos modificada para trabajar con todo lo que nos ofrece identity, así como un usuario super administrador creado. El siguiente paso es crear un servicio que nos permita operar con los usuarios, esto es, registrarlos, loguearlos, borrarlos y hacer el refresco del token de acceso.
La interfaz de dicho servicio quedará de la siguiente manera.
public interface IUserService
{
Task<TokenResponse> SignUpAsync(SignUpRequest userInfo, CancellationToken cancellationToken = default);
Task<TokenResponse> SignInAsync(SignInRequest userInfo, CancellationToken cancellationToken = default);
Task DeleteAsync(UserDeleteRequest deleteRequest, CancellationToken cancellationToken = default);
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest refreshTokenRequest, CancellationToken cancellationToken = default);
}
Los modelos con los que trabaja el servicio no son las entidades de la base de datos, por lo que a continuación muestro estos modelos, en los que se puede ver que se añaden ciertas reglas que deben cumplir algunos campos.
public record SignInRequest
{
[Required]
public string UserName { get; init; }
[Required]
[MinLength(12)]
public string Password { get; init; }
public SignInRequest(string userName, string password)
{
UserName = userName;
Password = password;
}
}
public record SignUpRequest
{
[Required]
public string UserName { get; init; }
[Required]
[EmailAddress]
public string Email { get; init; }
[Required]
[MinLength(12)]
public string Password { get; init; }
public SignUpRequest(string userName, string email, string password)
{
UserName = userName;
Email = email;
Password = password;
}
}
public record UserDeleteRequest
{
public string UserName { get; init; }
public UserDeleteRequest(string userName)
{
UserName = userName;
}
}
public record RefreshTokenRequest
{
[Required]
public string Token { get; init; }
[Required]
public string RefreshToken { get; init; }
public RefreshTokenRequest(string token, string refreshToken)
{
Token = token;
RefreshToken = refreshToken;
}
}
public record UserResponse
{
public string Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
public List<string> Roles { get; init; }
public UserResponse(string id, string name, string email, List<string> roles)
{
Id = id;
Name = name;
Email = email;
Roles = roles;
}
}
public record TokenResponse
{
public string Token { get; init; }
public string RefreshToken { get; init; }
public TokenResponse(string token, string refreshToken)
{
Token = token;
RefreshToken = refreshToken;
}
}
Ya que tenemos los modelos y la interfaz de nuestro servicio, solo falta la implementación.
El primer paso es crear el constructor, en el que vamos a recibir servicios de utilidad.
private readonly UserManager<UserEntity> _userManager;
private readonly SignInManager<UserEntity> _signInManager;
private readonly IConfiguration _configuration;
private readonly IHttpContextService _httpContextService;
public UserService(UserManager<UserEntity> userManager, SignInManager<UserEntity> signInManager, IConfiguration configuration, IHttpContextService httpContextService)
{
_userManager = userManager;
_signInManager = signInManager;
_configuration = configuration;
_httpContextService = httpContextService;
}
Vemos que voy a utilizar los managers proporcionados por identity para trabajar con los usuarios.
Por otro lado, voy a necesitar IConfiguration para leer información relacionada con los tokens de acceso, como el tiempo que tarda en expirar el token, el tiempo que tarda en expirar el refresh token, el issuer, audience y key usados en la generación del token…
Por último, también vemos que recibo la interfaz IHttpContextService cuya implementación no es más que un wrapper de IHttpContextAccessor, que utilizo para obtener información del usuario que realiza la petición.
public class HttpContextService : IHttpContextService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public UserResponse GetContextUser()
{
return new UserResponse(
_httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier),
_httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Name),
_httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Email),
_httpContextAccessor.HttpContext.User.FindAll(ClaimTypes.Role)?.Select(c => c.Value)?.ToList());
}
}
En este punto, ya tenemos el constructor y sabemos para qué vamos a usar los objetos inyectados en éste. El siguiente paso es empezar a implementar nuestra interfaz.
Registro
El primer método que nos encontramos es SignUpAsync que, como su nombre indica, lo utilizaremos para realizar el registro de un usuario.
public async Task<TokenResponse> SignUpAsync(SignUpRequest userInfo, CancellationToken cancellationToken = default)
{
UserEntity user = userInfo.ToIdentityUser();
var result = await _userManager.CreateAsync(user, userInfo.Password);
if (!result.Succeeded)
return null;
return await GetTokenInfoAsync(user.UserName);
}
Lo que hace el método anterior es bastante sencillo. El DTO recibido se transforma en un UserEntity, que es el modelo con el que trabaja nuestra base de datos. El siguiente paso es crear el usuario utilizando el user manager y, si se crea correctamente, se crea el token que se va a retornar.
Si la creación por parte de CreateAsync falla, simplemente retorno un null porque no me interesa dar detalles sobre por qué ha fallado la creación del usuario.
El objeto que se retorna va a contener el token y el refresh token. La generación de estos se hace de la siguiente manera.
private async Task<TokenResponse> GetTokenInfoAsync(string userName)
{
int expirationMinutes = int.Parse(_configuration["Jwt:ExpireMinutes"]!);
var expiration = DateTime.UtcNow.AddMinutes(expirationMinutes);
var token = await GenerateTokenAsync(userName, expiration);
var user = await GenerateRefreshTokenAsync(userName);
var tokenInfo = new TokenResponse(token, user.RefreshToken);
return tokenInfo;
}
private async Task<string> GenerateTokenAsync(string userName, DateTime expiration)
{
var issuer = _configuration["Jwt:Issuer"];
var audience = _configuration["Jwt:Audience"];
var key = _configuration["Jwt:Key"];
;
SymmetricSecurityKey securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key!));
SigningCredentials credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
IList<Claim> claims = await GetUserClaimsAsync(userName);
JwtSecurityToken securityToken = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: expiration,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
private async Task<UserEntity> GenerateRefreshTokenAsync(string userName, CancellationToken cancellationToken = default)
{
var refreshTokenExpireMinutes = int.Parse(_configuration["Jwt:RefreshTokenExpireMinutes"]!);
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
var refreshToken = Convert.ToBase64String(randomNumber);
UserEntity user = await _userManager.FindByNameAsync(userName);
user.RefreshToken = refreshToken;
user.RefreshTokenExpiration = DateTime.UtcNow.AddMinutes(refreshTokenExpireMinutes);
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
return null;
return user;
}
La generación del token se hace en el método GenerateTokenAsync a partir del issuer, audience, claims, expiration y credentials, usando clases proporcionadas por identity
Por otro lado, para generar el refresh token se genera un array de 32 bytes que se guarda en la tabla de usuarios, en la base de datos, junto a su expiración. De esta manera, cuando se solicite el refresco del token, podemos validar si el token de refresco es correcto, está asociado al usuario que lo solicita y no ha expirado. Además, al refrescar el token, se genera un nuevo refresh token, por lo que aseguramos que un refresh token se va a utilizar un única vez, como máximo.
Login
Una vez que podemos registrar usuarios, el siguiente paso sería poder logarlos, para ello he añadido el código que se muestra a continuación.
public async Task<TokenResponse> SignInAsync(SignInRequest userInfo, CancellationToken cancellationToken = default)
{
SignInResult result = await _signInManager.PasswordSignInAsync(userInfo.UserName, userInfo.Password, false, false);
return result.Succeeded ? await GetTokenInfoAsync(userInfo.UserName) : null;
}
El código es análogo al anterior pero, en este caso, se utiliza sign in manager para validar los credenciales del usuario.
Del mismo modo que en el registro, si los credenciales no son correctos, no se retorna información del motivo. En caso contrario (el login sí es correcto) se genera el TokenResponse, que contiene el token de acceso y el refresh token
Borrado
Antes de mostrar el código creado para el borrado de un usuario, es necesario tener en cuenta ciertas reglas “de negocio” que se han tenido en cuenta en la implementación
-
Un usuario sin privilegios solo puede borrarse a si mismo
-
Un usuario super admin puede borrar a cualquier usuario sin privilegios
-
Un super admin no puede ser eliminado
Esas reglas son las que he creado para mi ejemplo, pero lo normal es que cada negocio tenga sus propias validaciones.
Para el borrado de un usuario, al igual que para su creación, me apoyo en user manager.
public async Task DeleteAsync(UserDeleteRequest deleteRequest, CancellationToken cancellationToken = default)
{
UserEntity user = await _userManager.FindByNameAsync(deleteRequest.UserName);
await ValidateEntityToDeleteAsync(user, cancellationToken);
await _userManager.DeleteAsync(user!);
}
private async Task ValidateEntityToDeleteAsync(UserEntity entity, CancellationToken cancellationToken = default)
{
if (entity == null)
throw new NotValidOperationException(ErrorCodes.ITEM_NOT_EXISTS, $"The user to remove does not exists");
var contextUser = _httpContextService.GetContextUser();
//Only superadmin can delete other users
if (!entity.Id.Equals(contextUser.Id, StringComparison.InvariantCultureIgnoreCase) &&
contextUser.Roles?.Contains(Roles.SUPERADMIN) != true)
throw new ForbidenActionException();
//A superadmin can not be deleted
if (await _userManager.IsInRoleAsync(entity, Roles.SUPERADMIN))
throw new ForbidenActionException();
}
Refresco del token
La ultima funcionalidad que ofrece el servicio que estamos implementando es la del refresco del token. Recibe un objeto con información del token y del refresh token. El primero de ellos se utiliza para extraer el nombre de usuario y, con este, recuperar de la base de datos el usuario, que tiene asociado un refresh token. El siguiente paso es validar si el refresh token asociado al usuario es el mismo que el recibido por el método y si no está caducado. Si las validaciones anteriores se cumplen, se retornará un nuevo token y refresh token para que el cliente pueda seguir operando.
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest refreshTokenRequest, CancellationToken cancellationToken = default)
{
var userName = GetUserNameFromToken(refreshTokenRequest);
if (userName == null)
return null;
var user = await _userManager.FindByNameAsync(userName);
if (user == null ||
user.RefreshToken != refreshTokenRequest.RefreshToken ||
user.RefreshTokenExpiration <= DateTime.UtcNow)
return null;
return await GetTokenInfoAsync(user.UserName);
}
Creando controlador para exponer nuestros endpoints
Teniendo nuestro servicio ya creado, podemos crear nuestro controlador, con un endpoint por cada funcionalidad.
public class UserController : BaseController
{
private readonly IUserService _service;
public UserController(IUserService service)
{
_service = service;
}
[HttpPost]
[Route("signin")]
[ProducesResponseType(typeof(PaginationResponse<TokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SignInAsync([FromBody]SignInRequest user, CancellationToken cancellationToken = default)
{
if (user == null)
return BadRequest();
if (!ModelState.IsValid)
return BadRequest();
TokenResponse tokenInfo = await _service.SignInAsync(user, cancellationToken);
return tokenInfo == null ? Unauthorized() : Ok(tokenInfo);
}
[HttpPost]
[Route("signup")]
[ProducesResponseType(typeof(PaginationResponse<TokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SignUpAsync([FromBody]SignUpRequest user, CancellationToken cancellationToken = default)
{
if (user == null)
return BadRequest();
if (!ModelState.IsValid)
return BadRequest();
TokenResponse tokenInfo = await _service.SignUpAsync(user, cancellationToken);
return tokenInfo == null ? BadRequest() : Ok(tokenInfo);
}
[HttpPut]
[Route("delete")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteAsync([FromBody]UserDeleteRequest deleteRequest, CancellationToken cancellationToken = default)
{
if (deleteRequest == null)
return BadRequest();
if (!ModelState.IsValid)
return BadRequest();
await _service.DeleteAsync(deleteRequest, cancellationToken);
return NoContent();
}
[HttpPost]
[Route("refreshToken")]
[ProducesResponseType(typeof(PaginationResponse<TokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> RefreshTokenAsync([FromBody]RefreshTokenRequest refreshTokenRequest, CancellationToken cancellationToken = default)
{
if (refreshTokenRequest == null)
return BadRequest();
if (!ModelState.IsValid)
return BadRequest();
TokenResponse tokenInfo = await _service.RefreshTokenAsync(refreshTokenRequest, cancellationToken);
return tokenInfo == null ? Unauthorized() : Ok(tokenInfo);
}
}
Añadiendo configuración
Ya solo falta añadir cierta configuración.
El primer paso es modificar nuestro appsettings para incluir valores de configuración necesarios.
"Jwt": {
"Issuer": "localhost",
"Audience": "localhost",
"Key": "pDtgszf3BMykVdV35nytr20ejg7gkXNZCD2RarZ4vXsPLrUa6Y",
"ExpireMinutes": "5",
"RefreshTokenExpireMinutes": "120"
}
Recordemos que algunos de estos parámetros de configuración los hemos utilizado en el servicio del punto anterior. Asimismo, es necesaria algo más de configuración desde nuestro program.
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
...
services.AddIdentity<UserEntity, IdentityRole>(o =>
{
o.Password.RequireDigit = true;
o.Password.RequireLowercase = true;
o.Password.RequireUppercase = true;
o.Password.RequireNonAlphanumeric = true;
o.Password.RequiredLength = 12;
o.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<TasksDbContext>()
.AddDefaultTokenProviders();
services.AddTransient<IHttpContextService, HttpContextService>();
services.AddTransient<IUserService, UserService>();
...
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!)),
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(int.Parse(configuration["Jwt:ExpireMinutes"]!)),
});
...
builder.Services.AddHostedService<HostedService>();
...
app.UseAuthentication();
app.UseAuthorization();
app.AddMiddlewares();
app.MapControllers();
app.Run();
Destacar que en este punto, es donde añadimos ciertas opciones sobre identity. En el ejemplo vemos que se configura para requerir dígitos, minúsculas, mayúsculas y símbolos en el password, además de una longitud mínima de 12 caracteres.
Añadiendo autorización a nuestros endpoints
Para proteger nuestros endpoint obligando a que sean llamados por alguien autorizado, solamente tenemos que añadir el atributo Authorize.
Además, podemos aplicar ciertas reglas a esa autorización, para indicar, por ejemplo, que se base en algún esquema, o que el usuario pertenezca a un rol o que cumpla alguna política que hayamos definido, para requerir algún claim, por ejemplo.
[Authorize]
...
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
...
[Authorize(Roles = Roles.SUPERADMIN)]
...
[Authorize(Policy = "SpainUser")]
...
Talk is cheap, show me the code
En este caso, os dejo un enlace a un repo en el que implemento distintas funcionalidades de asp.net, entre ellas todas las expuestas en este post. https://github.com/jorgediegocrespo/TasksWebApi