ASP.NET Core - Autenticação JWT com Refresh Token - I


 Neste artigo veremos a autenticação JWT com Refresh Token em uma aplicação ASP .NET Core.

Como eu já escrevi diversos artigos sobre o assunto vou realizar uma abordagem bem objetiva e direta sem entrar em detalhes nos conceitos envolvidos.

Para saber mais sobre os Json Web Tokens - JWT consulte meus artigos:

Neste artigo, que foi baseado em outros artigos sobre o assunto,  vamos criar uma API ASP .NET Core, proteger os seus endpoints, gerar o token JWT, registrar usuários e tratar com a autorização baseada em roles usando o Identity.

O que é um Refresh Token e porque precisamos deles ?

De forma bem objetiva, um Refresh Token ou token de atualização é um token especial usado para obter um Access Token ou token de acesso adicional. Isso permite que você tenha tokens de acesso de curta duração sem precisar coletar credenciais sempre que um deles expirar.

Assim, se estivermos usando um token de acesso por muito tempo, há uma chance maior de um hacker roubar nosso token e usá-lo de forma inadequada. Portanto, não é muito seguro usar o token de acesso por um longo período.

Os refresh tokens ou tokens de atualização são tipos de tokens que podem ser usados ​​para obter novos tokens de acesso. Quando os tokens de acesso expiram, podemos usar os tokens de atualização para obter um novo token de acesso. (Em geral, a vida útil de um token de atualização é maior que a vida útil de um token de acesso.)

Na grande parte dos artigos que encontramos na web o refresh token é gerado e armazenado em um banco de dados e isso sempre me incomodou, visto que não via muito sentido em ter que armazenar o token de atualização em uma base de dados. No entanto se a sua aplicação precisa forçar o usuário a refazer o login isso justificaria armazenar o token de atualização. Um exemplo deste caso seriam as aplicações bancárias que após um tempo de inatividade forçam o cliente a refazer o login.

Assim neste artigo iremos realizar as seguintes tarefas:

Para isso vamos usar os seguintes recursos:

A ASP.NET Core Identity é um sistema de associação que permite adicionar funcionalidade de login ao seu aplicativo onde os usuários podem criar uma conta e fazer login com um nome de usuário e senha, ou podem usar um provedor de login externo, como Facebook, Google, Conta da Microsoft, Twitter e muito mais.

O refresh_token dever ser devolvido para seu aplicativo junto com o token JWT principal no momento do login. O app então deverá armazenar o refresh_token internamente, assim como já faz com o access_token

Sempre que o usuário fizer login no aplicativo usando credenciais válidas, atualizaremos o token de atualização e o tempo de expiração do token na tabela de usuários dentro do banco de dados do Identity.

Após a expiração do token de acesso, se o usuário tentar novamente obter um recurso protegido do aplicativo, ele vai gerar um erro 401 não autorizado. Em seguida, o usuário pode tentar atualizar o token usando o token de acesso atual e o token de atualização.

No método de atualização, o aplicativo confirmará o token expirado e o token de atualização e,  se ambos forem válidos, o aplicativo emitirá um novo token de acesso e atualizará o token para o usuário. O usuário correspondente pode usar esse novo token para acessar recursos protegidos no aplicativo.

Se algo der errado, o token de atualização pode ser revogado, o que significa que quando o aplicativo tentar usá-lo para obter um novo token de acesso, essa solicitação será rejeitada e o usuário precisará inserir as credenciais novamente e se autenticar.


Criando o projeto Web API

Abra o VS 2022 Community e selecione a opção Create New Project;

Selecione o template ASP.NET Core Web API e informe o nome AspnJwt_RefreshToken;

A seguir defina as configurações conforme a figura abaixo e clique em Create;

A seguir vamos incluir os seguintes pacotes Nuget no projeto usando o comando install-package <nome> na janela Package Manager Console.

  1. Microsoft.EntityFrameworkCore.SqlServer
  2. Microsoft.AspNetCore.Identity.EntityFrameworkCore
  3. Microsoft.AspNetCore.Authentication.JwtBearer

Vamos criar no projeto a pasta Models onde iremos criar as entidades usadas em nosso projeto.

Nesta pasta crie a classe ApplicationUser que herda de IdentityUser e representa um usuário da aplicação:

public class ApplicationUser : IdentityUser
{
    public string? RefreshToken { get; set; }
    public DateTime RefreshTokenExpiryTime { get; set; }
}

Ainda nesta pasta crie a classe AppDbContext que vai herdar da classe IdentityDbContext<ApplicationUser> :

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace AspnJwt_RefreshToken.Models;

public class AppDbContext : IdentityDbContext<ApplicationUser>
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {}
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}

Esta classe representa o contexto do EF Core e realiza o mapeamento ORM.

Continuando vamos criar na pasta Models as seguintes classes que darão suporte para a nossa implementação.

1 - UserRoles

public static class UserRoles
{
    public const string Admin = "Admin";
    public const string User = "User";
}

Nesta classe definimos dois perfis que iremos usar: Admin e User.

2-  Response

public class Response
{
    public string? Status { get; set; }
    public string? Message { get; set; }
}

Esta classe será usada para tratar o response após o registro e o login do usuário.

3- TokenModel

public class TokenModel
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; }
}

Nesta classe definimos as propriedades AccesToken e RefreshToken que representam o token e o refresh token.

4- LoginModel

using System.ComponentModel.DataAnnotations;

namespace AspnJwt_RefreshToken.Models;

public class LoginModel
{
    [Required(ErrorMessage = "User Name is required")]
    public string? Username { get; set; }

    [Required(ErrorMessage = "Password is required")]
    public string? Password { get; set; }
}

Esta classe usaremos para realizar o login.

5- RegisterModel

using System.ComponentModel.DataAnnotations;

namespace AspnJwt_RefreshToken.Models;

public class RegisterModel
{
    [Required(ErrorMessage = "User Name is required")]
    public string? Username { get; set; }

    [EmailAddress]
    [Required(ErrorMessage = "Email is required")]
    public string? Email { get; set; }

    [Required(ErrorMessage = "Password is required")]
    public string? Password { get; set; }
}

Esta classe será usada para registrar o usuário.

Vamos definir no arquivo appsettings.json a string de conexão e as configurações usadas para criar o token JWT e a nossa chave privada.

{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=desktop-bhp8771\\sqlexpress;Initial Catalog=JwtRefreshTokenDB;
Integrated Security=True"

  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "JWT": {
    "ValidAudience": "http://localhost:7043",
    "ValidIssuer": "http://localhost:5043",
    "SecretKey": "Minha@Super#Secreta&Chave*Privada!2022",
    "TokenValidityInMinutes": 30,
    "RefreshTokenValidityInMinutes": 60

  }
}

- SecretKey é uma string secreta que é usada para assinatura do token;
- ValidIssuer - Indica a parte que esta emitindo o JWT;
- ValidAudience - Indica os destinatários do JWT;

Criamos duas seções neste arquivo. A seção ConnectionStrings onde definimos a string de conexão e a seção JWT onde definimos valores de configuração para a autenticação JWT . Definimos um tempo de expiração para o token de 30 minutos e de 60 minutos para o refresh token.

Agora vamos registrar os serviços na classe Program:

using AspnJwt_RefreshToken.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Entity Framework
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder
                           .Configuration.GetConnectionString("DefaultConnection")));

// Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

// Authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})

// Jwt Bearer
.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new TokenValidationParameters()
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ClockSkew = TimeSpan.Zero,

        ValidAudience = builder.Configuration["JWT:ValidAudience"],
        ValidIssuer = builder.Configuration["JWT:ValidIssuer"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.
                           GetBytes(builder.Configuration["JWT:SecretKey"]))
    };
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

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

app.MapControllers();

app.Run();

Nesta classe registramos os serviços para o contexto do EF Core definindo o provedor usado e a string de conexão, o serviço do Identity e da autenticação e do JWT Bearer.

Podemos agora aplicar o Migrations usando a abordagem Code-First e gerar o banco de dados onde iremos armazenar o refresh token.

Para isso vamos abrir uma janela do Package Manager Console e usar a ferramenta EF Core Tools dotnet ef emitindo os comandos:

dotnet ef migrations add Inicial  - Cria o script de migração

dotnet ef database update - Aplica o script e gera o banco e as tabelas

Ao final podemos confirmar a criação do banco de dados e das tabelas do Identity usando o SQL Server Management Studio:

Criando o controller AuthenticateController

Vamos criar na pasta Controllers do projeto o controlador AutenticateController onde iremos implementar as funcionalidades para :

using AspnJwt_RefreshToken.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;

namespace AspnJwt_RefreshToken.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AuthenticateController : ControllerBase
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;
        private readonly IConfiguration _configuration;

        public AuthenticateController(
            UserManager<ApplicationUser> userManager,
            RoleManager<IdentityRole> roleManager,
            IConfiguration configuration)
        {
            _userManager = userManager;
            _roleManager = roleManager;
            _configuration = configuration;
        }

        [HttpPost]
        [Route("login")]
        public async Task<IActionResult> Login([FromBody] LoginModel model)
        {
            var user = await _userManager.FindByNameAsync(model.Username);

            if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
            {
                var userRoles = await _userManager.GetRolesAsync(user);

                var authClaims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, user.UserName),
                    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
                };

                foreach (var userRole in userRoles)
                {
                    authClaims.Add(new Claim(ClaimTypes.Role, userRole));
                }

                var token = CreateToken(authClaims);
                var refreshToken = GenerateRefreshToken();

                _ = int.TryParse(_configuration["JWT:RefreshTokenValidityInMinutes"],
                    out int refreshTokenValidityInMinutes);

                user.RefreshToken = refreshToken;
                user.RefreshTokenExpiryTime = DateTime.Now.AddMinutes(refreshTokenValidityInMinutes);

                await _userManager.UpdateAsync(user);

                return Ok(new
                {
                    Token = new JwtSecurityTokenHandler().WriteToken(token),
                    RefreshToken = refreshToken,
                    Expiration = token.ValidTo
                });
            }
            return Unauthorized();
        }

        [HttpPost]
        [Route("register-admin")]

        public async Task<IActionResult> RegisterAdmin([FromBody] RegisterModel model)
        {
            var userExists = await _userManager.FindByNameAsync(model.Username);

            if (userExists != null)
            {
                return StatusCode(StatusCodes.Status500InternalServerError,
                    new Response { Status = "Error", Message = "User already exists!" });
            }

            ApplicationUser user = new()
            {
                Email = model.Email,
                SecurityStamp = Guid.NewGuid().ToString(),
                UserName = model.Username
            };

            var result = await _userManager.CreateAsync(user, model.Password);

            if (!result.Succeeded)
            {
                return StatusCode(StatusCodes.Status500InternalServerError,
                    new Response { Status = "Error", Message = "User creation failed!" });
            }

            if (!await _roleManager.RoleExistsAsync(UserRoles.Admin))
                 await _roleManager.CreateAsync(new IdentityRole(UserRoles.Admin));

            if (!await _roleManager.RoleExistsAsync(UserRoles.User))
                 await _roleManager.CreateAsync(new IdentityRole(UserRoles.User));

            if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
                await _userManager.AddToRoleAsync(user, UserRoles.Admin);

            if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
                await _userManager.AddToRoleAsync(user, UserRoles.User);

            return Ok(new Response { Status = "Success", Message = "User created successfully!" });
        }

        [HttpPost]
        [Route("register")]

        public async Task<IActionResult> Register([FromBody] RegisterModel model)
        {
            var userExists = await _userManager.FindByNameAsync(model.Username);

            if (userExists != null)
            {
                return StatusCode(StatusCodes.Status500InternalServerError,
                    new Response { Status = "Error", Message = "User already exists!" });
            }

            ApplicationUser user = new()
            {
                Email = model.Email,
                SecurityStamp = Guid.NewGuid().ToString(),
                UserName = model.Username
            };
            var result = await _userManager.CreateAsync(user, model.Password);

            if (!result.Succeeded)
            {
                return StatusCode(StatusCodes.Status500InternalServerError,
                       new Response { Status = "Error", Message = "User creation failed." });
            }

            return Ok(new Response { Status = "Success", Message = "User created successfully!" });
        }

        [HttpPost]
        [Route("refresh-token")]

        public async Task<IActionResult> RefreshToken(TokenModel tokenModel)
        {
            if (tokenModel is null)
            {
                return BadRequest("Invalid client request");
            }

            string? accessToken = tokenModel.AccessToken;
            string? refreshToken = tokenModel.RefreshToken;

            var principal = GetPrincipalFromExpiredToken(accessToken);
            if (principal == null)
            {
                return BadRequest("Invalid access token/refresh token");
            }

            string username = principal.Identity.Name;
            var user = await _userManager.FindByNameAsync(username);

            if (user == null || user.RefreshToken != refreshToken ||
                       user.RefreshTokenExpiryTime <= DateTime.Now)
            {
                return BadRequest("Invalid access token/refresh token");
            }

            var newAccessToken = CreateToken(principal.Claims.ToList());
            var newRefreshToken = GenerateRefreshToken();

            user.RefreshToken = newRefreshToken;
            await _userManager.UpdateAsync(user);

            return new ObjectResult(new
            {
                accessToken = new JwtSecurityTokenHandler().WriteToken(newAccessToken),
                refreshToken = newRefreshToken
            });
        }

        [Authorize]
        [HttpPost]
        [Route("revoke/{username}")]
        public async Task<IActionResult> Revoke(string username)
        {
            var user = await _userManager.FindByNameAsync(username);
            if (user == null) return BadRequest("Invalid user name");

            user.RefreshToken = null;
            await _userManager.UpdateAsync(user);

            return NoContent();
        }

        [Authorize]
        [HttpPost]
        [Route("revoke-all")]

        public async Task<IActionResult> RevokeAll()
        {
            var users = _userManager.Users.ToList();
            foreach (var user in users)
            {
                user.RefreshToken = null;
                await _userManager.UpdateAsync(user);
            }

            return NoContent();
        }

        private JwtSecurityToken CreateToken(List<Claim> authClaims)
        {
            var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8
                                 .GetBytes(_configuration["JWT:SecretKey"]));
            _ = int.TryParse(_configuration["JWT:TokenValidityInMinutes"], out
                int tokenValidityInMinutes);

            var token = new JwtSecurityToken(
                issuer: _configuration["JWT:ValidIssuer"],
                audience: _configuration["JWT:ValidAudience"],
                expires: DateTime.Now.AddMinutes(tokenValidityInMinutes),
                claims: authClaims,
                signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
                );

            return token;
        }

        private static string GenerateRefreshToken()
        {
            var randomNumber = new byte[64];
            using var rng = RandomNumberGenerator.Create();
            rng.GetBytes(randomNumber);
            return Convert.ToBase64String(randomNumber);
        }

        private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
        {
            var tokenValidationParameters = new TokenValidationParameters
            {
                ValidateAudience = false,
                ValidateIssuer = false,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8
                                   .GetBytes(_configuration["JWT:SecretKey"])),
                ValidateLifetime = false
            };

            var tokenHandler = new JwtSecurityTokenHandler();
           

            var principal = tokenHandler.ValidateToken(token, tokenValidationParameters,
                            out SecurityToken securityToken);

            if (securityToken is not JwtSecurityToken jwtSecurityToken ||
                      !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256,
                                     StringComparison.InvariantCultureIgnoreCase))
                throw new SecurityTokenException("Invalid token");

            return principal;
        }
    }
}

Para testar a nossa implementação vamos aplicar o atributo [Authorize] no controlador WeatherForecastController do projeto e a seguir vamos usar a nossa implementação.

Executando o projeto teremos o seguinte resultado exibido na interface do Swagger:

Na próxima parte do artigo iremos testar a nossa implementação.

"Se o SENHOR não edificar a casa, em vão trabalham os que a edificam; se o SENHOR não guardar a cidade, em vão vigia a sentinela."
Salmos 127:1

Referências:


José Carlos Macoratti